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.
- package/CHANGELOG.md +42 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-image.mjs +84 -34
- package/browser-runtime/ai/core-local.mjs +195 -43
- package/browser-runtime/ai/core-onnx.mjs +71 -12
- package/docs/reference/README.md +6 -0
- package/docs/reference/availability-and-normalization.md +1 -0
- package/docs/reference/core-native-packaging.md +11 -4
- package/docs/reference/core-repositories.md +93 -9
- package/docs/reference/core-runtime.md +46 -5
- package/docs/reference/inventory/package-api.json +62 -20
- package/docs/reference/inventory/runtime-components.json +2 -1
- package/docs/reference/inventory/runtime-modules.json +3 -3
- package/docs/reference/local-ai.md +112 -2
- package/docs/reference/local-image-generation.md +15 -4
- package/docs/reference/model-assets.md +50 -3
- package/docs/reference/native-decisions.md +181 -0
- package/docs/reference/runtime-components.md +62 -5
- package/docs/reference/runtime-modules.md +44 -7
- package/docs/reference/sdk-api.md +229 -19
- package/package.json +5 -2
- package/runtime/arcane/components/theme-switcher.html +112 -13
- package/runtime/arcane/modules/PreferenceStore.js +18 -13
- package/runtime/arcane/modules/SystemAppearance.js +24 -6
- package/src/core/repositories.mjs +27 -8
- package/src/core/runtime.mjs +26 -1
- package/src/core/services/decisions.mjs +144 -0
- package/src/core/services/local-ai.mjs +31 -9
- package/src/core/services/model-assets.mjs +98 -13
- package/src/git-identity.mjs +22 -58
- package/src/local-ai/decision-tokenizer-worker.mjs +31 -0
- package/src/local-ai/decision-tokenizer.mjs +109 -0
- package/src/local-ai/decisions.mjs +273 -0
- package/src/local-ai/dev.mjs +8 -2
- package/src/local-ai/onnx-worker.mjs +67 -7
- package/src/local-ai/onnx.mjs +23 -12
- package/src/native/portable-layout.mjs +8 -3
- package/src/repository.mjs +144 -4
package/src/git-identity.mjs
CHANGED
|
@@ -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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
+
}
|
package/src/local-ai/dev.mjs
CHANGED
|
@@ -40,10 +40,16 @@ export function createDevelopmentLocalAI({
|
|
|
40
40
|
let modelAssets;
|
|
41
41
|
let imageService;
|
|
42
42
|
let speechService;
|
|
43
|
-
|
|
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(
|
|
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
|
|
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)
|