arcane-os 0.32.0 → 0.33.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.0
4
+
5
+ - Add the default and named `AIModelSelectionController` export at
6
+ `arcane-os/modules/AIModelSelectionController.js`. It coordinates existing
7
+ LLM, STT and TTS provider/model select elements with app-owned defaults,
8
+ catalogs and optional explicit asynchronous inventory discovery.
9
+ - Preserve unknown saved values and newer drafts during asynchronous
10
+ hydration and discovery, including paired LLM provider/model choices and
11
+ per-provider model memory. Expose current selection, operation state,
12
+ original errors and deterministic disposal without late control mutations.
13
+ - Keep the existing six-slot preference tuple order and spelling unchanged.
14
+ The controller neither saves User preferences nor activates or downloads
15
+ providers, models or voices; application policy and persistence stay local.
16
+
3
17
  ## 0.32.0
4
18
 
5
19
  - Export `profileUpdateFromSearchParams(searchParams)` from
package/README.md CHANGED
@@ -19,7 +19,7 @@ version-locked SDK runtime, while an integrated Arcane checkout uses its live
19
19
  `arcane/` runtime. Both profiles share the theme, packaging, event, cancellation,
20
20
  and browser run contracts while retaining their selected application layout.
21
21
 
22
- This checkout defines the `0.32.0` SDK contract. Applications pin one exact npm
22
+ This checkout defines the `0.33.0` SDK contract. Applications pin one exact npm
23
23
  version and lockfile; registry state is deliberately not baked into application
24
24
  artifacts.
25
25
 
@@ -85,7 +85,7 @@ Create one browser application, install its pinned SDK, and start its source
85
85
  server:
86
86
 
87
87
  ```bash
88
- npx arcane-os@0.32.0 new hello-speech --path ./hello-speech --target browser
88
+ npx arcane-os@0.33.0 new hello-speech --path ./hello-speech --target browser
89
89
  cd hello-speech
90
90
  npm install
91
91
  ```
@@ -8,9 +8,9 @@
8
8
  "sdkVersion": "0.6.0",
9
9
  "protocol": "arcane/1"
10
10
  },
11
- "artifactCount": 85,
12
- "javascriptArtifactCount": 83,
13
- "esmExportCount": 353,
11
+ "artifactCount": 86,
12
+ "javascriptArtifactCount": 84,
13
+ "esmExportCount": 355,
14
14
  "artifacts": [
15
15
  {
16
16
  "file": "runtime/arcane/modules/AI.js",
@@ -32,6 +32,20 @@
32
32
  "normalization": "Complete mutable caller-owned browser speech authority, SDK-owned provider registration/replacement/disposal, explicit STT/TTS activation, normalized Whisper STT and Kokoro TTS execution with default capacities 1 and 4 respectively, explicit provider-neutral execution snapshots through status(role,{execution:true}), sticky readiness, canonical window.user readiness without compatibility-event object-identity admission or polling, exact ordered structural calls with required arguments.message, atomic nonblank all-ID tool-result sequencing, complete all-choice streaming/data validation, native Ollama structural adaptation, complete-response then all validated tool callbacks then completion ordering, observed async callbacks, Blob/File transcription, automatic repeated-formatting-mark removal from cloned outbound TTS input while caller and visible content stays exact, immediate exact-segment synthesis, per-call voice/speed capture, optional final-segment pause on the existing audio clock, optional per-call terminal playback completion, detached preparation with complete semantic DBOPFS audio reuse and same-owner pending request sharing, independent prepared playback without a model load for cached audio, ordered audio-clock scheduling, playable audio, active-generation TTS operation-failure routing, mute, and cancellation are normalized; provider/model/runtime/voice policy remains caller-owned. Both speech roles default execution.device to auto: webnn-npu when navigator.ml.createContext is exposed, then webgpu when navigator.gpu is exposed, then wasm for CPU execution. Failed backend loads clean up the pool before trying fresh Workers with the same prepared model and dtype; explicit devices never fall back. STT capacity only accepts 1; TTS accepts 1 through 4. Execution snapshots expose requestedDevice, selectedDevice, maxConcurrentRequests, and activeRequestCount for both roles; selectedDevice is null while unloaded and names the successful upstream session backend request when loaded. It does not prove every operation used the physical NPU: WebNN unsupported operations may use WASM, and compatibility depends on the browser, driver, hardware, and graph.",
33
33
  "surface": "Browser-speech protocol/event/error/reason constants; default `AI`; read-only `providerRuntime`, `browserSpeechConfiguration`, and `browserSpeechDescriptor`; explicit `providerRuntime.status(role,{execution:true})` snapshots; `streamTTS(text,end,options={})` with optional voice, speed, pauseAfterMs, and waitForPlayback; automatic speech-input cleanup with SDK-internal preparation metadata; `prepareTTS({parts,storage,identity,signal,onState})` returning ordered segments, state, ready, getAudio(index), and cancel(); `playPreparedTTS(prepared,{signal,onState})` returning state, error, finished, pause(), resume(), and stop() with one playback lane per AI and observational state/error callbacks; configure/dispose speech, route lifecycle, declaration-validated chat/stream requests with exact ordered calls; optional streamRequest toolText:{name,field} and onToolText(text,call,displayId) for decoded selected argument text with actual call identity, distinct from ordinary prose and terminal tool execution; synthesis/transcription and playback controls; initializes from current canonical user readiness, installs `window.ai`, and projects `ai-ready` plus active-generation `ai-tts-failure`."
34
34
  },
35
+ {
36
+ "file": "runtime/arcane/modules/AIModelSelectionController.js",
37
+ "name": "AIModelSelectionController.js",
38
+ "kind": "esm",
39
+ "exports": [
40
+ "AIModelSelectionController",
41
+ "default"
42
+ ],
43
+ "summary": "Binds six caller-owned provider/model selects with draft-safe hydration, explicit inventory discovery, and paired LLM selection.",
44
+ "availability": "Browser / native WebView with supplied select elements",
45
+ "protocol": "In-process selection state and native input/change events",
46
+ "normalization": "Mutable six-slot selection copies preserve complete exact saved values, unknown options, labels, and newer drafts. Caller-owned catalogs merge with existing options; unscoped initial LLM options belong to the initial provider while unscoped explicit catalog entries are shared. LLM provider/model pairing preserves each provider's remembered model, with default-pair, first-enabled-match, or empty selection for a new provider. Explicit hydration and discovery track independent latest operations and retain complete errors. No User writes, storage, model activation, download, polling, or Core call occurs.",
47
+ "surface": "Named/default AIModelSelectionController; constructor({selects,defaults,catalogs,inventory}); getSelection(), hydrate(tupleOrPromise), discover(), state, and dispose(). Slot order: llmProvider, sttProvider, ttsProvider, llmModel, ttsModel, sttModel."
48
+ },
35
49
  {
36
50
  "file": "runtime/arcane/modules/AIPreferenceRuntime.js",
37
51
  "name": "AIPreferenceRuntime.js",
@@ -58,7 +72,7 @@
58
72
  "summary": "Normalizes and compares the six provider/model preference slots.",
59
73
  "availability": "Cross-host",
60
74
  "protocol": "In-process only",
61
- "normalization": "Fully normalized frozen tuple.",
75
+ "normalization": "Mutable slot-order array and normalized tuple. Existing token trimming, aliases, caller-selected allowed values, and defaults remain unchanged; selection controllers can reuse the order independently of token normalization.",
62
76
  "surface": "`AI_PREFERENCE_SLOT_KEYS`, `normalizeAIPreferenceTuple()`, `aiPreferenceTuplesEqual()`."
63
77
  },
64
78
  {
@@ -48,8 +48,9 @@ own asynchronous work, cancellation, and backpressure.
48
48
  | Module | Kind | Capability | Availability | Normalization |
49
49
  | --- | --- | --- | --- | --- |
50
50
  | [`AI.js`](#aijs) | esm | Provider-selectable chat, speech-to-text, text-to-speech, tool calling, structured output, streaming, bounded synthesis, and ordered audio-clock playback. | Browser + native bridge + cloud | High-level chat/speech behavior and active TTS operation failures are normalized; provider diagnostics remain mixed. |
51
+ | [`AIModelSelectionController.js`](#aimodelselectioncontrollerjs) | esm | Binds six supplied provider/model selects with draft-safe hydration, explicit catalog discovery, and paired LLM selection. | Browser / native WebView | Mutable exact values and complete errors; storage and activation remain app-owned. |
51
52
  | [`AIPreferenceRuntime.js`](#aipreferenceruntimejs) | esm | Applies and reads non-persistent per-user AI preference overrides. | Cross-host | Normalized six-slot preference state. |
52
- | [`AIPreferenceTuple.js`](#aipreferencetuplejs) | esm | Normalizes and compares the six provider/model preference slots. | Cross-host | Fully normalized frozen tuple. |
53
+ | [`AIPreferenceTuple.js`](#aipreferencetuplejs) | esm | Normalizes and compares the six provider/model preference slots. | Cross-host | Mutable slot order and normalized tuple. |
53
54
  | [`AIProviderRuntime.js`](#aiproviderruntimejs) | esm | Owns provider-neutral selection, lifecycle, routing, startup, requests, streaming, cancellation, and independent LLM/STT/TTS state. | Cross-host runtime; provider-specific availability | Normalized required provider members plus route/status contracts, with explicit local-only selection and no implicit fallback. |
54
55
  | [`AIResponseURLPolicy.js`](#airesponseurlpolicyjs) | esm | Extracts and audits links from AI Markdown, rendered HTML, CSS, srcset, bare URLs, and email text. | Cross-host | Mutable audit with exact link comparison after renderer-level decoding. |
55
56
  | [`AIRuntimeState.js`](#airuntimestatejs) | esm | Publishes sticky mutable role snapshots, lifecycle intents, and startup-settlement barriers. | Cross-host state contract | Closed monotonic state records; events report state but grant no authority. |
@@ -683,6 +684,123 @@ async function sayHello(applicationRuntime) {
683
684
 
684
685
  For on-device TTS, use the [browser speech quick start](ai/browser-speech.md).
685
686
 
687
+ ## AIModelSelectionController.js
688
+
689
+ ### Overview
690
+
691
+ Use existing native selects to edit provider/model preferences while saved
692
+ preferences or an optional model inventory arrive asynchronously. The controller
693
+ owns only selection and option rendering. Applications retain their labels,
694
+ catalogs, defaults, saved preferences, model readiness, and explicit activation.
695
+ Importing or constructing it never loads a model, starts discovery, or writes
696
+ User preferences or storage.
697
+
698
+ ### Public surface
699
+
700
+ Exact exports: `AIModelSelectionController`, `default` (the same class).
701
+
702
+ `new AIModelSelectionController({selects, defaults, catalogs, inventory})` accepts
703
+ six existing selects by the following names. Tuples use this exact order:
704
+
705
+ | Index | Select key | Selection |
706
+ | --- | --- | --- |
707
+ | 0 | `llmProvider` | LLM provider |
708
+ | 1 | `sttProvider` | Speech-to-text provider |
709
+ | 2 | `ttsProvider` | Text-to-speech provider |
710
+ | 3 | `llmModel` | LLM model |
711
+ | 4 | `ttsModel` | Text-to-speech model |
712
+ | 5 | `sttModel` | Speech-to-text model |
713
+
714
+ `defaults` is an optional tuple; omitted slots use the selects' initial values.
715
+ Optional `catalogs` uses those same six keys, with arrays of strings or
716
+ `{value, label, provider?, disabled?}` records. The LLM model catalog also accepts
717
+ `CoreLocalModelCatalog` records shaped `{preferenceValue, providerValue, label}`.
718
+ Only the LLM model list uses provider filtering; existing options may supply
719
+ their provider in `data-provider`. An initial LLM option without that attribute
720
+ belongs to the initial default LLM provider. An explicitly supplied catalog entry
721
+ without a provider is shared across providers. Rendered options retain their
722
+ provider metadata. Caller catalog entries merge with the selects'
723
+ existing options, retaining supplied disabled states. Entries with the same value
724
+ use the later catalog definition, and discovered entries take precedence over
725
+ authored entries. Unknown saved selections receive an option
726
+ containing their exact value; values and labels are never trimmed or shortened.
727
+
728
+ - `getSelection()` returns a mutable copy of the current six-slot tuple.
729
+ - `hydrate(tupleOrPromise)` explicitly applies saved selections. Missing tuple
730
+ entries use application defaults. Native `input` or `change` edits made after
731
+ hydration starts survive its completion, and the LLM provider/model pair is
732
+ preserved together when either is edited. The newest hydration owns updates.
733
+ - `discover()` explicitly invokes optional `inventory({selection, signal})`.
734
+ The caller returns per-slot catalog arrays; they merge with authored options
735
+ without replacing selected values or drafts. A newer discovery aborts the
736
+ preceding signal and owns subsequent catalog updates. There is no polling.
737
+ - `state` returns `{hydrating, discovering, disposed, hydrationError,
738
+ discoveryError}`. Async failures retain their complete rejection objects.
739
+ - `dispose()` removes the owned native listeners, aborts pending inventory work,
740
+ and prevents late hydration or discovery from changing the controls. New work
741
+ after disposal rejects; existing asynchronous callers still own their promises.
742
+
743
+ Successful `hydrate()` and `discover()` calls resolve a current six-slot tuple
744
+ copy. Superseded successful operations also return the current selection without
745
+ reapplying their older data. `discover()` with no inventory callback returns the
746
+ current selection without starting work. `dispose()` returns `true` on its first
747
+ call and `false` on subsequent calls; the last selection remains readable.
748
+ When constructing a new controller over the same controls, supply the complete
749
+ app catalogs again: provider-filtered choices are retained by the current
750
+ controller, while only the current provider's choices appear in the DOM.
751
+
752
+ When the user changes the LLM provider, the controller remembers the preceding
753
+ provider's model. Returning to a provider restores its remembered model.
754
+ Otherwise, it selects the application's default provider/model pair when
755
+ applicable, the first enabled matching catalog model, or an empty value.
756
+ An explicitly saved or remembered disabled selection is retained.
757
+ It never carries a different provider's model forward as the new selection.
758
+ The other four slots remain independent.
759
+
760
+ ### Availability and normalization
761
+
762
+ **Browser / native WebView.** This controller requires supplied select elements,
763
+ uses native `input` and `change` events, and creates no custom event bus. It does
764
+ not query Core or infer readiness from inventory presence. An application may
765
+ provide an inventory callback that obtains `localAI.status` and passes its
766
+ `CoreLocalModelCatalog` projection; platform access remains with that caller.
767
+
768
+ Missing controls, non-string selection values or labels, a non-array hydration
769
+ result, and a supplied non-function inventory raise `TypeError`. Hydration and
770
+ discovery preserve complete caller failures through their promises; the latest
771
+ operation also records its error in the corresponding state field. Disposal
772
+ errors describe that lifecycle boundary. Selection itself has no persistence,
773
+ network, provider-transition, or activation side effect.
774
+
775
+ ### Example
776
+
777
+ This example uses an app-owned form containing the six named selects above and
778
+ a `<pre id="selected-models">` result. Their existing options and initial values
779
+ are the catalog and defaults, so changing one control visibly shows the tuple
780
+ the application can choose to save. No model policy or storage is added.
781
+
782
+ ```javascript
783
+ import AIModelSelectionController from 'arcane-os/modules/AIModelSelectionController.js';
784
+
785
+ const form = document.querySelector('#model-preferences');
786
+ const controller = new AIModelSelectionController(
787
+ {
788
+ selects: form.elements
789
+ }
790
+ );
791
+
792
+ form.addEventListener(
793
+ 'change',
794
+ function showModelSelection() {
795
+ document.querySelector('#selected-models').textContent = JSON.stringify(
796
+ controller.getSelection()
797
+ );
798
+ }
799
+ );
800
+
801
+ // Call controller.dispose() when the owning settings view is removed.
802
+ ```
803
+
686
804
  ## AIPreferenceRuntime.js
687
805
 
688
806
  ### Overview
@@ -721,7 +839,11 @@ Exact exports: `AI_PREFERENCE_SLOT_KEYS`, `aiPreferenceTuplesEqual`, `normalizeA
721
839
 
722
840
  ### Availability and normalization
723
841
 
724
- **Cross-host.** Fully normalized frozen tuple. Transport: In-process only. [Deep protocol details](protocols.md).
842
+ **Cross-host.** The slot-order array and returned tuples are mutable. Existing
843
+ token trimming, aliases, caller-selected allowed values, and default selection
844
+ are unchanged. `AIModelSelectionController` reuses the canonical slot order
845
+ without applying token normalization to saved selections. Transport: In-process
846
+ only. [Deep protocol details](protocols.md).
725
847
 
726
848
  ### Example
727
849
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -0,0 +1,279 @@
1
+ import Is from 'strong-type';
2
+ import {AI_PREFERENCE_SLOT_KEYS} from './AIPreferenceTuple.js';
3
+
4
+ const is = new Is(false);
5
+
6
+ function selectionText(value) {
7
+ if (!is.string(value)) {
8
+ throw new TypeError('Model selections and option values must be strings.');
9
+ }
10
+ return value;
11
+ }
12
+
13
+ function catalogOptions(entries = []) {
14
+ return Array.from(entries, function catalogOption(entry) {
15
+ if (is.string(entry)) {
16
+ return {value: entry, label: entry};
17
+ }
18
+ const value = selectionText(entry.value ?? entry.preferenceValue);
19
+ return {
20
+ value,
21
+ label: selectionText(entry.label ?? value),
22
+ provider: entry.provider ?? entry.providerValue,
23
+ disabled: entry.disabled === true
24
+ };
25
+ });
26
+ }
27
+
28
+ /**
29
+ * Owns selection UI only. Applications own catalogs, saved preferences,
30
+ * discovery requests and all provider/model activation.
31
+ */
32
+ export class AIModelSelectionController {
33
+ #keys = [...AI_PREFERENCE_SLOT_KEYS];
34
+ #selects;
35
+ #defaults;
36
+ #authored = {};
37
+ #discovered = {};
38
+ #selection;
39
+ #revisions = [0, 0, 0, 0, 0, 0];
40
+ #models = new Map();
41
+ #inventory;
42
+ #listener;
43
+ #hydration = 0;
44
+ #hydrating = false;
45
+ #hydrationError = null;
46
+ #discovery = null;
47
+ #discoveryError = null;
48
+ #disposed = false;
49
+
50
+ constructor({selects, defaults, catalogs = {}, inventory} = {}) {
51
+ this.#selects = selects;
52
+ this.#inventory = inventory;
53
+ this.#defaults = [];
54
+ for (const [index, key] of this.#keys.entries()) {
55
+ const control = selects?.[key];
56
+ if (!control || !is.function(control.addEventListener)
57
+ || !is.function(control.removeEventListener)) {
58
+ throw new TypeError(`An existing select element is required for ${key}.`);
59
+ }
60
+ this.#defaults.push(selectionText(defaults?.[index] ?? control.value));
61
+ const initialProvider = key === 'llmModel' ? this.#defaults[0] : undefined;
62
+ const existing = Array.from(control.options, function existingOption(option) {
63
+ return {
64
+ value: option.value,
65
+ label: option.label ?? option.textContent,
66
+ provider: option.dataset?.provider ?? initialProvider,
67
+ disabled: option.disabled
68
+ };
69
+ });
70
+ this.#authored[key] = [...catalogOptions(existing), ...catalogOptions(catalogs[key])];
71
+ }
72
+ if (inventory !== undefined && !is.function(inventory)) {
73
+ throw new TypeError('Model inventory must be a function when supplied.');
74
+ }
75
+ this.#selection = [...this.#defaults];
76
+ this.#rememberModel();
77
+ this.#render();
78
+ this.#listener = this.#handleChange.bind(this);
79
+ for (const key of this.#keys) {
80
+ selects[key].addEventListener('input', this.#listener);
81
+ selects[key].addEventListener('change', this.#listener);
82
+ }
83
+ }
84
+
85
+ get state() {
86
+ return {
87
+ hydrating: this.#hydrating,
88
+ discovering: this.#discovery !== null,
89
+ disposed: this.#disposed,
90
+ hydrationError: this.#hydrationError,
91
+ discoveryError: this.#discoveryError
92
+ };
93
+ }
94
+
95
+ getSelection() {
96
+ return [...this.#selection];
97
+ }
98
+
99
+ async hydrate(preferences) {
100
+ this.#requireActive();
101
+ const hydration = ++this.#hydration;
102
+ const revisions = [...this.#revisions];
103
+ this.#hydrating = true;
104
+ this.#hydrationError = null;
105
+ try {
106
+ const saved = await preferences;
107
+ if (!is.array(saved)) {
108
+ throw new TypeError('Hydrated AI preferences must be an array in six-slot order.');
109
+ }
110
+ if (this.#disposed || hydration !== this.#hydration) {
111
+ return this.getSelection();
112
+ }
113
+ const selection = this.getSelection();
114
+ const llmEdited = revisions[0] !== this.#revisions[0]
115
+ || revisions[3] !== this.#revisions[3];
116
+ for (const [index] of this.#keys.entries()) {
117
+ if (revisions[index] !== this.#revisions[index]
118
+ || ((index === 0 || index === 3) && llmEdited)) {
119
+ continue;
120
+ }
121
+ selection[index] = selectionText(saved[index] ?? this.#defaults[index]);
122
+ }
123
+ this.#selection = selection;
124
+ this.#rememberModel();
125
+ this.#render();
126
+ return this.getSelection();
127
+ } catch (error) {
128
+ if (!this.#disposed && hydration === this.#hydration) {
129
+ this.#hydrationError = error;
130
+ }
131
+ throw error;
132
+ } finally {
133
+ if (hydration === this.#hydration) {
134
+ this.#hydrating = false;
135
+ }
136
+ }
137
+ }
138
+
139
+ async discover() {
140
+ this.#requireActive();
141
+ if (!this.#inventory) {
142
+ return this.getSelection();
143
+ }
144
+ const previous = this.#discovery;
145
+ const discovery = new AbortController();
146
+ this.#discovery = discovery;
147
+ this.#discoveryError = null;
148
+ previous?.abort();
149
+ try {
150
+ const catalogs = await this.#inventory(
151
+ {
152
+ selection: this.getSelection(),
153
+ signal: discovery.signal
154
+ }
155
+ );
156
+ if (this.#disposed || this.#discovery !== discovery) {
157
+ return this.getSelection();
158
+ }
159
+ const discovered = {};
160
+ for (const key of this.#keys) {
161
+ discovered[key] = catalogOptions(catalogs?.[key]);
162
+ }
163
+ this.#discovered = discovered;
164
+ this.#render();
165
+ return this.getSelection();
166
+ } catch (error) {
167
+ if (!this.#disposed && this.#discovery === discovery) {
168
+ this.#discoveryError = error;
169
+ }
170
+ throw error;
171
+ } finally {
172
+ if (this.#discovery === discovery) {
173
+ this.#discovery = null;
174
+ }
175
+ }
176
+ }
177
+
178
+ dispose() {
179
+ if (this.#disposed) {
180
+ return false;
181
+ }
182
+ this.#disposed = true;
183
+ this.#hydrating = false;
184
+ for (const key of this.#keys) {
185
+ this.#selects[key].removeEventListener('input', this.#listener);
186
+ this.#selects[key].removeEventListener('change', this.#listener);
187
+ }
188
+ const discovery = this.#discovery;
189
+ this.#discovery = null;
190
+ discovery?.abort();
191
+ return true;
192
+ }
193
+
194
+ #requireActive() {
195
+ if (this.#disposed) {
196
+ throw new Error('The model selection controller is disposed.');
197
+ }
198
+ }
199
+
200
+ #rememberModel() {
201
+ this.#models.set(this.#selection[0], this.#selection[3]);
202
+ }
203
+
204
+ #optionsFor(key) {
205
+ const options = new Map();
206
+ const entries = [...this.#authored[key], ...(this.#discovered[key] ?? [])];
207
+ for (const option of entries) {
208
+ if (key === 'llmModel' && option.provider !== undefined
209
+ && option.provider !== this.#selection[0]) {
210
+ continue;
211
+ }
212
+ options.set(option.value, option);
213
+ }
214
+ return options;
215
+ }
216
+
217
+ #handleChange(event) {
218
+ const index = this.#keys.findIndex(function changedControl(key) {
219
+ return this.#selects[key] === event.currentTarget;
220
+ }, this);
221
+ if (this.#disposed || index < 0) {
222
+ return;
223
+ }
224
+ const value = event.currentTarget.value;
225
+ this.#revisions[index] += 1;
226
+ if (index === 0 && value !== this.#selection[0]) {
227
+ this.#rememberModel();
228
+ this.#selection[0] = value;
229
+ this.#selection[3] = this.#modelForProvider(value);
230
+ this.#revisions[3] += 1;
231
+ } else {
232
+ this.#selection[index] = value;
233
+ }
234
+ this.#rememberModel();
235
+ if (index === 0) {
236
+ this.#render();
237
+ }
238
+ }
239
+
240
+ #modelForProvider(provider) {
241
+ if (this.#models.has(provider)) {
242
+ return this.#models.get(provider);
243
+ }
244
+ if (provider === this.#defaults[0]) {
245
+ return this.#defaults[3];
246
+ }
247
+ for (const option of this.#optionsFor('llmModel').values()) {
248
+ if (!option.disabled) {
249
+ return option.value;
250
+ }
251
+ }
252
+ return '';
253
+ }
254
+
255
+ #render() {
256
+ for (const [index, key] of this.#keys.entries()) {
257
+ const control = this.#selects[key];
258
+ const selected = this.#selection[index];
259
+ const options = this.#optionsFor(key);
260
+ if (!options.has(selected)) {
261
+ options.set(selected, {value: selected, label: selected});
262
+ }
263
+ control.replaceChildren();
264
+ for (const option of options.values()) {
265
+ const element = control.ownerDocument.createElement('option');
266
+ element.value = option.value;
267
+ element.textContent = option.label;
268
+ element.disabled = option.disabled === true;
269
+ if (option.provider !== undefined) {
270
+ element.dataset.provider = option.provider;
271
+ }
272
+ control.append(element);
273
+ }
274
+ control.value = selected;
275
+ }
276
+ }
277
+ }
278
+
279
+ export default AIModelSelectionController;
@@ -1,14 +1,14 @@
1
1
  import Is from 'strong-type';
2
2
  const is=new Is(false);
3
3
 
4
- export const AI_PREFERENCE_SLOT_KEYS=Object.freeze([
4
+ export const AI_PREFERENCE_SLOT_KEYS=[
5
5
  'llmProvider',
6
6
  'sttProvider',
7
7
  'ttsProvider',
8
8
  'llmModel',
9
9
  'ttsModel',
10
10
  'sttModel'
11
- ]);
11
+ ];
12
12
 
13
13
  function preferenceToken(value){
14
14
  return is.string(value)