arcane-os 0.66.0 → 0.68.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +1 -1
  3. package/browser-runtime/ai/core-image.mjs +84 -34
  4. package/browser-runtime/ai/core-local.mjs +195 -43
  5. package/browser-runtime/ai/core-onnx.mjs +71 -12
  6. package/docs/reference/README.md +6 -0
  7. package/docs/reference/availability-and-normalization.md +1 -0
  8. package/docs/reference/core-native-packaging.md +11 -4
  9. package/docs/reference/core-repositories.md +93 -9
  10. package/docs/reference/core-runtime.md +46 -5
  11. package/docs/reference/inventory/package-api.json +62 -20
  12. package/docs/reference/inventory/runtime-components.json +2 -1
  13. package/docs/reference/inventory/runtime-modules.json +3 -3
  14. package/docs/reference/local-ai.md +112 -2
  15. package/docs/reference/local-image-generation.md +15 -4
  16. package/docs/reference/model-assets.md +50 -3
  17. package/docs/reference/native-decisions.md +181 -0
  18. package/docs/reference/runtime-components.md +62 -5
  19. package/docs/reference/runtime-modules.md +44 -7
  20. package/docs/reference/sdk-api.md +229 -19
  21. package/package.json +5 -2
  22. package/runtime/arcane/components/theme-switcher.html +112 -13
  23. package/runtime/arcane/modules/PreferenceStore.js +18 -13
  24. package/runtime/arcane/modules/SystemAppearance.js +24 -6
  25. package/src/core/repositories.mjs +27 -8
  26. package/src/core/runtime.mjs +26 -1
  27. package/src/core/services/decisions.mjs +144 -0
  28. package/src/core/services/local-ai.mjs +31 -9
  29. package/src/core/services/model-assets.mjs +98 -13
  30. package/src/git-identity.mjs +22 -58
  31. package/src/local-ai/decision-tokenizer-worker.mjs +31 -0
  32. package/src/local-ai/decision-tokenizer.mjs +109 -0
  33. package/src/local-ai/decisions.mjs +273 -0
  34. package/src/local-ai/dev.mjs +8 -2
  35. package/src/local-ai/onnx-worker.mjs +67 -7
  36. package/src/local-ai/onnx.mjs +23 -12
  37. package/src/native/portable-layout.mjs +8 -3
  38. package/src/repository.mjs +144 -4
@@ -3,6 +3,7 @@ import Is from 'strong-type';
3
3
  import {ArcaneError, throwIfAborted} from './errors.mjs';
4
4
  import {runProcess} from './process.mjs';
5
5
  import {createEventQueue} from './event-queue.mjs';
6
+ import {readGitText} from './repository.mjs';
6
7
 
7
8
  const is = new Is(false);
8
9
 
@@ -16,67 +17,30 @@ export async function readGitIdentity({directory, signal, onEvent, run = runProc
16
17
  const args = ['config'];
17
18
  if (scope !== 'effective') args.push(`--${scope}`);
18
19
  args.push('--includes', '--null', '--get-regexp', '^(user[.](name|email)|github[.]user)$');
19
- const chunks = [];
20
- try {
21
- const result = await run('git', args, {
22
- cwd, signal, onEvent: events.send, allowNonzero: true,
23
- outputEncoding: {stdout: null},
24
- captureOutput: {stdout: false},
25
- emitOutputEvents: {stdout: false},
26
- // Git configuration may contain non-UTF-8 values. Keep its raw
27
- // output until close rather than silently replacing characters.
28
- onOutput: function observeOutput({stream, chunk}) {
29
- if (stream === 'stdout') chunks.push(chunk);
30
- }
31
- });
32
- try { throwIfAborted(signal); }
33
- catch (error) { error.details = result; throw error; }
34
- const identity = {name: null, email: null, githubUser: null};
35
- if (result.code === 1) return identity;
36
- if (result.code !== 0) {
37
- throw new ArcaneError('ARCANE_OPERATION_FAILED',
38
- `Git ${scope} identity lookup exited with code ${String(result.code)}.`, {details: result});
39
- }
40
- let output;
41
- try { output = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(Buffer.concat(chunks)); }
42
- catch (cause) {
43
- throw new ArcaneError('ARCANE_GIT_IDENTITY_NOT_TEXT',
44
- `Git ${scope} identity configuration cannot be represented as UTF-8 text.`, {cause, details: result});
45
- }
46
- const fields = {'user.name': 'name', 'user.email': 'email', 'github.user': 'githubUser'};
47
- // Git's NUL record framing preserves complete values, including newlines
48
- // and empty strings. The last occurrence is the effective scalar value.
49
- for (const record of output.split('\0')) {
50
- if (record === '') continue;
51
- const separator = record.indexOf('\n');
52
- const key = separator === -1 ? record : record.slice(0, separator);
53
- if (Object.hasOwn(fields, key)) {
54
- identity[fields[key]] = separator === -1 ? '' : record.slice(separator + 1);
55
- }
56
- }
57
- return identity;
58
- } catch (error) {
59
- // Keep the process error and its native details intact. Raw stdout
60
- // belongs to this reader because the process owner did not decode it.
61
- const rawStdout = Buffer.concat(chunks);
62
- let attached = false;
63
- let attachmentError;
64
- try {
65
- if (error !== null && (is.object(error) || is.function(error)) && !('rawStdout' in error)) {
66
- Object.defineProperty(error, 'rawStdout', {
67
- value: rawStdout, writable: true, enumerable: true, configurable: true
68
- });
69
- attached = true;
70
- }
71
- } catch (cause) { attachmentError = cause; }
72
- if (attached) throw error;
73
- const failure = new ArcaneError(error?.code ?? 'ARCANE_OPERATION_FAILED',
74
- error instanceof Error ? error.message : String(error),
75
- {cause: error, details: error?.details, exitCode: error?.exitCode});
20
+ const {result, output, rawStdout} = await readGitText(args, {
21
+ cwd, signal, onEvent: events.send, run, allowNonzero: true,
22
+ notTextCode: 'ARCANE_GIT_IDENTITY_NOT_TEXT', label: `Git ${scope} identity configuration`
23
+ });
24
+ const identity = {name: null, email: null, githubUser: null};
25
+ if (result.code === 1) return identity;
26
+ if (result.code !== 0) {
27
+ const failure = new ArcaneError('ARCANE_OPERATION_FAILED',
28
+ `Git ${scope} identity lookup exited with code ${String(result.code)}.`, {details: result});
76
29
  failure.rawStdout = rawStdout;
77
- if (attachmentError) failure.attachmentError = attachmentError;
78
30
  throw failure;
79
31
  }
32
+ const fields = {'user.name': 'name', 'user.email': 'email', 'github.user': 'githubUser'};
33
+ // Git's NUL record framing preserves complete values, including newlines
34
+ // and empty strings. The last occurrence is the effective scalar value.
35
+ for (const record of output.split('\0')) {
36
+ if (record === '') continue;
37
+ const separator = record.indexOf('\n');
38
+ const key = separator === -1 ? record : record.slice(0, separator);
39
+ if (Object.hasOwn(fields, key)) {
40
+ identity[fields[key]] = separator === -1 ? '' : record.slice(separator + 1);
41
+ }
42
+ }
43
+ return identity;
80
44
  }
81
45
 
82
46
  const scopes = cwd === undefined ? ['global'] : ['global', 'local', 'effective'];
@@ -0,0 +1,31 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {parentPort, workerData} from 'node:worker_threads';
3
+ import {Tokenizer} from '@huggingface/tokenizers';
4
+ import {createDecisionInputs, encodeDecisionRows} from '../../browser-runtime/ai/decision-runtime.mjs';
5
+ import {serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
6
+
7
+ // Parsing and tokenization run here, not on Core's request/rendering thread.
8
+ try {
9
+ const [tokenizerText, configurationText] = await Promise.all([
10
+ readFile(workerData.tokenizerPath, 'utf8'),
11
+ readFile(workerData.tokenizerConfigPath, 'utf8')
12
+ ]);
13
+ const tokenizer = new Tokenizer(JSON.parse(tokenizerText), JSON.parse(configurationText));
14
+ const interfaceAdapter = {
15
+ encode(text, options) { return tokenizer.encode(text, options).ids; },
16
+ convert_tokens_to_ids(token) { return tokenizer.token_to_id(token); }
17
+ };
18
+ parentPort.on('message', function encodeRequest({id, rows}) {
19
+ try {
20
+ const {encoded, pad} = encodeDecisionRows(interfaceAdapter, rows, 'laya');
21
+ const inputs = createDecisionInputs(encoded, pad);
22
+ parentPort.postMessage({id, result: inputs});
23
+ } catch (error) {
24
+ parentPort.postMessage({id, error: serializeCoreError(error)});
25
+ }
26
+ });
27
+ parentPort.postMessage({id: 0, result: {ready: true}});
28
+ } catch (error) {
29
+ parentPort.postMessage({id: 0, error: serializeCoreError(error)});
30
+ parentPort.close();
31
+ }
@@ -0,0 +1,109 @@
1
+ import {Worker} from 'node:worker_threads';
2
+ import {CoreError} from '../../browser-runtime/core/contracts.mjs';
3
+
4
+ function cancelled() {
5
+ return new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'Decision tokenization was cancelled.'});
6
+ }
7
+
8
+ /** One tokenizer worker owned by one native model activation. */
9
+ export function createDecisionTokenizer({tokenizerPath, tokenizerConfigPath, signal, onError}) {
10
+ signal?.throwIfAborted();
11
+ const pending = new Map();
12
+ let nextId = 0;
13
+ let closing;
14
+ let terminalError;
15
+ let exited = false;
16
+ const worker = new Worker(new URL('./decision-tokenizer-worker.mjs', import.meta.url), {
17
+ workerData: {tokenizerPath, tokenizerConfigPath}
18
+ });
19
+ let resolveExit;
20
+ const exit = new Promise(function tokenizerExit(resolve) { resolveExit = resolve; });
21
+
22
+ function rejectPending(error) {
23
+ terminalError ??= error;
24
+ for (const operation of pending.values()) operation.reject(error);
25
+ pending.clear();
26
+ }
27
+
28
+ function operation(id) {
29
+ return new Promise(function retainOperation(resolve, reject) {
30
+ pending.set(id, {resolve, reject});
31
+ });
32
+ }
33
+
34
+ const ready = operation(0);
35
+ // The native owner observes ready together with ONNX load. Retain an error
36
+ // handler immediately when the other owner is still starting.
37
+ ready.catch(function observeTokenizerLoadFailure() {});
38
+ worker.on('message', function tokenizerMessage(message) {
39
+ const request = pending.get(message.id);
40
+ if (!request) return;
41
+ pending.delete(message.id);
42
+ if (Object.hasOwn(message, 'error')) {
43
+ const error = new CoreError(message.error);
44
+ request.reject(error);
45
+ if (message.id === 0) failed(error);
46
+ }
47
+ else request.resolve(message.result);
48
+ });
49
+ worker.on('error', failed);
50
+ worker.on('messageerror', failed);
51
+ worker.once('exit', function tokenizerExited(code) {
52
+ exited = true;
53
+ signal?.removeEventListener('abort', abort);
54
+ const error = terminalError ?? new CoreError({
55
+ code: 'ARCANE_DECISION_TOKENIZER_CLOSED',
56
+ message: `The decision tokenizer worker exited with code ${code}.`
57
+ });
58
+ if (!closing) failed(error);
59
+ else rejectPending(error);
60
+ resolveExit(code);
61
+ });
62
+
63
+ function failed(error) {
64
+ const firstFailure = !terminalError && !closing;
65
+ rejectPending(error);
66
+ close(error).catch(function reportTokenizerFailure(value) { console.error(value); });
67
+ if (firstFailure) onError?.(error);
68
+ }
69
+
70
+ function close(reason = cancelled()) {
71
+ if (closing) return closing;
72
+ rejectPending(reason);
73
+ signal?.removeEventListener('abort', abort);
74
+ closing = Promise.resolve().then(async function releaseTokenizer() {
75
+ let terminationError;
76
+ try { if (!exited) await worker.terminate(); }
77
+ catch (error) {
78
+ terminationError = error;
79
+ console.error('Terminating the decision tokenizer failed.', error);
80
+ }
81
+ // A rejected termination request is not evidence that the worker
82
+ // has stopped reading its files. Retain ownership until real exit.
83
+ await exit;
84
+ if (terminationError) throw terminationError;
85
+ });
86
+ return closing;
87
+ }
88
+
89
+ function abort() {
90
+ close(signal.reason).catch(function reportTokenizerCloseFailure(error) { console.error(error); });
91
+ }
92
+ signal?.addEventListener('abort', abort, {once: true});
93
+ if (signal?.aborted) abort();
94
+
95
+ async function encode(rows) {
96
+ await ready;
97
+ if (terminalError || closing || exited) throw terminalError ?? cancelled();
98
+ const id = ++nextId;
99
+ const result = operation(id);
100
+ try { worker.postMessage({id, rows}); }
101
+ catch (error) {
102
+ pending.get(id).reject(error);
103
+ pending.delete(id);
104
+ }
105
+ return result;
106
+ }
107
+
108
+ return {ready, encode, close, get exited() { return exited; }};
109
+ }
@@ -0,0 +1,273 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {createArcaneEventSource} from '../event-manager.mjs';
3
+ import {CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
4
+ import {decodeDecisionOutputs} from '../../browser-runtime/ai/decision-runtime.mjs';
5
+ import {createDecisionTokenizer} from './decision-tokenizer.mjs';
6
+
7
+ const STATE_EVENT = 'ai.decisions.state';
8
+ const LAYA_FILES = ['onnx/model.onnx', 'onnx/model.onnx_data', 'tokenizer.json', 'tokenizer_config.json'];
9
+
10
+ function cancellation(message) {
11
+ return new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message});
12
+ }
13
+
14
+ function reportCleanupFailure(error) { console.error('Native decision cleanup failed.', error); }
15
+
16
+ /**
17
+ * One explicitly activated Laya FP32 model using an existing native ONNX owner.
18
+ * It does not create another inference runtime or load on construction.
19
+ */
20
+ export function createNativeDecisionModel({
21
+ onnx, modelAssets, workingDirectory, paths,
22
+ model = 'onnx-community/laya-typed-decisions-ONNX', revision = 'main',
23
+ dtype = 'fp32', sessionOptions, executionPreference = 'gpu', signal
24
+ } = {}) {
25
+ if (dtype !== 'fp32') throw new TypeError('This native Laya graph selection requires dtype fp32.');
26
+ const owner = {};
27
+ const events = createArcaneEventSource(owner, {source: 'native-decisions', eventTypes: [STATE_EVENT]});
28
+ let activation;
29
+ let releaseTask;
30
+ let disposal;
31
+ let disposed = false;
32
+ let state = 'unloaded';
33
+ let progress = null;
34
+ let lastError = null;
35
+ let execution = null;
36
+
37
+ function current() {
38
+ return {
39
+ family: 'laya', model, revision, dtype, state,
40
+ loaded: state === 'ready', busy: state === 'loading' || Boolean(activation?.jobs.size),
41
+ activeRequests: activation?.jobs.size ?? 0, progress,
42
+ error: lastError ? serializeCoreError(lastError) : null,
43
+ execution
44
+ };
45
+ }
46
+
47
+ function publish() { events.dispatch(STATE_EVENT, current()); }
48
+
49
+ function subscribe(listener, {emitCurrent = true, signal: subscriptionSignal} = {}) {
50
+ const unsubscribe = events.on(STATE_EVENT, function changed(event) { listener(event.detail); }, {signal: subscriptionSignal});
51
+ try { if (emitCurrent && !subscriptionSignal?.aborted) listener(current()); }
52
+ catch (error) { unsubscribe(); throw error; }
53
+ return unsubscribe;
54
+ }
55
+
56
+ function assertOpen(operationSignal) {
57
+ if (disposed) throw new CoreError({code: 'ARCANE_AI_DISPOSED', message: 'The native decision model is disposed.'});
58
+ signal?.throwIfAborted();
59
+ operationSignal?.throwIfAborted();
60
+ }
61
+
62
+ function updateProgress(entry, value) {
63
+ if (activation !== entry || entry.controller.signal.aborted) return;
64
+ progress = value;
65
+ publish();
66
+ }
67
+
68
+ function stop(entry, reason, terminalState = 'unloaded') {
69
+ if (entry.release) return entry.release;
70
+ if (activation === entry) {
71
+ activation = undefined;
72
+ state = disposed ? 'disposing' : 'unloading';
73
+ progress = null;
74
+ lastError = terminalState === 'error' ? reason : null;
75
+ }
76
+ // Establish cleanup ownership before abort observers or state listeners
77
+ // can request another unload or dispose.
78
+ entry.release = Promise.resolve().then(async function releaseActivation() {
79
+ const failures = [];
80
+ await Promise.allSettled([entry.loading, ...entry.jobs]);
81
+ entry.unsubscribe?.();
82
+ const ownedSession = onnx.current().sessions.some(function owned(value) { return value.id === entry.id; });
83
+ const engineRelease = entry.onnxLoadStarted && ownedSession
84
+ ? onnx.unload({id: entry.id})
85
+ : Promise.resolve();
86
+ const releases = await Promise.allSettled([engineRelease, entry.tokenizer?.close(reason)]);
87
+ for (const result of releases) if (result.status === 'rejected') failures.push(result.reason);
88
+ // unload joins actual native worker exit, even when load/run already
89
+ // rejected. Working files cannot be retired at promise rejection.
90
+ const exited = onnx.current().sessions.find(function owned(value) { return value.id === entry.id; })?.exited;
91
+ const engineStopped = releases[0].status === 'fulfilled' || exited === true;
92
+ const tokenizerStopped = !entry.tokenizer || entry.tokenizer.exited;
93
+ if (engineStopped && tokenizerStopped) {
94
+ try { await entry.retained?.release(); } catch (error) { failures.push(error); }
95
+ if (entry.prepared) {
96
+ try { await modelAssets.release(entry.id); } catch (error) { failures.push(error); }
97
+ }
98
+ }
99
+ const cleanupError = failures.length ? new AggregateError(failures, 'Releasing native decision resources failed.') : null;
100
+ if (!activation) {
101
+ state = disposed ? 'disposed' : cleanupError ? 'error' : terminalState;
102
+ progress = null;
103
+ execution = null;
104
+ if (cleanupError) lastError = cleanupError;
105
+ publish();
106
+ }
107
+ if (cleanupError) throw cleanupError;
108
+ return current();
109
+ });
110
+ releaseTask = entry.release;
111
+ entry.controller.abort(reason);
112
+ entry.tokenizer?.close(reason).catch(reportCleanupFailure);
113
+ publish();
114
+ entry.release.catch(reportCleanupFailure);
115
+ return entry.release;
116
+ }
117
+
118
+ function observeCancellation(entry, operationSignal) {
119
+ function abort() { stop(entry, operationSignal.reason).catch(reportCleanupFailure); }
120
+ operationSignal?.addEventListener('abort', abort, {once: true});
121
+ if (operationSignal?.aborted) abort();
122
+ return function detachAbort() { operationSignal?.removeEventListener('abort', abort); };
123
+ }
124
+
125
+ async function prepareFiles(entry) {
126
+ if (paths) return paths;
127
+ if (!modelAssets) throw new CoreError({code: 'ARCANE_DECISION_ASSETS_UNAVAILABLE', message: 'Native decisions need prepared paths or the Core model-assets owner.'});
128
+ const base = `https://huggingface.co/${model}/resolve/${encodeURIComponent(revision)}/`;
129
+ const projection = await modelAssets.prepare({
130
+ id: entry.id, workingDirectory,
131
+ members: LAYA_FILES.map(function selectFile(path) { return {path, url: new URL(path, base).href}; }),
132
+ signal: entry.controller.signal,
133
+ onProgress(value) { updateProgress(entry, value); }
134
+ });
135
+ entry.prepared = true;
136
+ entry.controller.signal.throwIfAborted();
137
+ entry.retained = modelAssets.retain(entry.id);
138
+ await modelAssets.release(entry.id);
139
+ entry.prepared = false;
140
+ const files = Object.fromEntries(projection.members.map(function memberFile(member) { return [member.path, member.nativePath]; }));
141
+ return {model: files['onnx/model.onnx'], tokenizer: files['tokenizer.json'], tokenizerConfig: files['tokenizer_config.json']};
142
+ }
143
+
144
+ function beginActivation() {
145
+ const entry = {id: randomUUID(), controller: new AbortController(), jobs: new Set(), prepared: false, onnxLoadStarted: false};
146
+ activation = entry;
147
+ state = 'loading';
148
+ progress = {phase: 'preparing-model'};
149
+ lastError = null;
150
+ execution = null;
151
+ entry.loading = Promise.resolve().then(async function activateNativeModel() {
152
+ const files = await prepareFiles(entry);
153
+ entry.controller.signal.throwIfAborted();
154
+ updateProgress(entry, {phase: 'loading-model'});
155
+ entry.tokenizer = createDecisionTokenizer({
156
+ tokenizerPath: files.tokenizer, tokenizerConfigPath: files.tokenizerConfig,
157
+ signal: entry.controller.signal,
158
+ onError(error) {
159
+ if (activation === entry) stop(entry, error, 'error').catch(reportCleanupFailure);
160
+ }
161
+ });
162
+ entry.onnxLoadStarted = true;
163
+ const loaded = onnx.load({
164
+ id: entry.id, model: files.model, sessionOptions, executionPreference,
165
+ signal: entry.controller.signal
166
+ });
167
+ const results = await Promise.allSettled([loaded, entry.tokenizer.ready]);
168
+ const failures = results.filter(function failed(result) { return result.status === 'rejected'; })
169
+ .map(function reason(result) { return result.reason; });
170
+ if (failures.length) throw failures.length === 1 ? failures[0] : new AggregateError(failures, 'Loading the native decision model failed.');
171
+ entry.controller.signal.throwIfAborted();
172
+ if (activation !== entry) throw cancellation('The decision activation was replaced.');
173
+ execution = results[0].value;
174
+ state = 'ready';
175
+ progress = {phase: 'ready'};
176
+ entry.unsubscribe = onnx.subscribe(function nativeStateChanged(snapshot) {
177
+ if (activation !== entry || entry.controller.signal.aborted) return;
178
+ const selected = snapshot.sessions.find(function sameSession(value) { return value.id === entry.id; });
179
+ // A failed run may leave ONNX loaded and its queued runs usable.
180
+ // Stopping revokes readiness before physical residency ends.
181
+ if (snapshot.closed || selected?.stopping || !selected?.loaded || ['unloading', 'unloaded'].includes(selected.state)) {
182
+ const error = selected?.error ? new CoreError(selected.error) : cancellation('The native ONNX session was retired.');
183
+ stop(entry, error, selected?.error ? 'error' : 'unloaded').catch(reportCleanupFailure);
184
+ }
185
+ });
186
+ publish();
187
+ return current();
188
+ });
189
+ entry.loading.catch(function activationFailed(error) {
190
+ if (activation === entry) stop(entry, error, entry.controller.signal.aborted ? 'unloaded' : 'error').catch(reportCleanupFailure);
191
+ });
192
+ publish();
193
+ return entry;
194
+ }
195
+
196
+ async function load({signal: operationSignal} = {}) {
197
+ assertOpen(operationSignal);
198
+ if (releaseTask && !activation) await releaseTask;
199
+ assertOpen(operationSignal);
200
+ const entry = activation ?? beginActivation();
201
+ const detach = observeCancellation(entry, operationSignal);
202
+ try {
203
+ await entry.loading;
204
+ entry.controller.signal.throwIfAborted();
205
+ return current();
206
+ } finally { detach(); }
207
+ }
208
+
209
+ async function evaluate(rows, {signal: operationSignal, runOptions} = {}) {
210
+ assertOpen(operationSignal);
211
+ const entry = activation;
212
+ if (!entry) throw new CoreError({code: 'ARCANE_DECISION_NOT_LOADED', message: 'Load the selected native decision model before evaluating.'});
213
+ const detach = observeCancellation(entry, operationSignal);
214
+ const task = Promise.resolve().then(async function evaluateCompleteRows() {
215
+ await entry.loading;
216
+ entry.controller.signal.throwIfAborted();
217
+ if (rows.length === 0) return {decisions: [], outputs: {}};
218
+ updateProgress(entry, {phase: 'tokenizing'});
219
+ const feeds = await entry.tokenizer.encode(rows);
220
+ entry.controller.signal.throwIfAborted();
221
+ updateProgress(entry, {phase: 'evaluating'});
222
+ const outputs = await onnx.run({id: entry.id, feeds, runOptions, signal: entry.controller.signal});
223
+ entry.controller.signal.throwIfAborted();
224
+ return decodeDecisionOutputs(rows, outputs);
225
+ });
226
+ entry.jobs.add(task);
227
+ publish();
228
+ let result;
229
+ try { result = await task; }
230
+ catch (error) {
231
+ if (activation === entry && !entry.controller.signal.aborted) {
232
+ lastError = error;
233
+ publish();
234
+ }
235
+ throw error;
236
+ } finally {
237
+ detach();
238
+ entry.jobs.delete(task);
239
+ if (activation === entry) {
240
+ progress = {phase: entry.jobs.size ? 'evaluating' : 'ready'};
241
+ publish();
242
+ }
243
+ }
244
+ entry.controller.signal.throwIfAborted();
245
+ if (activation !== entry) throw cancellation('The decision result belongs to a retired activation.');
246
+ return result;
247
+ }
248
+
249
+ function unload() {
250
+ return activation ? stop(activation, cancellation('The native decision model was unloaded.'))
251
+ : releaseTask ?? Promise.resolve(current());
252
+ }
253
+
254
+ function dispose() {
255
+ if (disposal) return disposal;
256
+ disposed = true;
257
+ signal?.removeEventListener('abort', lifetimeAborted);
258
+ let release;
259
+ disposal = Promise.resolve().then(async function disposeNativeModel() {
260
+ try { await release; }
261
+ finally { state = 'disposed'; publish(); events.dispose(); }
262
+ return current();
263
+ });
264
+ release = activation ? stop(activation, cancellation('The native decision model was disposed.')) : releaseTask;
265
+ if (!activation && !release) { state = 'disposed'; publish(); }
266
+ return disposal;
267
+ }
268
+
269
+ function lifetimeAborted() { dispose().catch(reportCleanupFailure); }
270
+ signal?.addEventListener('abort', lifetimeAborted, {once: true});
271
+ if (signal?.aborted) lifetimeAborted();
272
+ return {load, current, status: current, subscribe, evaluate, classify: evaluate, unload, dispose};
273
+ }
@@ -40,10 +40,16 @@ export function createDevelopmentLocalAI({
40
40
  let modelAssets;
41
41
  let imageService;
42
42
  let speechService;
43
- if (selected.runtimes.some(function selectedImageRuntime(requirement) {
43
+ const imageSelected = selected.runtimes.some(function selectedImageRuntime(requirement) {
44
44
  return requirement.id === 'stable-diffusion.cpp';
45
+ });
46
+ if (imageSelected || selected.runtimes.some(function selectedONNXRuntime(requirement) {
47
+ return requirement.id === 'onnx';
45
48
  })) {
46
49
  modelAssets = createModelAssetService({appRoot});
50
+ definitions.push(modelAssets);
51
+ }
52
+ if (imageSelected) {
47
53
  imageService = createLocalImageService(
48
54
  selected,
49
55
  {
@@ -56,7 +62,7 @@ export function createDevelopmentLocalAI({
56
62
  }
57
63
  }
58
64
  );
59
- definitions.push(modelAssets, imageService);
65
+ definitions.push(imageService);
60
66
  }
61
67
  const whisperRequirement = selected.runtimes.find(function selectedWhisperRuntime(requirement) {
62
68
  return requirement.id === 'whisper.cpp';
@@ -15,6 +15,71 @@ function describeError(error){
15
15
  return result;
16
16
  }
17
17
 
18
+ async function createSession(request) {
19
+ const supplied = request.sessionOptions;
20
+ const selectProviders = supplied === undefined
21
+ || (supplied !== null && is.object(supplied) && supplied.executionProviders === undefined);
22
+ const options = selectProviders ? {...supplied, executionProviders: ['cpu']} : supplied;
23
+ if (request.executionPreference !== 'gpu' || !selectProviders) {
24
+ session = await ort.InferenceSession.create(request.model, options);
25
+ return undefined;
26
+ }
27
+
28
+ const execution = {
29
+ preference: 'gpu',
30
+ supportedBackends: [],
31
+ selectedProviders: [],
32
+ attempts: [],
33
+ fallback: false,
34
+ discoveryError: null
35
+ };
36
+ const failures = [];
37
+ try {
38
+ execution.supportedBackends = ort.listSupportedBackends();
39
+ } catch (error) {
40
+ execution.discoveryError = describeError(error);
41
+ failures.push(error);
42
+ }
43
+ // The inventory advertises compiled support. `bundled` describes packaging,
44
+ // not whether an installed provider library or physical device can load.
45
+ const candidates = ['cuda', 'tensorrt', 'dml', 'coreml', 'webgpu'].filter(
46
+ function advertisedProvider(name) {
47
+ return execution.supportedBackends.some(
48
+ function supportedBackend(backend) { return backend.name === name; }
49
+ );
50
+ }
51
+ );
52
+ candidates.push('cpu');
53
+ for (const provider of candidates) {
54
+ const executionProviders = provider === 'cpu' ? ['cpu'] : [provider, 'cpu'];
55
+ const selectedOptions = {...supplied, executionProviders};
56
+ if (provider === 'dml') {
57
+ // DirectML requires these session settings. Explicit caller values
58
+ // remain exact and can produce the native provider's own error.
59
+ if (selectedOptions.enableMemPattern === undefined) selectedOptions.enableMemPattern = false;
60
+ if (selectedOptions.executionMode === undefined) selectedOptions.executionMode = 'sequential';
61
+ }
62
+ const attempt = {executionProviders, status: 'loading', error: null};
63
+ execution.attempts.push(attempt);
64
+ execution.fallback = provider === 'cpu';
65
+ try {
66
+ session = await ort.InferenceSession.create(request.model, selectedOptions);
67
+ attempt.status = 'configured';
68
+ execution.selectedProviders = executionProviders;
69
+ // Successful creation proves this session configuration was accepted.
70
+ // ORT can assign graph nodes to CPU; it does not prove GPU execution.
71
+ return execution;
72
+ } catch (error) {
73
+ attempt.status = 'failed';
74
+ attempt.error = describeError(error);
75
+ failures.push(error);
76
+ }
77
+ }
78
+ const error = new AggregateError(failures, 'ONNX accelerator session creation and CPU fallback failed.');
79
+ error.execution = execution;
80
+ throw error;
81
+ }
82
+
18
83
  function tensorMap(records,inputs=false){
19
84
  return Object.fromEntries(Object.entries(records).map(function createTensor([name,record]){
20
85
  // ORT Node 1.30.0 supplies string feeds to FillStringTensor through
@@ -33,19 +98,14 @@ async function handleRequest(request){
33
98
  try{
34
99
  let result;
35
100
  if(request.operation==='load'){
36
- const supplied=request.sessionOptions;
37
- const options=supplied===undefined
38
- ?{executionProviders:['cpu']}
39
- :supplied!==null&&is.object(supplied)&&supplied.executionProviders===undefined
40
- ?{...supplied,executionProviders:['cpu']}
41
- :supplied;
42
- session=await ort.InferenceSession.create(request.model,options);
101
+ const execution = await createSession(request);
43
102
  result={
44
103
  inputNames:session.inputNames,
45
104
  outputNames:session.outputNames,
46
105
  inputMetadata:session.inputMetadata,
47
106
  outputMetadata:session.outputMetadata
48
107
  };
108
+ if (execution) result.execution = execution;
49
109
  }else if(request.operation==='run'){
50
110
  const feeds=tensorMap(request.feeds,true);
51
111
  const fetches=request.fetches===undefined||request.fetches===null||is.array(request.fetches)