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 +14 -0
- package/README.md +2 -2
- package/docs/reference/inventory/runtime-modules.json +18 -4
- package/docs/reference/runtime-modules.md +124 -2
- package/package.json +1 -1
- package/runtime/arcane/modules/AIModelSelectionController.js +279 -0
- package/runtime/arcane/modules/AIPreferenceTuple.js +2 -2
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.
|
|
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.
|
|
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":
|
|
12
|
-
"javascriptArtifactCount":
|
|
13
|
-
"esmExportCount":
|
|
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": "
|
|
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 |
|
|
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.**
|
|
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
|
@@ -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=
|
|
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)
|