arcane-os 0.78.0 → 0.79.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.
@@ -58,7 +58,7 @@ name to `decisions`. It exposes `load`, `evaluate`, `classify` (an alias for
58
58
  | Method | Parameters and result |
59
59
  | --- | --- |
60
60
  | `decisions.status` | Current lifecycle snapshot; does not load anything. |
61
- | `decisions.load` | Explicit activation; returns the ready lifecycle snapshot. The request signal cancels activation. |
61
+ | `decisions.load` | `{executionTarget?}`, where the target is `{deviceId:string}` or `null`; explicit activation or target replacement, returning the ready lifecycle snapshot. The request signal cancels activation. |
62
62
  | `decisions.evaluate` | `{rows, runOptions?}`; returns `{decisions, outputs}`. The request signal cancels the active model operation. |
63
63
  | `decisions.unload` | Releases the activation. Service-lifetime cleanup continues after the caller disconnects. |
64
64
 
@@ -111,12 +111,66 @@ is loaded by this API.
111
111
  This graph selection requires `dtype:'fp32'`, which is also the default; another
112
112
  dtype reports the incompatible selection rather than substituting a graph.
113
113
  `sessionOptions` and `runOptions` pass to the existing native ONNX API unchanged.
114
- `executionPreference` defaults to `gpu` here: actual advertised native GPU
115
- provider creation is attempted, followed by an honest CPU fallback. Explicit
116
- `sessionOptions.executionProviders` takes precedence. The underlying generic
117
- ONNX factory continues to default to CPU. Accepted provider configuration is
118
- not evidence that GPU nodes executed. Full provider-attempt diagnostics remain
119
- in `execution`; see [native ONNX sessions](local-ai.md#native-onnx-sessions).
114
+ With no `executionTarget` selection, `executionPreference` defaults to `gpu`
115
+ here: actual advertised native GPU provider creation is attempted, followed by
116
+ an honest CPU fallback. Explicit `sessionOptions.executionProviders` takes
117
+ precedence on that unchanged path. The underlying generic ONNX factory
118
+ continues to default to CPU. Accepted provider configuration is not evidence
119
+ that GPU nodes executed. Full provider-attempt diagnostics remain in
120
+ `execution`; see [native ONNX sessions](local-ai.md#native-onnx-sessions).
121
+
122
+ ## Select an activation's execution device
123
+
124
+ Both factories accept `executionTarget: {deviceId:string}` or `null` in their
125
+ configuration. Direct model and service calls accept
126
+ `load({executionTarget, signal})`; the Core `decisions.load` method carries the
127
+ same target in its parameters. Use an actual stable ID from the shared
128
+ [physical device catalog](execution-devices.md), or the documented whole-host
129
+ CPU ID:
130
+
131
+ ```js
132
+ await decisions.load({executionTarget: {deviceId: 'cpu'}});
133
+ // A different explicit selection drains the current activation and replaces it.
134
+ await decisions.load({executionTarget: null});
135
+ ```
136
+
137
+ Omitting `executionTarget`, or supplying `undefined`, retains the most recent
138
+ explicit selection, initially the factory's selection. When neither supplies a
139
+ target, existing constructor, session-option and provider-default behavior
140
+ remains unchanged. Explicit `null` requests automatic device resolution at the
141
+ new activation boundary. It remains distinct from omission. The selection
142
+ stays available for a later explicit load after unloading or a failed load;
143
+ the SDK creates no preference store and does not change application preferences.
144
+
145
+ The decision owner forwards the target to its existing ONNX owner. That owner
146
+ resolves the physical ID, supported provider and provider-specific address.
147
+ An unavailable or unsupported explicit target reports its actual failure; it
148
+ does not silently select another adapter or a cloud provider. GPU selection
149
+ does not require every graph node to execute on the GPU: supported CPU graph
150
+ partitioning remains an engine concern. See the native ONNX contract for exact
151
+ target routing and automatic-selection behavior.
152
+
153
+ The existing `current().execution` field remains the complete ONNX session load
154
+ record for that activation. Its `execution` member carries `requestedTarget`,
155
+ `resolvedDevice`, `resolution`, `configuredTarget` and `observedTarget`, alongside
156
+ provider-attempt diagnostics. These distinguish requested selection, physical
157
+ resolution, accepted configuration and actual execution evidence. An unknown
158
+ `observedTarget` remains `null`; successful configuration alone does not establish
159
+ which device executed graph nodes.
160
+
161
+ `pendingActivation` is separately `null` or `{executionTarget}` while a load or
162
+ replacement is pending; its `executionTarget` property is omitted at JSON
163
+ transport when the selection is `undefined`. A pending target never overwrites the previous
164
+ activation's `execution` record. The old record remains attributable to the
165
+ retiring activation until its cleanup completes, then the new activation owns
166
+ its own record. A request is not a claim that the requested device is running.
167
+
168
+ Concurrent loads for the same target share their pending activation. A
169
+ different target supersedes the earlier pending request, which rejects with
170
+ cancellation instead of returning another target's ready result. Replacement
171
+ joins this model's actual tokenizer/native cleanup before creating its
172
+ successor. Independent model owners retain their independent workers and
173
+ lifetimes; there is no all-model loading barrier or trial inference.
120
174
 
121
175
  ## Complete inputs and outputs
122
176
 
@@ -148,14 +202,15 @@ runtime; sharing the encoding implementation alone does not establish it.
148
202
  ## Lifetime and cancellation
149
203
 
150
204
  `current()` returns `{family, model, revision, dtype, state, loaded, busy,
151
- activeRequests, progress, error, execution}`. States include `unloaded`,
205
+ activeRequests, progress, error, execution, pendingActivation}`. States include
206
+ `unloaded`,
152
207
  `loading`, `ready`, `unloading`, `disposing`, `disposed` and `error`. Semantic
153
208
  progress includes model preparation, tokenization and evaluation; downloaded
154
209
  members report files, not byte progress. Complete technical errors belong in
155
210
  developer diagnostics, while the application owns its user-facing status.
156
211
 
157
- Concurrent callers share one explicit activation. Cancelling a load or
158
- evaluation cancels that activation and its outstanding operations, because the
212
+ Concurrent callers selecting the same target share one explicit activation.
213
+ Cancelling a load or evaluation cancels that activation and its outstanding operations, because the
159
214
  native session and tokenizer have one owner. Use separately owned models when
160
215
  independent cancellation is required. The construction `signal` governs the
161
216
  whole model lifetime. `unload()` permits another explicit load after successful
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.78.0",
3
+ "version": "0.79.1",
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",
@@ -69,6 +69,7 @@
69
69
  "./core/preferences": "./src/core/services/preferences.mjs",
70
70
  "./core/diarization": "./src/diarization/service.mjs",
71
71
  "./core/local-ai": "./src/core/services/local-ai.mjs",
72
+ "./core/execution-devices": "./src/core/services/execution-devices.mjs",
72
73
  "./core/decisions": "./src/core/services/decisions.mjs",
73
74
  "./core/image": "./src/core/services/image.mjs",
74
75
  "./core/speech": "./src/core/services/speech.mjs",
@@ -77,6 +78,7 @@
77
78
  "./diarization/build": "./src/diarization/build.mjs",
78
79
  "./local-ai": "./src/local-ai/install.mjs",
79
80
  "./local-ai/onnx": "./src/local-ai/onnx.mjs",
81
+ "./local-ai/execution-devices": "./src/local-ai/execution-devices.mjs",
80
82
  "./local-ai/decisions": "./src/local-ai/decisions.mjs",
81
83
  "./local-ai/image": "./src/local-ai/image-runtime.mjs",
82
84
  "./local-ai/whisper": "./src/local-ai/whisper/index.mjs",
@@ -123,7 +125,7 @@
123
125
  "test": "npm run test:unit && npm run test:functional && npm run test:integration && npm run test:regression",
124
126
  "test:release": "node ./bin/arcane-test.mjs test/npm-release.test.mjs",
125
127
  "test:unit": "node ./bin/arcane-test.mjs test/app-descriptor.test.mjs test/app-schema.test.mjs test/app-selection.test.mjs test/contracts.test.mjs test/conversation-closing-report.test.mjs test/doctor.test.mjs test/mail-credentials.test.mjs test/mail-outbox.test.mjs test/mail-public-api.test.mjs test/mail-send.test.mjs test/mail-transport.test.mjs test/native-windows-icon.test.mjs test/targets.test.mjs test/workspace-operation-lock.test.mjs",
126
- "test:functional": "node ./bin/arcane-test.mjs test/ai-ollama-readiness.test.mjs test/core-ollama-lifecycle.test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-kokoro-complete-input.test.mjs test/browser-speech-providers.test.mjs test/browser-speech-artifacts.test.mjs test/browser-speech-resource-bridge.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/codex-app-server.test.mjs test/mcp-stdio-server.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-client-installation.test.mjs test/core-ai-installation.test.mjs test/core-llama-lifecycle.test.mjs test/core-development.test.mjs test/core-host.test.mjs test/core-shared-host.test.mjs test/core-model-assets.test.mjs test/core-native-services.test.mjs test/core-packaged-web.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-committed-events.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-model-resources.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/local-image-editing.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/model-resource-bridge.test.mjs test/native-decisions.test.mjs test/native-onnx.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
128
+ "test:functional": "node ./bin/arcane-test.mjs test/ai-ollama-readiness.test.mjs test/core-ollama-lifecycle.test.mjs test/ai-request-model.test.mjs test/ai-request-retry.test.mjs test/ai-stream-cancellation.test.mjs test/browser-decisions.test.mjs test/browser-kokoro-complete-input.test.mjs test/browser-speech-providers.test.mjs test/browser-speech-artifacts.test.mjs test/browser-speech-resource-bridge.test.mjs test/browser-wasm-gpu-notice.test.mjs test/browser-wasm-download-resume.test.mjs test/chat-input.test.mjs test/chat-stream-speech.test.mjs test/chat-transcript-scroll.test.mjs test/chat-transient-retention.test.mjs test/cli.test.mjs test/continuous-voice-capture.test.mjs test/codex-app-server.test.mjs test/mcp-stdio-server.test.mjs test/core-classic-source.test.mjs test/core-client.test.mjs test/core-client-installation.test.mjs test/core-ai-installation.test.mjs test/core-llama-lifecycle.test.mjs test/core-development.test.mjs test/core-host.test.mjs test/core-shared-host.test.mjs test/core-model-assets.test.mjs test/core-native-services.test.mjs test/core-packaged-web.test.mjs test/core-runtime.test.mjs test/core-stdio.test.mjs test/dbopfs-committed-events.test.mjs test/dbopfs-document-library.test.mjs test/dbopfs-model-resources.test.mjs test/dbopfs-png.test.mjs test/dbopfs.test.mjs test/dev-server.test.mjs test/dev-pwa.test.mjs test/dom-event-instrumentation.test.mjs test/event-manager.test.mjs test/events.test.mjs test/execution-devices.test.mjs test/file-preview.test.mjs test/import-map.test.mjs test/local-image-editing.test.mjs test/mail-cli.test.mjs test/mail-runtime.test.mjs test/mail-server.test.mjs test/markdown-editor.test.mjs test/markdown-media.test.mjs test/markdown-source-map.test.mjs test/modal.test.mjs test/model-resource-bridge.test.mjs test/native-decisions.test.mjs test/native-onnx.test.mjs test/native-module-packaging.test.mjs test/native-module-resource-references.test.mjs test/packaging.test.mjs test/pwa-packaging.test.mjs test/pwa-client.test.mjs test/pwa-install.test.mjs test/pwa-worker.test.mjs test/persistent-ai-chat-session.test.mjs test/reference-completeness.test.mjs test/rendered-print.test.mjs test/runtime-api-behavior.test.mjs test/runtime.test.mjs test/scaffold.test.mjs test/speech-playback.test.mjs test/site.test.mjs test/theme-manager.test.mjs test/twin-cloud-images.test.mjs test/twin-cloud.test.mjs test/websocket-client-import-map.test.mjs test/websocket.test.mjs test/update-check.test.mjs test/user-explicit-updates.test.mjs",
127
129
  "test:integration": "node ./bin/arcane-test.mjs test/installed-package-runtime.test.mjs test/root-app-layout.test.mjs test/integrated-shared.test.mjs test/integrated-workspace.test.mjs test/mail-browser.test.mjs test/native-macos-provider.test.mjs test/native-plan.test.mjs test/native-portable-provider.test.mjs test/native-provider-loader.test.mjs test/npm-release.test.mjs test/release-bundle.test.mjs test/release-capability-smoke.test.mjs test/shared-payload-batch.test.mjs test/tarball.test.mjs test/browser-wasm-cpu.test.mjs test/wllama-webgpu-runtime.test.mjs",
128
130
  "test:regression": "node ./bin/arcane-test.mjs test/channel-workflows.test.mjs test/html-import-registration.test.mjs test/html-import-packaged-script.test.mjs test/logging-regression.test.mjs test/markdown-speech.test.mjs test/prepared-speech.test.mjs test/ai-audio-output.test.mjs test/native-provider-generation.test.mjs test/speech-queue-regression.test.mjs test/testing.test.mjs test/test-sets.test.mjs",
129
131
  "check": "node tools/check-source.mjs && npm test",
@@ -4,6 +4,10 @@ import {createArcaneEventSource} from '../../event-manager.mjs';
4
4
  import {CoreError} from '../../../browser-runtime/core/contracts.mjs';
5
5
  import {encodeTensorMap} from '../../../browser-runtime/ai/onnx-tensors.mjs';
6
6
 
7
+ function sameExecutionTarget(left, right) {
8
+ return left === right || (left != null && right != null && left.deviceId !== undefined && left.deviceId === right.deviceId);
9
+ }
10
+
7
11
  /** App-owned selection; shared typed-decision execution, without a renderer. */
8
12
  export function createNativeDecisionService(configuration = {}, {appRoot = process.cwd()} = {}) {
9
13
  const events = createArcaneEventSource({}, {source: 'core-decisions', eventTypes: ['decisions.state']});
@@ -15,14 +19,22 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
15
19
  let releasing;
16
20
  let closing;
17
21
  let unsubscribe;
22
+ let selectedTarget = configuration.executionTarget;
23
+ let loadingTarget;
24
+ let loadedTarget;
18
25
 
19
26
  function current() {
20
- return model?.current() ?? {
27
+ const snapshot = model?.current() ?? {
21
28
  family: 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
22
29
  revision: configuration.revision ?? 'main', dtype: configuration.dtype ?? 'fp32',
23
30
  state: closing ? 'disposed' : 'unloaded', loaded: false, busy: Boolean(loading),
24
31
  activeRequests: 0, progress: null, error: null, execution: null
25
32
  };
33
+ const pending = Boolean(loading) && (!snapshot.loaded || !sameExecutionTarget(loadedTarget, loadingTarget));
34
+ return {
35
+ ...snapshot, busy: snapshot.busy || pending,
36
+ pendingActivation: pending ? {executionTarget: loadingTarget} : snapshot.pendingActivation ?? null
37
+ };
26
38
  }
27
39
 
28
40
  function publish(value = current()) {
@@ -39,20 +51,24 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
39
51
 
40
52
  function operationSignal(signal) { return signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; }
41
53
 
42
- function load({signal} = {}) {
54
+ function load({signal, executionTarget: requestedTarget} = {}) {
43
55
  operationSignal(signal).throwIfAborted();
44
56
  if (!context) throw new CoreError({code: 'CORE_NOT_READY', message: 'Register and start the decision service with Core before loading.'});
45
- if (loading) return observeLoad(loading, loadController, signal);
46
- if (model?.current().loaded) return Promise.resolve(model.current());
47
- loadController = new AbortController();
48
- const controller = loadController;
57
+ const target = requestedTarget === undefined ? selectedTarget : requestedTarget;
58
+ if (loading && !loadController.signal.aborted && sameExecutionTarget(loadingTarget, target)) return observeLoad(loading, loadController, signal);
59
+ if (!loading && model?.current().loaded && sameExecutionTarget(loadedTarget, target)) return Promise.resolve(current());
60
+ const previousLoad = loading;
61
+ const previousController = loadController;
62
+ const controller = new AbortController();
49
63
  const selectedSignal = AbortSignal.any([lifetime.signal, controller.signal]);
50
64
  const precedingRelease = releasing;
51
- loading = Promise.resolve().then(async function loadSelectedModel() {
65
+ const task = Promise.resolve().then(async function loadSelectedModel() {
52
66
  selectedSignal.throwIfAborted();
53
67
  if (precedingRelease) await precedingRelease;
68
+ if (previousLoad) await Promise.allSettled([previousLoad]);
54
69
  selectedSignal.throwIfAborted();
55
70
  if (model) await model.dispose();
71
+ selectedSignal.throwIfAborted();
56
72
  unsubscribe?.();
57
73
  // Only the explicit load waits for its native dependencies. Merely
58
74
  // registering this service does not download a model or delay UI.
@@ -65,23 +81,43 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
65
81
  Object.entries(configuration.paths).map(function nativePath([name, value]) { return [name, path.resolve(appRoot, value)]; })
66
82
  );
67
83
  model = createNativeDecisionModel({
68
- ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets,
84
+ ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets, executionTarget: target,
69
85
  signal: lifetime.signal
70
86
  });
71
- unsubscribe = model.subscribe(publish);
72
- return model.load({signal: selectedSignal});
87
+ unsubscribe = model.subscribe(function modelStateChanged() { publish(); });
88
+ const result = await model.load({signal: selectedSignal});
89
+ selectedSignal.throwIfAborted();
90
+ loadedTarget = target;
91
+ return result;
73
92
  });
74
- function settled() { loading = undefined; loadController = undefined; publish(); }
75
- loading.then(settled, settled);
93
+ function settled() {
94
+ if (loading !== task) return;
95
+ loading = undefined;
96
+ loadController = undefined;
97
+ publish();
98
+ }
99
+ task.then(settled, settled).catch(function reportStateFailure(error) { console.error('Native decision state publication failed.', error); });
100
+ loading = task;
101
+ loadController = controller;
102
+ loadingTarget = target;
103
+ selectedTarget = target;
104
+ previousController?.abort(new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision activation request was replaced.'}));
76
105
  publish();
77
- return observeLoad(loading, controller, signal);
106
+ return observeLoad(task, controller, signal);
78
107
  }
79
108
 
80
109
  function observeLoad(task, controller, signal) {
81
110
  function abort() { controller.abort(signal.reason); }
82
111
  signal?.addEventListener('abort', abort, {once: true});
83
112
  if (signal?.aborted) abort();
84
- return task.finally(function detachLoadCaller() { signal?.removeEventListener('abort', abort); });
113
+ return task.then(function loadedActivation(result) {
114
+ controller.signal.throwIfAborted();
115
+ const snapshot = current();
116
+ if ((loading && loadController !== controller) || !snapshot.loaded || snapshot.execution !== result.execution) {
117
+ throw new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision activation was replaced.'});
118
+ }
119
+ return snapshot;
120
+ }).finally(function detachLoadCaller() { signal?.removeEventListener('abort', abort); });
85
121
  }
86
122
 
87
123
  async function evaluate(rows, {signal, runOptions} = {}) {
@@ -129,7 +165,7 @@ export function createNativeDecisionService(configuration = {}, {appRoot = proce
129
165
  start(serviceContext) { context = serviceContext; },
130
166
  methods: {
131
167
  'decisions.status': current,
132
- 'decisions.load': function loadRequest(parameters, request) { return load({signal: request.signal}); },
168
+ 'decisions.load': function loadRequest(parameters, request) { return load({executionTarget: parameters?.executionTarget, signal: request.signal}); },
133
169
  'decisions.evaluate': async function evaluateRequest({rows, runOptions}, request) {
134
170
  const result = await evaluate(rows, {runOptions, signal: request.signal});
135
171
  // Typed arrays retain their complete values through Core's
@@ -0,0 +1,32 @@
1
+ import {createExecutionDeviceCatalog} from '../../local-ai/execution-devices.mjs';
2
+
3
+ /**
4
+ * Independent of model-service startup. Core gives this service ownership of
5
+ * the shared catalog's disposal; model engines borrowing it do not dispose it.
6
+ */
7
+ export function createExecutionDeviceService({catalog = createExecutionDeviceCatalog()} = {}) {
8
+ function devices({refresh = false} = {}, {signal} = {}) {
9
+ return catalog.devices(
10
+ {refresh, signal}
11
+ );
12
+ }
13
+
14
+ function resolveTarget({executionTarget = null, refresh = false} = {}, {signal} = {}) {
15
+ return catalog.resolveTarget(
16
+ {executionTarget, refresh, signal}
17
+ );
18
+ }
19
+
20
+ function dispose() {
21
+ return catalog.dispose();
22
+ }
23
+
24
+ return {
25
+ name: 'execution-devices',
26
+ methods: {
27
+ 'localai.devices': devices,
28
+ 'localai.resolveTarget': resolveTarget
29
+ },
30
+ dispose
31
+ };
32
+ }
@@ -121,7 +121,7 @@ function collectCompletion(completion, chunk) {
121
121
  }
122
122
 
123
123
  /** Core owns native processes; applications own runtime and model selection. */
124
- export function createLocalAIService(configuration, {appRoot, runtimes = [], signal, onEvent, prepare} = {}) {
124
+ export function createLocalAIService(configuration, {appRoot, runtimes = [], signal, onEvent, prepare, executionDevices} = {}) {
125
125
  const config = normalizeLocalAIConfig(configuration) ?? {runtimes: []};
126
126
  const lifetime = new AbortController();
127
127
  const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
@@ -173,7 +173,9 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
173
173
 
174
174
  function startONNX() {
175
175
  if (!onnxRecord?.modulePath) return;
176
- onnx = createONNXRuntime({modulePath: onnxRecord.modulePath, signal: lifetimeSignal, onEvent});
176
+ onnx = createONNXRuntime(
177
+ {modulePath: onnxRecord.modulePath, signal: lifetimeSignal, onEvent, executionDevices}
178
+ );
177
179
  stopONNXSubscription = onnx.subscribe(function onnxChanged() { publish(); });
178
180
  }
179
181
 
@@ -630,9 +632,14 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
630
632
  return snapshot();
631
633
  },
632
634
  'onnx.status': function currentONNX() { return onnxStatus(); },
633
- 'onnx.load': function loadONNX({id, model, sessionOptions}, request) {
634
- return requireONNX().load({id, model: path.resolve(appRoot ?? process.cwd(), model), sessionOptions,
635
- signal: AbortSignal.any([lifetimeSignal, request.signal])});
635
+ 'onnx.load': function loadONNX({id, model, sessionOptions, executionPreference, executionTarget}, request) {
636
+ return requireONNX().load(
637
+ {
638
+ id, model: path.resolve(appRoot ?? process.cwd(), model), sessionOptions,
639
+ executionPreference, executionTarget,
640
+ signal: AbortSignal.any([lifetimeSignal, request.signal])
641
+ }
642
+ );
636
643
  },
637
644
  'onnx.run': async function runONNX({id, feeds, fetches, runOptions}, request) {
638
645
  const outputs = await requireONNX().run({id, feeds: decodeTensorMap(feeds),
@@ -13,6 +13,10 @@ function cancellation(message) {
13
13
 
14
14
  function reportCleanupFailure(error) { console.error('Native decision cleanup failed.', error); }
15
15
 
16
+ function sameExecutionTarget(left, right) {
17
+ return left === right || (left != null && right != null && left.deviceId !== undefined && left.deviceId === right.deviceId);
18
+ }
19
+
16
20
  /**
17
21
  * One explicitly activated Laya FP32 model using an existing native ONNX owner.
18
22
  * It does not create another inference runtime or load on construction.
@@ -20,7 +24,7 @@ function reportCleanupFailure(error) { console.error('Native decision cleanup fa
20
24
  export function createNativeDecisionModel({
21
25
  onnx, modelAssets, workingDirectory, paths,
22
26
  model = 'onnx-community/laya-typed-decisions-ONNX', revision = 'main',
23
- dtype = 'fp32', sessionOptions, executionPreference = 'gpu', signal
27
+ dtype = 'fp32', sessionOptions, executionPreference = 'gpu', executionTarget, signal
24
28
  } = {}) {
25
29
  if (dtype !== 'fp32') throw new TypeError('This native Laya graph selection requires dtype fp32.');
26
30
  const owner = {};
@@ -33,14 +37,18 @@ export function createNativeDecisionModel({
33
37
  let progress = null;
34
38
  let lastError = null;
35
39
  let execution = null;
40
+ let selectedTarget = executionTarget;
41
+ let pendingActivation;
36
42
 
37
43
  function current() {
44
+ const pending = pendingActivation && (state !== 'ready' || pendingActivation.entry !== activation) ? pendingActivation : null;
38
45
  return {
39
46
  family: 'laya', model, revision, dtype, state,
40
- loaded: state === 'ready', busy: state === 'loading' || Boolean(activation?.jobs.size),
47
+ loaded: state === 'ready', busy: state === 'loading' || Boolean(pending) || Boolean(activation?.jobs.size),
41
48
  activeRequests: activation?.jobs.size ?? 0, progress,
42
49
  error: lastError ? serializeCoreError(lastError) : null,
43
- execution
50
+ execution,
51
+ pendingActivation: pending ? {executionTarget: pending.executionTarget} : null
44
52
  };
45
53
  }
46
54
 
@@ -141,8 +149,8 @@ export function createNativeDecisionModel({
141
149
  return {model: files['onnx/model.onnx'], tokenizer: files['tokenizer.json'], tokenizerConfig: files['tokenizer_config.json']};
142
150
  }
143
151
 
144
- function beginActivation() {
145
- const entry = {id: randomUUID(), controller: new AbortController(), jobs: new Set(), prepared: false, onnxLoadStarted: false};
152
+ function beginActivation(target) {
153
+ const entry = {id: randomUUID(), controller: new AbortController(), jobs: new Set(), prepared: false, onnxLoadStarted: false, executionTarget: target};
146
154
  activation = entry;
147
155
  state = 'loading';
148
156
  progress = {phase: 'preparing-model'};
@@ -161,7 +169,7 @@ export function createNativeDecisionModel({
161
169
  });
162
170
  entry.onnxLoadStarted = true;
163
171
  const loaded = onnx.load({
164
- id: entry.id, model: files.model, sessionOptions, executionPreference,
172
+ id: entry.id, model: files.model, sessionOptions, executionPreference, executionTarget: entry.executionTarget,
165
173
  signal: entry.controller.signal
166
174
  });
167
175
  const results = await Promise.allSettled([loaded, entry.tokenizer.ready]);
@@ -193,19 +201,76 @@ export function createNativeDecisionModel({
193
201
  return entry;
194
202
  }
195
203
 
196
- async function load({signal: operationSignal} = {}) {
197
- assertOpen(operationSignal);
198
- if (releaseTask && !activation) await releaseTask;
199
- assertOpen(operationSignal);
200
- const entry = activation ?? beginActivation();
204
+ async function awaitActivation(entry, operationSignal) {
201
205
  const detach = observeCancellation(entry, operationSignal);
202
206
  try {
203
207
  await entry.loading;
204
208
  entry.controller.signal.throwIfAborted();
209
+ if (activation !== entry) throw cancellation('The decision activation was replaced.');
205
210
  return current();
206
211
  } finally { detach(); }
207
212
  }
208
213
 
214
+ function observeLoad(request, operationSignal) {
215
+ function abort() { request.controller.abort(operationSignal.reason); }
216
+ operationSignal?.addEventListener('abort', abort, {once: true});
217
+ if (operationSignal?.aborted) abort();
218
+ return request.task.then(function loadedActivation() {
219
+ request.controller.signal.throwIfAborted();
220
+ request.entry.controller.signal.throwIfAborted();
221
+ if (activation !== request.entry) throw cancellation('The decision activation was replaced.');
222
+ return current();
223
+ }).finally(function detachLoadCaller() { operationSignal?.removeEventListener('abort', abort); });
224
+ }
225
+
226
+ async function load({signal: operationSignal, executionTarget: requestedTarget} = {}) {
227
+ assertOpen(operationSignal);
228
+ const target = requestedTarget === undefined ? selectedTarget : requestedTarget;
229
+ if (pendingActivation && !pendingActivation.controller.signal.aborted && sameExecutionTarget(pendingActivation.executionTarget, target)) {
230
+ return observeLoad(pendingActivation, operationSignal);
231
+ }
232
+ if (!pendingActivation && activation && sameExecutionTarget(activation.executionTarget, target)) {
233
+ return awaitActivation(activation, operationSignal);
234
+ }
235
+ const previous = pendingActivation;
236
+ const request = {executionTarget: target, controller: new AbortController()};
237
+ let precedingRelease;
238
+ // Retain the replacement before synchronous abort/state observers run.
239
+ // Only this model's retiring activation must finish before its successor.
240
+ request.task = Promise.resolve().then(async function loadSelectedTarget() {
241
+ request.controller.signal.throwIfAborted();
242
+ if (precedingRelease) await precedingRelease;
243
+ assertOpen(request.controller.signal);
244
+ if (pendingActivation !== request) throw cancellation('The decision activation request was replaced.');
245
+ request.entry ??= activation ?? beginActivation(request.executionTarget);
246
+ return awaitActivation(request.entry, request.controller.signal);
247
+ });
248
+ function settled(error) {
249
+ request.detach?.();
250
+ if (pendingActivation !== request) return;
251
+ pendingActivation = undefined;
252
+ if (!request.entry && !activation && !disposed && state === 'loading') {
253
+ state = request.controller.signal.aborted ? 'unloaded' : 'error';
254
+ progress = null;
255
+ lastError = request.controller.signal.aborted ? null : error;
256
+ }
257
+ publish();
258
+ }
259
+ request.task.then(function loaded() { settled(); }, function loadFailed(error) { settled(error); }).catch(reportCleanupFailure);
260
+ pendingActivation = request;
261
+ selectedTarget = target;
262
+ const reason = cancellation('The decision execution target was replaced.');
263
+ previous?.controller.abort(reason);
264
+ if (activation && !sameExecutionTarget(activation.executionTarget, target)) stop(activation, reason);
265
+ precedingRelease = !activation ? releaseTask : undefined;
266
+ if (!activation && !precedingRelease) {
267
+ request.entry = beginActivation(target);
268
+ request.detach = observeCancellation(request.entry, request.controller.signal);
269
+ }
270
+ publish();
271
+ return observeLoad(request, operationSignal);
272
+ }
273
+
209
274
  async function evaluate(rows, {signal: operationSignal, runOptions} = {}) {
210
275
  assertOpen(operationSignal);
211
276
  const entry = activation;
@@ -247,21 +312,34 @@ export function createNativeDecisionModel({
247
312
  }
248
313
 
249
314
  function unload() {
250
- return activation ? stop(activation, cancellation('The native decision model was unloaded.'))
251
- : releaseTask ?? Promise.resolve(current());
315
+ const reason = cancellation('The native decision model was unloaded.');
316
+ const pending = pendingActivation;
317
+ pending?.controller.abort(reason);
318
+ const release = activation ? stop(activation, reason) : releaseTask ?? Promise.resolve(current());
319
+ if (!pending) return release;
320
+ return Promise.allSettled([release, pending.task]).then(function releasedActivation(results) {
321
+ if (results[0].status === 'rejected') throw results[0].reason;
322
+ return current();
323
+ });
252
324
  }
253
325
 
254
326
  function dispose() {
255
327
  if (disposal) return disposal;
256
328
  disposed = true;
257
329
  signal?.removeEventListener('abort', lifetimeAborted);
330
+ const pending = pendingActivation;
331
+ const reason = cancellation('The native decision model was disposed.');
258
332
  let release;
259
333
  disposal = Promise.resolve().then(async function disposeNativeModel() {
260
- try { await release; }
334
+ try {
335
+ const results = await Promise.allSettled([release, pending?.task]);
336
+ if (results[0].status === 'rejected') throw results[0].reason;
337
+ }
261
338
  finally { state = 'disposed'; publish(); events.dispose(); }
262
339
  return current();
263
340
  });
264
- release = activation ? stop(activation, cancellation('The native decision model was disposed.')) : releaseTask;
341
+ pending?.controller.abort(reason);
342
+ release = activation ? stop(activation, reason) : releaseTask;
265
343
  if (!activation && !release) { state = 'disposed'; publish(); }
266
344
  return disposal;
267
345
  }
@@ -4,19 +4,23 @@ import {createLocalAIService} from '../core/services/local-ai.mjs';
4
4
  import {createLocalImageService} from '../core/services/image.mjs';
5
5
  import {createModelAssetService} from '../core/services/model-assets.mjs';
6
6
  import {createSpeechService} from '../core/services/speech.mjs';
7
+ import {createExecutionDeviceService} from '../core/services/execution-devices.mjs';
7
8
  import {normalizeLocalAIConfig} from './config.mjs';
8
9
  import {discoverLocalAIRuntimes} from './discover.mjs';
9
10
  import {ensureLocalAIRuntimes} from './install.mjs';
10
11
  import {createWhisperRuntime} from './whisper/index.mjs';
12
+ import {createExecutionDeviceCatalog} from './execution-devices.mjs';
11
13
 
12
14
  /** Local engines share the ordinary app-service development transport. */
13
15
  export function createDevelopmentLocalAI({
14
16
  config, appRoot, directory, application, version, signal, onEvent,
15
17
  services = [], serviceModules = [], context = {}
16
18
  } = {}) {
19
+ const selected = normalizeLocalAIConfig(config) ?? {runtimes: []};
17
20
  const lifetime = new AbortController();
18
21
  signal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
19
- const selected = normalizeLocalAIConfig(config) ?? {runtimes: []};
22
+ // Discovery starts on demand, independently of model runtime preparation.
23
+ const executionDevices = createExecutionDeviceCatalog({signal});
20
24
  const localConfig = {
21
25
  ...selected,
22
26
  runtimes: selected.runtimes.filter(function selectedChatRuntime(requirement) {
@@ -26,7 +30,7 @@ export function createDevelopmentLocalAI({
26
30
  const service = createLocalAIService(
27
31
  localConfig,
28
32
  {
29
- appRoot, signal, onEvent,
33
+ appRoot, signal, onEvent, executionDevices,
30
34
  async prepare({signal, onEvent: report}) {
31
35
  const discovered = await discoverLocalAIRuntimes({config: localConfig, appRoot, signal});
32
36
  const installed = await ensureLocalAIRuntimes(
@@ -36,7 +40,7 @@ export function createDevelopmentLocalAI({
36
40
  }
37
41
  }
38
42
  );
39
- const definitions = [service, ...services];
43
+ const definitions = [createExecutionDeviceService({catalog: executionDevices}), service, ...services];
40
44
  let modelAssets;
41
45
  let imageService;
42
46
  let speechService;