@happyvertical/smrt-svelte 0.51.11 → 0.51.13
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 +27 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts +129 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.js +331 -0
- package/dist/browser-ai/adapters/llm/bitgpu-inference-backend.test.js +464 -0
- package/dist/browser-ai/adapters/llm/index.d.ts +2 -0
- package/dist/browser-ai/adapters/llm/index.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/index.js +2 -0
- package/dist/browser-ai/adapters/llm/types.d.ts +18 -0
- package/dist/browser-ai/adapters/llm/types.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts +67 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.d.ts.map +1 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.js +364 -0
- package/dist/browser-ai/adapters/llm/webllm-inference-backend.test.js +646 -0
- package/dist/browser-ai/adapters/llm/webllm.d.ts.map +1 -1
- package/dist/browser-ai/adapters/llm/webllm.js +19 -2
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte +190 -0
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts +17 -0
- package/dist/browser-ai/svelte/components/ModelStatusControl.svelte.d.ts.map +1 -0
- package/dist/browser-ai/svelte/components/__tests__/ModelStatusControl.test.js +172 -0
- package/dist/browser-ai/svelte/index.d.ts +2 -1
- package/dist/browser-ai/svelte/index.d.ts.map +1 -1
- package/dist/browser-ai/svelte/index.js +3 -1
- package/dist/i18n/strings.browser-ai.d.ts +4 -0
- package/dist/i18n/strings.browser-ai.d.ts.map +1 -0
- package/dist/i18n/strings.browser-ai.js +4 -0
- package/package.json +19 -15
|
@@ -42,12 +42,19 @@ export class WebLLMAdapter {
|
|
|
42
42
|
await this.unloadModel();
|
|
43
43
|
}
|
|
44
44
|
if (this._initState === 'initializing') {
|
|
45
|
+
// The executor form is required here: `Promise.withResolvers` is not in
|
|
46
|
+
// the `lib` this package's typecheck resolves against.
|
|
45
47
|
return new Promise((resolve, reject) => {
|
|
46
48
|
const check = () => {
|
|
47
49
|
if (this._initState === 'ready')
|
|
48
50
|
resolve();
|
|
49
51
|
else if (this._initState === 'error')
|
|
50
52
|
reject(new Error('Initialization failed'));
|
|
53
|
+
else if (this._initState !== 'initializing')
|
|
54
|
+
// A concurrent `unloadModel()` reset the state under this waiter.
|
|
55
|
+
// It settles only on `ready` or `error`, so without this branch the
|
|
56
|
+
// poll waits forever — and `unload()` is reachable at any time.
|
|
57
|
+
reject(new Error('Initialization was released before it completed'));
|
|
51
58
|
else
|
|
52
59
|
setTimeout(check, 100);
|
|
53
60
|
};
|
|
@@ -104,11 +111,21 @@ export class WebLLMAdapter {
|
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
async importWebLLM() {
|
|
114
|
+
// A host-supplied loader is the only form that works in a browser: the
|
|
115
|
+
// default resolves a VARIABLE specifier (so this module never hard-depends
|
|
116
|
+
// on an optional peer), which the browser cannot resolve on its own.
|
|
117
|
+
const { loadModule } = this.options;
|
|
107
118
|
try {
|
|
108
|
-
return
|
|
119
|
+
return (loadModule
|
|
120
|
+
? await loadModule()
|
|
121
|
+
: await importOptional('@mlc-ai/web-llm'));
|
|
109
122
|
}
|
|
110
123
|
catch {
|
|
111
|
-
throw new InitializationError('webllm',
|
|
124
|
+
throw new InitializationError('webllm', loadModule
|
|
125
|
+
? 'The supplied WebLLM loader rejected while loading @mlc-ai/web-llm.'
|
|
126
|
+
: '@mlc-ai/web-llm could not be loaded. Install it, or pass a ' +
|
|
127
|
+
'`loadModule` that imports it statically: ' +
|
|
128
|
+
'`() => import("@mlc-ai/web-llm")`.');
|
|
112
129
|
}
|
|
113
130
|
}
|
|
114
131
|
async getCapabilities() {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Model status control
|
|
4
|
+
*
|
|
5
|
+
* Presents ONE inference backend's model lifecycle to a user: whether it is
|
|
6
|
+
* loaded, a way to load or release it, and live download progress. Driven
|
|
7
|
+
* entirely by the backend's own `status`/`progress`/`subscribe` surface, so it
|
|
8
|
+
* works anywhere — a shell dock, a settings panel, or a workbench that has no
|
|
9
|
+
* `<Provider>` ancestor. When a `<Provider ai>` IS configured, pass the local
|
|
10
|
+
* backend built from the app-state-managed adapter and this control reflects
|
|
11
|
+
* the same warm-cached model the rest of the app uses.
|
|
12
|
+
*
|
|
13
|
+
* An `unavailable` backend is a first-class state, not an error: a device
|
|
14
|
+
* without WebGPU is expected to fall through to the route backend, and the
|
|
15
|
+
* message says so rather than offering a Load button that cannot succeed.
|
|
16
|
+
*/
|
|
17
|
+
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
18
|
+
import { Button } from '@happyvertical/smrt-ui/ui';
|
|
19
|
+
import type {
|
|
20
|
+
InferenceBackend,
|
|
21
|
+
InferenceBackendStatus,
|
|
22
|
+
InferenceProgress,
|
|
23
|
+
} from '@happyvertical/smrt-web/ai';
|
|
24
|
+
import { M } from '../../../i18n/strings.browser-ai.js';
|
|
25
|
+
import DownloadProgress from './DownloadProgress.svelte';
|
|
26
|
+
|
|
27
|
+
export interface Props {
|
|
28
|
+
/** The backend whose model lifecycle this control presents. */
|
|
29
|
+
backend: InferenceBackend;
|
|
30
|
+
/** Human label for the model, e.g. "On-device model". */
|
|
31
|
+
label?: string;
|
|
32
|
+
/** Offer a release action once the model is ready. */
|
|
33
|
+
allowUnload?: boolean;
|
|
34
|
+
/** Extra class(es) for layout in a host. */
|
|
35
|
+
class?: string;
|
|
36
|
+
/** Called when a load or unload rejects (also rendered inline). */
|
|
37
|
+
onerror?: (error: Error) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
backend,
|
|
42
|
+
label = 'Local model',
|
|
43
|
+
allowUnload = true,
|
|
44
|
+
class: className = '',
|
|
45
|
+
onerror,
|
|
46
|
+
}: Props = $props();
|
|
47
|
+
|
|
48
|
+
const { t } = useI18n();
|
|
49
|
+
|
|
50
|
+
const STATUS_LABEL: Record<InferenceBackendStatus, string> = {
|
|
51
|
+
unavailable: 'Not available on this device',
|
|
52
|
+
idle: 'Not loaded',
|
|
53
|
+
loading: 'Loading…',
|
|
54
|
+
ready: 'Ready',
|
|
55
|
+
error: 'Load failed',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let busy = $state(false);
|
|
59
|
+
let failure = $state<string | null>(null);
|
|
60
|
+
|
|
61
|
+
// `backend.status`/`backend.progress` are plain properties, so nothing tells
|
|
62
|
+
// this component when they change. `revision` is the subscription's pulse: the
|
|
63
|
+
// derived re-reads the LIVE backend properties each time it ticks, which keeps
|
|
64
|
+
// the initial render correct (no effect needed for it) and follows a `backend`
|
|
65
|
+
// prop swap, since reading the prop inside the derived is itself reactive.
|
|
66
|
+
let revision = $state(0);
|
|
67
|
+
const snapshot = $derived.by(() => {
|
|
68
|
+
void revision;
|
|
69
|
+
return { status: backend.status, progress: backend.progress };
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
$effect(() => {
|
|
73
|
+
const current = backend;
|
|
74
|
+
// No bump here: the derived reads `backend` itself, so a prop swap already
|
|
75
|
+
// re-derives. Bumping from the effect body would read AND write `revision`,
|
|
76
|
+
// which Svelte treats as an update loop.
|
|
77
|
+
return current.subscribe?.(() => {
|
|
78
|
+
revision += 1;
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function run(
|
|
83
|
+
action: (() => void | Promise<void>) | undefined,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
if (!action) return;
|
|
86
|
+
busy = true;
|
|
87
|
+
failure = null;
|
|
88
|
+
try {
|
|
89
|
+
await action();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
92
|
+
failure = err.message;
|
|
93
|
+
onerror?.(err);
|
|
94
|
+
} finally {
|
|
95
|
+
busy = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<div class="model-status {className}" data-status={snapshot.status}>
|
|
101
|
+
<div class="header">
|
|
102
|
+
<span class="label">{label}</span>
|
|
103
|
+
<span class="state" role="status">{STATUS_LABEL[snapshot.status]}</span>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{#if snapshot.status === 'unavailable'}
|
|
107
|
+
<p class="hint">
|
|
108
|
+
{t(M['ui.model_status.webgpu_unavailable'])}
|
|
109
|
+
</p>
|
|
110
|
+
{:else if snapshot.status === 'loading'}
|
|
111
|
+
<DownloadProgress
|
|
112
|
+
progress={snapshot.progress ?? null}
|
|
113
|
+
label="Downloading model"
|
|
114
|
+
showPercent
|
|
115
|
+
showBytes
|
|
116
|
+
/>
|
|
117
|
+
{/if}
|
|
118
|
+
|
|
119
|
+
{#if failure}
|
|
120
|
+
<p class="error" role="alert">{failure}</p>
|
|
121
|
+
{/if}
|
|
122
|
+
|
|
123
|
+
<div class="actions">
|
|
124
|
+
{#if (snapshot.status === 'idle' || snapshot.status === 'error') && backend.load}
|
|
125
|
+
<Button onclick={() => run(() => backend.load?.())} disabled={busy}>
|
|
126
|
+
{snapshot.status === 'error' ? 'Retry' : 'Load model'}
|
|
127
|
+
</Button>
|
|
128
|
+
{:else if snapshot.status === 'ready' && allowUnload && backend.unload}
|
|
129
|
+
<Button
|
|
130
|
+
variant="secondary"
|
|
131
|
+
onclick={() => run(() => backend.unload?.())}
|
|
132
|
+
disabled={busy}
|
|
133
|
+
>
|
|
134
|
+
Unload
|
|
135
|
+
</Button>
|
|
136
|
+
{/if}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<style>
|
|
141
|
+
.model-status {
|
|
142
|
+
display: flex;
|
|
143
|
+
flex-direction: column;
|
|
144
|
+
gap: var(--smrt-spacing-sm, 8px);
|
|
145
|
+
padding: var(--smrt-spacing-md, 12px);
|
|
146
|
+
background: var(--smrt-color-surface-container-low, #f9fafb);
|
|
147
|
+
border-radius: var(--smrt-radius-medium, 8px);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.header {
|
|
151
|
+
display: flex;
|
|
152
|
+
justify-content: space-between;
|
|
153
|
+
align-items: baseline;
|
|
154
|
+
gap: var(--smrt-spacing-sm, 8px);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.label {
|
|
158
|
+
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
159
|
+
color: var(--smrt-color-on-surface, #1a1c1e);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.state {
|
|
163
|
+
font: var(--smrt-typography-body-small-font, 0.8125rem / 1.2 sans-serif);
|
|
164
|
+
color: var(--smrt-color-on-surface-variant, #44474f);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
.model-status[data-status='ready'] .state {
|
|
168
|
+
color: var(--smrt-color-primary, #005ac1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.model-status[data-status='error'] .state {
|
|
172
|
+
color: var(--smrt-color-error, #ba1a1a);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.hint,
|
|
176
|
+
.error {
|
|
177
|
+
margin: 0;
|
|
178
|
+
font: var(--smrt-typography-body-small-font, 0.8125rem / 1.2 sans-serif);
|
|
179
|
+
color: var(--smrt-color-on-surface-variant, #44474f);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.error {
|
|
183
|
+
color: var(--smrt-color-error, #ba1a1a);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.actions {
|
|
187
|
+
display: flex;
|
|
188
|
+
gap: var(--smrt-spacing-sm, 8px);
|
|
189
|
+
}
|
|
190
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { InferenceBackend } from '@happyvertical/smrt-web/ai';
|
|
2
|
+
export interface Props {
|
|
3
|
+
/** The backend whose model lifecycle this control presents. */
|
|
4
|
+
backend: InferenceBackend;
|
|
5
|
+
/** Human label for the model, e.g. "On-device model". */
|
|
6
|
+
label?: string;
|
|
7
|
+
/** Offer a release action once the model is ready. */
|
|
8
|
+
allowUnload?: boolean;
|
|
9
|
+
/** Extra class(es) for layout in a host. */
|
|
10
|
+
class?: string;
|
|
11
|
+
/** Called when a load or unload rejects (also rendered inline). */
|
|
12
|
+
onerror?: (error: Error) => void;
|
|
13
|
+
}
|
|
14
|
+
declare const ModelStatusControl: import("svelte").Component<Props, {}, "">;
|
|
15
|
+
type ModelStatusControl = ReturnType<typeof ModelStatusControl>;
|
|
16
|
+
export default ModelStatusControl;
|
|
17
|
+
//# sourceMappingURL=ModelStatusControl.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ModelStatusControl.svelte.d.ts","sourceRoot":"","sources":["../../../../src/browser-ai/svelte/components/ModelStatusControl.svelte.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EACV,gBAAgB,EAGjB,MAAM,4BAA4B,CAAC;AAKpC,MAAM,WAAW,KAAK;IACpB,+DAA+D;IAC/D,OAAO,EAAE,gBAAgB,CAAC;IAC1B,yDAAyD;IACzD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sDAAsD;IACtD,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,4CAA4C;IAC5C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mEAAmE;IACnE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAyGD,QAAA,MAAM,kBAAkB,2CAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ModelStatusControl` — the user-facing model lifecycle surface.
|
|
3
|
+
*
|
|
4
|
+
* The states that matter here are the ones a user acts on: whether the model is
|
|
5
|
+
* loaded, whether it can be loaded on this device at all, and what a download
|
|
6
|
+
* is doing. The `unavailable` case is asserted explicitly because offering a
|
|
7
|
+
* Load button on a device that cannot run the model is the failure this control
|
|
8
|
+
* exists to prevent.
|
|
9
|
+
*/
|
|
10
|
+
import { expectNoA11yViolations } from '@happyvertical/smrt-ui/test-support/a11y';
|
|
11
|
+
import { render, screen } from '@testing-library/svelte';
|
|
12
|
+
import userEvent from '@testing-library/user-event';
|
|
13
|
+
import { tick } from 'svelte';
|
|
14
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import ModelStatusControl from '../ModelStatusControl.svelte';
|
|
16
|
+
function makeBackend(initial = {}) {
|
|
17
|
+
let status = initial.status ?? 'idle';
|
|
18
|
+
let progress;
|
|
19
|
+
const listeners = new Set();
|
|
20
|
+
const backend = {
|
|
21
|
+
id: 'local',
|
|
22
|
+
kind: 'local',
|
|
23
|
+
get status() {
|
|
24
|
+
return status;
|
|
25
|
+
},
|
|
26
|
+
get progress() {
|
|
27
|
+
return progress;
|
|
28
|
+
},
|
|
29
|
+
loadCalls: 0,
|
|
30
|
+
unloadCalls: 0,
|
|
31
|
+
loadImpl: async () => { },
|
|
32
|
+
setStatus(next) {
|
|
33
|
+
status = next;
|
|
34
|
+
for (const listener of [...listeners])
|
|
35
|
+
listener();
|
|
36
|
+
},
|
|
37
|
+
setProgress(next) {
|
|
38
|
+
progress = next;
|
|
39
|
+
for (const listener of [...listeners])
|
|
40
|
+
listener();
|
|
41
|
+
},
|
|
42
|
+
async load() {
|
|
43
|
+
backend.loadCalls += 1;
|
|
44
|
+
await backend.loadImpl();
|
|
45
|
+
},
|
|
46
|
+
async unload() {
|
|
47
|
+
backend.unloadCalls += 1;
|
|
48
|
+
status = 'idle';
|
|
49
|
+
for (const listener of [...listeners])
|
|
50
|
+
listener();
|
|
51
|
+
},
|
|
52
|
+
async chat() {
|
|
53
|
+
return { content: '' };
|
|
54
|
+
},
|
|
55
|
+
async *stream() {
|
|
56
|
+
yield '';
|
|
57
|
+
},
|
|
58
|
+
subscribe(listener) {
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
listeners.delete(listener);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
return backend;
|
|
66
|
+
}
|
|
67
|
+
describe('ModelStatusControl', () => {
|
|
68
|
+
it('offers a load action when the model is not loaded', async () => {
|
|
69
|
+
const backend = makeBackend({ status: 'idle' });
|
|
70
|
+
render(ModelStatusControl, { props: { backend } });
|
|
71
|
+
expect(screen.getByRole('status')).toHaveTextContent('Not loaded');
|
|
72
|
+
await userEvent.click(screen.getByRole('button', { name: 'Load model' }));
|
|
73
|
+
expect(backend.loadCalls).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
it('offers a release action once the model is ready', async () => {
|
|
76
|
+
const backend = makeBackend({ status: 'ready' });
|
|
77
|
+
render(ModelStatusControl, { props: { backend } });
|
|
78
|
+
expect(screen.getByRole('status')).toHaveTextContent('Ready');
|
|
79
|
+
await userEvent.click(screen.getByRole('button', { name: 'Unload' }));
|
|
80
|
+
expect(backend.unloadCalls).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
it('hides the release action when the host opts out', () => {
|
|
83
|
+
const backend = makeBackend({ status: 'ready' });
|
|
84
|
+
render(ModelStatusControl, {
|
|
85
|
+
props: { backend, allowUnload: false },
|
|
86
|
+
});
|
|
87
|
+
expect(screen.queryByRole('button', { name: 'Unload' })).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
it('never offers a load action on a device that cannot run the model', () => {
|
|
90
|
+
const backend = makeBackend({ status: 'unavailable' });
|
|
91
|
+
render(ModelStatusControl, { props: { backend } });
|
|
92
|
+
expect(screen.getByRole('status')).toHaveTextContent('Not available on this device');
|
|
93
|
+
// The point of the state: no affordance that cannot succeed.
|
|
94
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
95
|
+
expect(screen.getByText(/will use the server route/)).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
it('offers no action for a backend that cannot load or unload', () => {
|
|
98
|
+
// A route-kind backend defines neither method and always reports `ready`;
|
|
99
|
+
// offering an Unload that silently does nothing is the same defect as
|
|
100
|
+
// offering a Load on an unavailable device.
|
|
101
|
+
const backend = makeBackend({ status: 'ready' });
|
|
102
|
+
// "A backend with no unload" is not hypothetical: it is the route backend.
|
|
103
|
+
delete backend.load;
|
|
104
|
+
delete backend.unload;
|
|
105
|
+
render(ModelStatusControl, { props: { backend } });
|
|
106
|
+
expect(screen.getByRole('status')).toHaveTextContent('Ready');
|
|
107
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
it('offers no load action for an idle backend without load', () => {
|
|
110
|
+
const backend = makeBackend({ status: 'idle' });
|
|
111
|
+
delete backend.load;
|
|
112
|
+
render(ModelStatusControl, { props: { backend } });
|
|
113
|
+
expect(screen.getByRole('status')).toHaveTextContent('Not loaded');
|
|
114
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
it('renders download progress while loading', () => {
|
|
117
|
+
const backend = makeBackend({ status: 'loading' });
|
|
118
|
+
backend.setProgress({
|
|
119
|
+
state: 'downloading',
|
|
120
|
+
bytesLoaded: 250_000,
|
|
121
|
+
bytesTotal: 500_000,
|
|
122
|
+
percent: 50,
|
|
123
|
+
});
|
|
124
|
+
render(ModelStatusControl, { props: { backend } });
|
|
125
|
+
expect(screen.getByRole('status')).toHaveTextContent('Loading');
|
|
126
|
+
expect(screen.getByText('Downloading model')).toBeInTheDocument();
|
|
127
|
+
expect(screen.getByText('50%')).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
it('offers a retry after a failed load', async () => {
|
|
130
|
+
const backend = makeBackend({ status: 'error' });
|
|
131
|
+
render(ModelStatusControl, { props: { backend } });
|
|
132
|
+
expect(screen.getByRole('status')).toHaveTextContent('Load failed');
|
|
133
|
+
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
|
134
|
+
expect(backend.loadCalls).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
it('surfaces a rejected load inline and to the host', async () => {
|
|
137
|
+
const backend = makeBackend({ status: 'idle' });
|
|
138
|
+
backend.loadImpl = async () => {
|
|
139
|
+
throw new Error('out of memory');
|
|
140
|
+
};
|
|
141
|
+
const onerror = vi.fn();
|
|
142
|
+
render(ModelStatusControl, { props: { backend, onerror } });
|
|
143
|
+
await userEvent.click(screen.getByRole('button', { name: 'Load model' }));
|
|
144
|
+
await tick();
|
|
145
|
+
expect(await screen.findByRole('alert')).toHaveTextContent('out of memory');
|
|
146
|
+
expect(onerror).toHaveBeenCalledWith(expect.any(Error));
|
|
147
|
+
});
|
|
148
|
+
it('follows a backend status change without a host re-render', async () => {
|
|
149
|
+
const backend = makeBackend({ status: 'idle' });
|
|
150
|
+
render(ModelStatusControl, { props: { backend } });
|
|
151
|
+
expect(screen.getByRole('status')).toHaveTextContent('Not loaded');
|
|
152
|
+
backend.setStatus('ready');
|
|
153
|
+
await tick();
|
|
154
|
+
expect(screen.getByRole('status')).toHaveTextContent('Ready');
|
|
155
|
+
});
|
|
156
|
+
it('has no accessibility violations in any state', async () => {
|
|
157
|
+
for (const status of [
|
|
158
|
+
'unavailable',
|
|
159
|
+
'idle',
|
|
160
|
+
'loading',
|
|
161
|
+
'ready',
|
|
162
|
+
'error',
|
|
163
|
+
]) {
|
|
164
|
+
const backend = makeBackend({ status });
|
|
165
|
+
const { container, unmount } = render(ModelStatusControl, {
|
|
166
|
+
props: { backend },
|
|
167
|
+
});
|
|
168
|
+
await expectNoA11yViolations(container);
|
|
169
|
+
unmount();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
import AILoadingOverlay from './components/AILoadingOverlay.svelte';
|
|
10
10
|
import CapabilityGate from './components/CapabilityGate.svelte';
|
|
11
11
|
import DownloadProgress from './components/DownloadProgress.svelte';
|
|
12
|
+
import ModelStatusControl from './components/ModelStatusControl.svelte';
|
|
12
13
|
import STTTest from './components/STTTest.svelte';
|
|
13
14
|
import VoiceInput from './components/VoiceInput.svelte';
|
|
14
|
-
export { AILoadingOverlay, CapabilityGate, DownloadProgress, STTTest, VoiceInput, };
|
|
15
|
+
export { AILoadingOverlay, CapabilityGate, DownloadProgress, ModelStatusControl, STTTest, VoiceInput, };
|
|
15
16
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/browser-ai/svelte/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,gBAAgB,MAAM,sCAAsC,CAAC;AACpE,OAAO,cAAc,MAAM,oCAAoC,CAAC;AAChE,OAAO,gBAAgB,MAAM,sCAAsC,CAAC;AACpE,OAAO,OAAO,MAAM,6BAA6B,CAAC;AAClD,OAAO,UAAU,MAAM,gCAAgC,CAAC;AAGxD,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,gBAAgB,EAChB,OAAO,EACP,UAAU,GACX,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/browser-ai/svelte/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,gBAAgB,MAAM,sCAAsC,CAAC;AACpE,OAAO,cAAc,MAAM,oCAAoC,CAAC;AAChE,OAAO,gBAAgB,MAAM,sCAAsC,CAAC;AACpE,OAAO,kBAAkB,MAAM,wCAAwC,CAAC;AACxE,OAAO,OAAO,MAAM,6BAA6B,CAAC;AAClD,OAAO,UAAU,MAAM,gCAAgC,CAAC;AAGxD,OAAO,EACL,gBAAgB,EAChB,cAAc,EACd,gBAAgB,EAChB,kBAAkB,EAClB,OAAO,EACP,UAAU,GACX,CAAC"}
|
|
@@ -12,10 +12,11 @@ import { BROWSER_AI_MODULE_META } from '../ui.js';
|
|
|
12
12
|
import AILoadingOverlay from './components/AILoadingOverlay.svelte';
|
|
13
13
|
import CapabilityGate from './components/CapabilityGate.svelte';
|
|
14
14
|
import DownloadProgress from './components/DownloadProgress.svelte';
|
|
15
|
+
import ModelStatusControl from './components/ModelStatusControl.svelte';
|
|
15
16
|
import STTTest from './components/STTTest.svelte';
|
|
16
17
|
import VoiceInput from './components/VoiceInput.svelte';
|
|
17
18
|
// Export components
|
|
18
|
-
export { AILoadingOverlay, CapabilityGate, DownloadProgress, STTTest, VoiceInput, };
|
|
19
|
+
export { AILoadingOverlay, CapabilityGate, DownloadProgress, ModelStatusControl, STTTest, VoiceInput, };
|
|
19
20
|
// Note: Component prop types (AILoadingOverlayProps, etc.) are available
|
|
20
21
|
// via svelte-package build output but cannot be re-exported here because
|
|
21
22
|
// tsc --noEmit cannot resolve type exports from .svelte files.
|
|
@@ -24,5 +25,6 @@ ModuleUIRegistry.registerModule(BROWSER_AI_MODULE_META);
|
|
|
24
25
|
ModuleUIRegistry.register('@happyvertical/smrt-svelte/browser-ai', 'ai-loading-overlay', AILoadingOverlay);
|
|
25
26
|
ModuleUIRegistry.register('@happyvertical/smrt-svelte/browser-ai', 'capability-gate', CapabilityGate);
|
|
26
27
|
ModuleUIRegistry.register('@happyvertical/smrt-svelte/browser-ai', 'download-progress', DownloadProgress);
|
|
28
|
+
ModuleUIRegistry.register('@happyvertical/smrt-svelte/browser-ai', 'model-status-control', ModelStatusControl);
|
|
27
29
|
ModuleUIRegistry.register('@happyvertical/smrt-svelte/browser-ai', 'stt-test', STTTest);
|
|
28
30
|
ModuleUIRegistry.register('@happyvertical/smrt-svelte/browser-ai', 'voice-input', VoiceInput);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strings.browser-ai.d.ts","sourceRoot":"","sources":["../../src/i18n/strings.browser-ai.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,CAAC;;CAGZ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-svelte",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.13",
|
|
4
4
|
"smrtJsdoc": "strict",
|
|
5
5
|
"description": "Svelte 5 components for SMRT user management - auth, users, tenants, roles, permissions, groups",
|
|
6
6
|
"type": "module",
|
|
@@ -120,20 +120,21 @@
|
|
|
120
120
|
"access": "public"
|
|
121
121
|
},
|
|
122
122
|
"dependencies": {
|
|
123
|
-
"@happyvertical/logger": "^0.89.
|
|
124
|
-
"@happyvertical/smrt-languages": "0.51.
|
|
125
|
-
"@happyvertical/smrt-types": "0.51.
|
|
126
|
-
"@happyvertical/smrt-ui": "0.51.
|
|
127
|
-
"@happyvertical/smrt-web": "0.51.
|
|
123
|
+
"@happyvertical/logger": "^0.89.12",
|
|
124
|
+
"@happyvertical/smrt-languages": "0.51.13",
|
|
125
|
+
"@happyvertical/smrt-types": "0.51.13",
|
|
126
|
+
"@happyvertical/smrt-ui": "0.51.13",
|
|
127
|
+
"@happyvertical/smrt-web": "0.51.13",
|
|
128
128
|
"@tanstack/db": "^0.6.14",
|
|
129
129
|
"@tanstack/svelte-db": "^0.1.91",
|
|
130
130
|
"esm-env": "^1.2.2"
|
|
131
131
|
},
|
|
132
132
|
"peerDependencies": {
|
|
133
133
|
"@huggingface/transformers": ">=3.8.1",
|
|
134
|
-
"@mlc-ai/web-llm": ">=0.2.
|
|
134
|
+
"@mlc-ai/web-llm": ">=0.2.85",
|
|
135
135
|
"@remotion/whisper-web": ">=4.0.484",
|
|
136
136
|
"@xenova/transformers": "^2.17.2",
|
|
137
|
+
"bitgpu": "^0.19.1",
|
|
137
138
|
"chrono-node": "^2.9.1",
|
|
138
139
|
"svelte": "^5.56.4"
|
|
139
140
|
},
|
|
@@ -150,19 +151,22 @@
|
|
|
150
151
|
"@xenova/transformers": {
|
|
151
152
|
"optional": true
|
|
152
153
|
},
|
|
154
|
+
"bitgpu": {
|
|
155
|
+
"optional": true
|
|
156
|
+
},
|
|
153
157
|
"chrono-node": {
|
|
154
158
|
"optional": true
|
|
155
159
|
}
|
|
156
160
|
},
|
|
157
161
|
"devDependencies": {
|
|
158
|
-
"@happyvertical/smrt-agents": "0.51.
|
|
159
|
-
"@happyvertical/smrt-chat": "0.51.
|
|
160
|
-
"@happyvertical/smrt-content": "0.51.
|
|
161
|
-
"@happyvertical/smrt-core": "0.51.
|
|
162
|
-
"@happyvertical/smrt-reports": "0.51.
|
|
163
|
-
"@happyvertical/smrt-scanner": "0.51.
|
|
164
|
-
"@happyvertical/smrt-tenancy": "0.51.
|
|
165
|
-
"@happyvertical/smrt-users": "0.51.
|
|
162
|
+
"@happyvertical/smrt-agents": "0.51.13",
|
|
163
|
+
"@happyvertical/smrt-chat": "0.51.13",
|
|
164
|
+
"@happyvertical/smrt-content": "0.51.13",
|
|
165
|
+
"@happyvertical/smrt-core": "0.51.13",
|
|
166
|
+
"@happyvertical/smrt-reports": "0.51.13",
|
|
167
|
+
"@happyvertical/smrt-scanner": "0.51.13",
|
|
168
|
+
"@happyvertical/smrt-tenancy": "0.51.13",
|
|
169
|
+
"@happyvertical/smrt-users": "0.51.13",
|
|
166
170
|
"@sveltejs/package": "^2.5.8",
|
|
167
171
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
168
172
|
"@testing-library/jest-dom": "^6.9.1",
|