arcane-os 0.66.0 → 0.67.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.
@@ -0,0 +1,144 @@
1
+ import path from 'node:path';
2
+ import {createNativeDecisionModel} from '../../local-ai/decisions.mjs';
3
+ import {createArcaneEventSource} from '../../event-manager.mjs';
4
+ import {CoreError} from '../../../browser-runtime/core/contracts.mjs';
5
+ import {encodeTensorMap} from '../../../browser-runtime/ai/onnx-tensors.mjs';
6
+
7
+ /** App-owned selection; shared typed-decision execution, without a renderer. */
8
+ export function createNativeDecisionService(configuration = {}, {appRoot = process.cwd()} = {}) {
9
+ const events = createArcaneEventSource({}, {source: 'core-decisions', eventTypes: ['decisions.state']});
10
+ const lifetime = new AbortController();
11
+ let context;
12
+ let model;
13
+ let loading;
14
+ let loadController;
15
+ let releasing;
16
+ let closing;
17
+ let unsubscribe;
18
+
19
+ function current() {
20
+ return model?.current() ?? {
21
+ family: 'laya', model: configuration.model ?? 'onnx-community/laya-typed-decisions-ONNX',
22
+ revision: configuration.revision ?? 'main', dtype: configuration.dtype ?? 'fp32',
23
+ state: closing ? 'disposed' : 'unloaded', loaded: false, busy: Boolean(loading),
24
+ activeRequests: 0, progress: null, error: null, execution: null
25
+ };
26
+ }
27
+
28
+ function publish(value = current()) {
29
+ events.dispatch('decisions.state', value);
30
+ context?.emit('decisions.state', value);
31
+ }
32
+
33
+ function subscribe(listener, {emitCurrent = true, signal} = {}) {
34
+ const stop = events.on('decisions.state', function changed(event) { listener(event.detail); }, {signal});
35
+ try { if (emitCurrent && !signal?.aborted) listener(current()); }
36
+ catch (error) { stop(); throw error; }
37
+ return stop;
38
+ }
39
+
40
+ function operationSignal(signal) { return signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal; }
41
+
42
+ function load({signal} = {}) {
43
+ operationSignal(signal).throwIfAborted();
44
+ 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;
49
+ const selectedSignal = AbortSignal.any([lifetime.signal, controller.signal]);
50
+ const precedingRelease = releasing;
51
+ loading = Promise.resolve().then(async function loadSelectedModel() {
52
+ selectedSignal.throwIfAborted();
53
+ if (precedingRelease) await precedingRelease;
54
+ selectedSignal.throwIfAborted();
55
+ if (model) await model.dispose();
56
+ unsubscribe?.();
57
+ // Only the explicit load waits for its native dependencies. Merely
58
+ // registering this service does not download a model or delay UI.
59
+ const [localAI, modelAssets] = await Promise.all([
60
+ context.getService('local-ai'),
61
+ configuration.paths ? undefined : context.getService('model-assets')
62
+ ]);
63
+ selectedSignal.throwIfAborted();
64
+ const paths = configuration.paths && Object.fromEntries(
65
+ Object.entries(configuration.paths).map(function nativePath([name, value]) { return [name, path.resolve(appRoot, value)]; })
66
+ );
67
+ model = createNativeDecisionModel({
68
+ ...configuration, paths, onnx: localAI.getONNXRuntime(), modelAssets,
69
+ signal: lifetime.signal
70
+ });
71
+ unsubscribe = model.subscribe(publish);
72
+ return model.load({signal: selectedSignal});
73
+ });
74
+ function settled() { loading = undefined; loadController = undefined; publish(); }
75
+ loading.then(settled, settled);
76
+ publish();
77
+ return observeLoad(loading, controller, signal);
78
+ }
79
+
80
+ function observeLoad(task, controller, signal) {
81
+ function abort() { controller.abort(signal.reason); }
82
+ signal?.addEventListener('abort', abort, {once: true});
83
+ if (signal?.aborted) abort();
84
+ return task.finally(function detachLoadCaller() { signal?.removeEventListener('abort', abort); });
85
+ }
86
+
87
+ async function evaluate(rows, {signal, runOptions} = {}) {
88
+ const selectedSignal = operationSignal(signal);
89
+ selectedSignal.throwIfAborted();
90
+ if (!model) throw new CoreError({code: 'ARCANE_DECISION_NOT_LOADED', message: 'Load the selected native decision model before evaluating.'});
91
+ return model.evaluate(rows, {signal: selectedSignal, runOptions});
92
+ }
93
+
94
+ function unload() {
95
+ if (releasing) return releasing;
96
+ // An unload during model preparation must cancel that accepted load,
97
+ // rather than wait for it to download and activate first.
98
+ loadController?.abort(new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision load was cancelled.'}));
99
+ const task = model?.unload() ?? Promise.resolve();
100
+ const acceptedLoad = loading;
101
+ releasing = Promise.resolve().then(async function releaseSelectedModel() {
102
+ const results = await Promise.allSettled([task, acceptedLoad]);
103
+ if (results[0].status === 'rejected') throw results[0].reason;
104
+ return current();
105
+ });
106
+ function released() { releasing = undefined; }
107
+ releasing.then(released, released);
108
+ return releasing;
109
+ }
110
+
111
+ function dispose() {
112
+ if (closing) return closing;
113
+ const reason = new CoreError({name: 'AbortError', code: 'ARCANE_AI_REQUEST_ABORTED', message: 'The native decision service is closing.'});
114
+ closing = Promise.resolve().then(async function closeDecisionService() {
115
+ const failures = [];
116
+ try { await loading; } catch (error) { if (!lifetime.signal.aborted) failures.push(error); }
117
+ try { await model?.dispose(); } catch (error) { failures.push(error); }
118
+ unsubscribe?.();
119
+ publish();
120
+ events.dispose();
121
+ if (failures.length) throw new AggregateError(failures, 'Closing the decision service failed.');
122
+ });
123
+ lifetime.abort(reason);
124
+ return closing;
125
+ }
126
+
127
+ return {
128
+ name: configuration.name ?? 'decisions', current, subscribe, load, evaluate, classify: evaluate, unload, dispose,
129
+ start(serviceContext) { context = serviceContext; },
130
+ methods: {
131
+ 'decisions.status': current,
132
+ 'decisions.load': function loadRequest(parameters, request) { return load({signal: request.signal}); },
133
+ 'decisions.evaluate': async function evaluateRequest({rows, runOptions}, request) {
134
+ const result = await evaluate(rows, {runOptions, signal: request.signal});
135
+ // Typed arrays retain their complete values through Core's
136
+ // existing JSON tensor encoding, only at this transport edge.
137
+ return {...result, outputs: encodeTensorMap(result.outputs)};
138
+ },
139
+ 'decisions.unload': {lifetime: 'service', handle: unload}
140
+ }
141
+ };
142
+ }
143
+
144
+ export default createNativeDecisionService;
@@ -149,8 +149,13 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
149
149
  };
150
150
  }
151
151
 
152
+ function runtimeRecord(engine) {
153
+ return {...engine.state, models: [...engine.models],
154
+ ...(engine.id === 'llama.cpp' ? {released: engine.released === true} : {})};
155
+ }
156
+
152
157
  function snapshot() {
153
- const records = [...engines.values()].map(function runtimeRecord(engine) { return {...engine.state, models: [...engine.models]}; });
158
+ const records = [...engines.values()].map(runtimeRecord);
154
159
  if (onnxSelected) records.push(onnxStatus());
155
160
  const ollama = records.find(function ollamaRecord(record) { return record.id === 'ollama'; });
156
161
  return {
@@ -283,7 +288,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
283
288
  engine.models = [...merged.values()];
284
289
  }
285
290
  publish();
286
- return {...engine.state, models: [...engine.models]};
291
+ return runtimeRecord(engine);
287
292
  }
288
293
 
289
294
  function observeModels(engine) {
@@ -347,7 +352,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
347
352
  return recovering;
348
353
  }
349
354
 
350
- async function engineReady(id, signal, {retryLoading = false} = {}) {
355
+ async function engineReady(id, signal, {retryLoading = false, resumeReleased = true} = {}) {
351
356
  signal?.throwIfAborted();
352
357
  const engine = engines.get(id);
353
358
  if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', `${id} is not selected in this application's localAI configuration.`);
@@ -358,13 +363,28 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
358
363
  if (retryLoading && engine.state.state === 'loading') {
359
364
  await awaitReadiness(restartEngine(engine), signal);
360
365
  }
361
- if (engine.released) { engine.released = false; startEngine(engine); }
366
+ if (engine.released) {
367
+ if (!resumeReleased) return engine;
368
+ engine.released = false;
369
+ startEngine(engine);
370
+ }
362
371
  await awaitReadiness(engine.ready, signal);
363
372
  signal?.throwIfAborted();
364
373
  if (!engine.state.available) throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', `${id} is unavailable.`);
365
374
  return engine;
366
375
  }
367
376
 
377
+ function observeLlama(signal) {
378
+ signal?.throwIfAborted();
379
+ const engine = engines.get('llama.cpp');
380
+ if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', "llama.cpp is not selected in this application's localAI configuration.");
381
+ // Observation neither waits for startup nor starts a released engine.
382
+ if (!engine.state.available || engine.released || engine.stopping || engine.recovering || engine.signal.aborted) {
383
+ return runtimeRecord(engine);
384
+ }
385
+ return refreshModels(engine, signal);
386
+ }
387
+
368
388
  function waitForModel(engine, model, signal) {
369
389
  return new Promise(function awaitModel(resolve, reject) {
370
390
  let unsubscribe;
@@ -563,6 +583,9 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
563
583
  const service = {
564
584
  name: 'local-ai',
565
585
  current: snapshot,
586
+ getONNXRuntime() {
587
+ return requireONNX();
588
+ },
566
589
  async start(currentContext) {
567
590
  context = currentContext;
568
591
  if (prepare) {
@@ -633,17 +656,16 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
633
656
  return snapshot();
634
657
  },
635
658
  'llama.status': async function llamaStatus(_parameters, request) {
636
- const engine = await engineReady('llama.cpp', request.signal);
637
- return refreshModels(engine, request.signal);
659
+ return observeLlama(request.signal);
638
660
  },
639
661
  'llama.models': async function llamaModels(_parameters, request) {
640
- const engine = await engineReady('llama.cpp', request.signal);
641
- const status = await refreshModels(engine, request.signal);
662
+ const status = await observeLlama(request.signal);
642
663
  return {models: status.models};
643
664
  },
644
665
  'llama.load': loadLlama,
645
666
  'llama.unload': async function unloadLlama({model}, request) {
646
- const engine = await engineReady('llama.cpp', request.signal);
667
+ const engine = await engineReady('llama.cpp', request.signal, {resumeReleased: false});
668
+ if (engine.released) return {...engine.state, models: [...engine.models], released: true, unloaded: true};
647
669
  engine.loadControllers.get(model)?.abort(failure('LOCAL_AI_MODEL_UNLOADED', `The application unloaded ${model}.`));
648
670
  if (engine.loads.has(model)) await Promise.allSettled([engine.loads.get(model)]);
649
671
  if (!engine.router) {
@@ -10,7 +10,7 @@ function failure(code, message) {
10
10
  return new CoreError({code, message});
11
11
  }
12
12
 
13
- /** Working files for native engines. The browser's stored assets stay authoritative. */
13
+ /** Working files for native engines. Caller-selected source assets stay authoritative. */
14
14
  export function createModelAssetService({appRoot = process.cwd()} = {}) {
15
15
  const projections = new Map();
16
16
  let context;
@@ -63,6 +63,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
63
63
  const errors = [];
64
64
  let filesClosed = false;
65
65
  try { await record.opening; } catch (error) { record.error ??= error; }
66
+ try { await record.downloading; } catch (error) { record.error ??= error; }
66
67
  try { await record.finishing; } catch (error) { record.error ??= error; }
67
68
  try { await closeMembers(record); filesClosed = true; } catch (error) { errors.push(error); }
68
69
  if (record.directory && filesClosed) {
@@ -87,10 +88,16 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
87
88
 
88
89
  function releasePreparation(record) {
89
90
  record.preparationOwned = false;
91
+ record.downloadController?.abort(failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.'));
90
92
  publish(record);
91
93
  return cleanup(record);
92
94
  }
93
95
 
96
+ function release(id) {
97
+ const record = projections.get(id);
98
+ return record ? releasePreparation(record) : Promise.resolve({id, state: 'released'});
99
+ }
100
+
94
101
  async function failedOperation(record, error) {
95
102
  record.error = error;
96
103
  record.state = 'error';
@@ -165,19 +172,98 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
165
172
  const member = record.members[memberIndex];
166
173
  if (!member) throw new TypeError('Select an existing model asset member.');
167
174
  try {
168
- const task = member.tail.then(async function appendOriginalContent() {
169
- request.signal.throwIfAborted();
170
- if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
171
- await member.handle.appendFile(Buffer.from(contentBase64, 'base64'));
172
- request.signal.throwIfAborted();
173
- });
174
- // File writes remain ordered; unrelated member files write independently.
175
- member.tail = task.catch(function observeWriteFailure() {});
176
- await task;
175
+ await appendMemberContent(record, member, Buffer.from(contentBase64, 'base64'), request.signal);
177
176
  return {id, memberIndex, written: true};
178
177
  } catch (error) { return failedOperation(record, error); }
179
178
  }
180
179
 
180
+ function appendMemberContent(record, member, content, signal) {
181
+ const task = member.tail.then(async function appendOriginalContent() {
182
+ signal.throwIfAborted();
183
+ if (!record.preparationOwned) throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
184
+ await member.handle.appendFile(content);
185
+ signal.throwIfAborted();
186
+ });
187
+ // File writes remain ordered; unrelated member files write independently.
188
+ member.tail = task.catch(function observeWriteFailure() {});
189
+ return task;
190
+ }
191
+
192
+ async function prepare({id, workingDirectory, members, signal, onProgress} = {}) {
193
+ const controller = new AbortController();
194
+ const operationSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
195
+ const request = {signal: operationSignal};
196
+ let record;
197
+ let completed = 0;
198
+ let firstFailure;
199
+
200
+ async function progress(phase, memberIndex) {
201
+ operationSignal.throwIfAborted();
202
+ await onProgress?.({
203
+ phase, completed, total: members.length, unit: 'files',
204
+ ...(memberIndex === undefined ? {} : {memberIndex, path: members[memberIndex].path})
205
+ });
206
+ operationSignal.throwIfAborted();
207
+ }
208
+
209
+ async function downloadMember(member, memberIndex) {
210
+ try {
211
+ await progress('download', memberIndex);
212
+ const response = await fetch(member.url, {signal: operationSignal});
213
+ if (!response.ok) {
214
+ const error = failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset request failed: ${response.status} ${response.statusText}`);
215
+ error.url = member.url;
216
+ error.status = response.status;
217
+ error.response = await response.text();
218
+ throw error;
219
+ }
220
+ if (!response.body) throw failure('MODEL_ASSET_DOWNLOAD_FAILED', `Model asset response has no content stream: ${member.url}`);
221
+ for await (const content of response.body) {
222
+ operationSignal.throwIfAborted();
223
+ await appendMemberContent(record, record.members[memberIndex], content, operationSignal);
224
+ await progress('download', memberIndex);
225
+ }
226
+ completed += 1;
227
+ await progress('download', memberIndex);
228
+ } catch (error) {
229
+ firstFailure ??= error;
230
+ controller.abort(error);
231
+ throw error;
232
+ }
233
+ }
234
+
235
+ try {
236
+ await openProjection({id, workingDirectory, members}, request);
237
+ record = requireProjection(id);
238
+ operationSignal.throwIfAborted();
239
+ if (!record.preparationOwned || closing) {
240
+ throw failure('MODEL_ASSET_PROJECTION_RELEASED', 'Model asset preparation was released.');
241
+ }
242
+ record.downloadController = controller;
243
+ // Cleanup joins this transfer-only task. Its rejection handler below
244
+ // may release preparation without waiting on itself.
245
+ record.downloading = Promise.resolve().then(async function downloadMembers() {
246
+ await progress('open');
247
+ const results = await Promise.allSettled(members.map(downloadMember));
248
+ const errors = results.filter(function rejected(result) { return result.status === 'rejected'; })
249
+ .map(function reason(result) { return result.reason; });
250
+ if (errors.length > 1) {
251
+ throw new AggregateError(errors, 'Downloading model asset members failed.', {cause: firstFailure});
252
+ }
253
+ if (errors.length) throw errors[0];
254
+ });
255
+ await record.downloading;
256
+ await progress('complete');
257
+ const projection = await completeProjection({id}, request);
258
+ await progress('ready');
259
+ return projection;
260
+ } catch (error) {
261
+ controller.abort(error);
262
+ if (record) return failedOperation(record, error);
263
+ throw error;
264
+ }
265
+ }
266
+
181
267
  async function completeProjection({id}, request) {
182
268
  const record = requireProjection(id);
183
269
  await record.opening;
@@ -245,7 +331,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
245
331
  }
246
332
 
247
333
  return {
248
- name: 'model-assets', current, retain,
334
+ name: 'model-assets', current, prepare, retain, release,
249
335
  start(serviceContext) { context = serviceContext; },
250
336
  methods: {
251
337
  'modelAssets.open': openProjection,
@@ -255,8 +341,7 @@ export function createModelAssetService({appRoot = process.cwd()} = {}) {
255
341
  'modelAssets.release': {
256
342
  lifetime: 'service',
257
343
  handle({id}) {
258
- const record = projections.get(id);
259
- return record ? releasePreparation(record) : {id, state: 'released'};
344
+ return release(id);
260
345
  }
261
346
  }
262
347
  },
@@ -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
+ }