arcane-os 0.60.0 → 0.62.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,605 @@
1
+ import {Buffer} from 'node:buffer';
2
+ import {isDeepStrictEqual} from 'node:util';
3
+ import Is from 'strong-type';
4
+ import {createArcaneEventSource} from '../../event-manager.mjs';
5
+ import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
6
+ import {normalizeLocalAIConfig} from '../../local-ai/config.mjs';
7
+ import {createImageRuntime} from '../../local-ai/image-runtime.mjs';
8
+
9
+ const is = new Is(false);
10
+
11
+ function failure(code, message) {
12
+ return new CoreError(
13
+ {code, message}
14
+ );
15
+ }
16
+
17
+ function waitForPreparation(task, signal) {
18
+ signal.throwIfAborted();
19
+ return new Promise(
20
+ function awaitImagePreparation(resolve, reject) {
21
+ function cancelled() {
22
+ signal.removeEventListener('abort', cancelled);
23
+ reject(signal.reason);
24
+ }
25
+ signal.addEventListener(
26
+ 'abort',
27
+ cancelled,
28
+ {once: true}
29
+ );
30
+ task.then(
31
+ function prepared(runtime) {
32
+ signal.removeEventListener('abort', cancelled);
33
+ if (signal.aborted) {
34
+ reject(signal.reason);
35
+ } else {
36
+ resolve(runtime);
37
+ }
38
+ },
39
+ function preparationFailed(error) {
40
+ signal.removeEventListener('abort', cancelled);
41
+ reject(error);
42
+ }
43
+ );
44
+ }
45
+ );
46
+ }
47
+
48
+ /** PNG encoding here belongs exclusively to the JSON Core transport. */
49
+ function encodeImage(image) {
50
+ const {data, ...metadata} = image;
51
+ return {
52
+ ...metadata,
53
+ encoding: 'base64',
54
+ data: Buffer.from(data).toString('base64')
55
+ };
56
+ }
57
+
58
+ /** One selected native image runtime; applications own prompts and persistence. */
59
+ export function createLocalImageService(
60
+ configuration,
61
+ {appRoot, runtimes = [], signal, onEvent, prepare, modelAssets} = {}
62
+ ) {
63
+ const config = normalizeLocalAIConfig(configuration) ?? {runtimes: []};
64
+ const requirement = config.runtimes.find(
65
+ function selectedImageRuntime(record) {
66
+ return record.id === 'stable-diffusion.cpp';
67
+ }
68
+ );
69
+ const lifetime = new AbortController();
70
+ const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
71
+ const owner = {};
72
+ const events = createArcaneEventSource(
73
+ owner,
74
+ {source: 'core-image', eventTypes: ['image.state']}
75
+ );
76
+ const jobs = new Set();
77
+ const projections = new Map();
78
+ const releases = new Set();
79
+ const releaseFailures = [];
80
+ let record = runtimes.find(
81
+ function installedImageRuntime(value) {
82
+ return value.id === 'stable-diffusion.cpp';
83
+ }
84
+ );
85
+ let state = {
86
+ id: 'stable-diffusion.cpp',
87
+ installed: Boolean(record?.libraryPath && record?.bindingModulePath),
88
+ available: false,
89
+ selectedModel: null,
90
+ loaded: false,
91
+ state: requirement ? 'created' : 'unavailable',
92
+ backend: record?.backend ?? null,
93
+ devices: [],
94
+ resources: {},
95
+ progress: null,
96
+ error: null,
97
+ models: record?.models ?? requirement?.models ?? []
98
+ };
99
+ let context;
100
+ let runtime;
101
+ let preparation;
102
+ let stopRuntimeSubscription;
103
+ let closing;
104
+ let closed = false;
105
+
106
+ function current() {
107
+ return {...state, closed};
108
+ }
109
+
110
+ function publish() {
111
+ const snapshot = current();
112
+ events.dispatch('image.state', snapshot);
113
+ context?.emit('image.state', snapshot);
114
+ return snapshot;
115
+ }
116
+
117
+ function acceptRuntimeState(value) {
118
+ state = {
119
+ ...state,
120
+ ...value,
121
+ available: value.available ?? (!value.closed && value.state !== 'error'),
122
+ installed: Boolean(record?.libraryPath && record?.bindingModulePath),
123
+ models: record?.models ?? requirement?.models ?? []
124
+ };
125
+ if (closing) {
126
+ state.available = false;
127
+ state.state = closed ? 'closed' : 'closing';
128
+ }
129
+ publish();
130
+ releaseUnusedProjections();
131
+ }
132
+
133
+ function diagnostic(event) {
134
+ if (!onEvent) {
135
+ return;
136
+ }
137
+ function reportDiagnosticFailure(error) {
138
+ console.error('Local image diagnostic observer failed.', error);
139
+ }
140
+ try {
141
+ Promise.resolve(onEvent(event)).catch(reportDiagnosticFailure);
142
+ } catch (error) {
143
+ reportDiagnosticFailure(error);
144
+ }
145
+ }
146
+
147
+ function notifyListener(listener, value) {
148
+ const result = listener(value);
149
+ if (result && is.function(result.then)) {
150
+ Promise.resolve(result).catch(
151
+ function reportImageListenerFailure(error) {
152
+ console.error('Local image state listener failed.', error);
153
+ }
154
+ );
155
+ }
156
+ }
157
+
158
+ function preparationProgress(event) {
159
+ if (!closing) {
160
+ state = {...state, progress: event};
161
+ publish();
162
+ }
163
+ diagnostic(event);
164
+ }
165
+
166
+ function beginPreparation() {
167
+ if (preparation || !requirement || closing) {
168
+ return preparation;
169
+ }
170
+ state = {...state, state: 'preparing', error: null};
171
+ preparation = Promise.resolve().then(
172
+ async function prepareSelectedImageRuntime() {
173
+ lifetimeSignal.throwIfAborted();
174
+ if (prepare) {
175
+ record = await prepare(
176
+ {
177
+ appRoot,
178
+ configuration: config,
179
+ requirement,
180
+ signal: lifetimeSignal,
181
+ onEvent: preparationProgress
182
+ }
183
+ );
184
+ }
185
+ lifetimeSignal.throwIfAborted();
186
+ if (!record?.libraryPath || !record?.bindingModulePath) {
187
+ throw failure(
188
+ 'LOCAL_AI_RUNTIME_UNAVAILABLE',
189
+ 'The selected local image runtime has not been prepared.'
190
+ );
191
+ }
192
+ runtime = createImageRuntime(
193
+ {
194
+ libraryPath: record.libraryPath,
195
+ bindingModulePath: record.bindingModulePath,
196
+ models: record.models ?? [],
197
+ backend: record.backend,
198
+ variants: record.variants,
199
+ signal: lifetimeSignal,
200
+ onEvent: diagnostic
201
+ }
202
+ );
203
+ stopRuntimeSubscription = runtime.subscribe(
204
+ acceptRuntimeState,
205
+ {replay: true}
206
+ );
207
+ return runtime;
208
+ }
209
+ );
210
+ preparation.catch(
211
+ function preparationFailed(error) {
212
+ state = {
213
+ ...state,
214
+ installed: Boolean(record?.libraryPath && record?.bindingModulePath),
215
+ available: false,
216
+ state: lifetimeSignal.aborted ? 'closing' : 'error',
217
+ error: lifetimeSignal.aborted ? null : serializeCoreError(error)
218
+ };
219
+ publish();
220
+ }
221
+ );
222
+ publish();
223
+ return preparation;
224
+ }
225
+
226
+ async function selectedRuntime(operationSignal) {
227
+ operationSignal.throwIfAborted();
228
+ if (!requirement) {
229
+ throw failure(
230
+ 'LOCAL_AI_RUNTIME_NOT_SELECTED',
231
+ 'stable-diffusion.cpp is not selected in this application configuration.'
232
+ );
233
+ }
234
+ const selected = await waitForPreparation(beginPreparation(), operationSignal);
235
+ operationSignal.throwIfAborted();
236
+ return selected;
237
+ }
238
+
239
+ async function projectedModel(model, assetProjectionId, resourcePaths, operationSignal) {
240
+ if (assetProjectionId === undefined) {
241
+ return model;
242
+ }
243
+ if (!modelAssets) {
244
+ throw failure(
245
+ 'LOCAL_AI_IMAGE_ASSETS_UNAVAILABLE',
246
+ 'This Core has no model asset projection service.'
247
+ );
248
+ }
249
+ let projection = projections.get(assetProjectionId);
250
+ if (!projection) {
251
+ projection = {id: assetProjectionId, lease: null, task: null, models: new Map()};
252
+ projection.task = Promise.resolve().then(
253
+ async function retainModelAssets() {
254
+ projection.lease = await modelAssets.retain(assetProjectionId);
255
+ return projection.lease;
256
+ }
257
+ );
258
+ projections.set(assetProjectionId, projection);
259
+ }
260
+ const lease = await projection.task;
261
+ operationSignal.throwIfAborted();
262
+ if (!resourcePaths || !is.object(resourcePaths) || is.array(resourcePaths)) {
263
+ throw new TypeError('Projected image models require resourcePaths mapping native roles to projection members.');
264
+ }
265
+ const entries = Object.entries(resourcePaths);
266
+ const modelId = is.string(model) ? model : model?.id;
267
+ const cached = projection.models.get(modelId);
268
+ if (cached && isDeepStrictEqual(cached.model, model)
269
+ && Object.keys(cached.resourcePaths).length === entries.length
270
+ && entries.every(
271
+ function matchingResource([role, memberPath]) {
272
+ return cached.resourcePaths[role] === memberPath;
273
+ }
274
+ )) {
275
+ return cached.prepared;
276
+ }
277
+ const definition = is.string(model)
278
+ ? record.models?.find(
279
+ function selectedModelDefinition(value) {
280
+ return value.id === model;
281
+ }
282
+ ) : model;
283
+ if (!definition) {
284
+ throw failure(
285
+ 'LOCAL_AI_IMAGE_MODEL_UNAVAILABLE',
286
+ `The selected image model ${String(model)} has no model definition.`
287
+ );
288
+ }
289
+ const resources = {...definition.resources};
290
+ for (const [role, memberPath] of entries) {
291
+ const member = lease.members.find(
292
+ function selectedProjectionMember(value) {
293
+ return value.path === memberPath;
294
+ }
295
+ );
296
+ if (!member) {
297
+ throw failure(
298
+ 'LOCAL_AI_IMAGE_RESOURCE_UNAVAILABLE',
299
+ `The retained model projection has no member ${String(memberPath)} for ${role}.`
300
+ );
301
+ }
302
+ resources[role] = member.nativePath;
303
+ }
304
+ const prepared = {...definition, resources};
305
+ projection.models.set(
306
+ modelId,
307
+ {model, resourcePaths: {...resourcePaths}, prepared}
308
+ );
309
+ return prepared;
310
+ }
311
+
312
+ function releaseUnusedProjections(all = false) {
313
+ // Native calls share one context. Retain every provisional projection
314
+ // until those calls settle, then compare with the actual loaded inputs.
315
+ if (jobs.size > 0) {
316
+ return;
317
+ }
318
+ const resources = all ? [] : Object.values(runtime?.current().resources ?? {});
319
+ for (const [id, projection] of projections) {
320
+ const active = projection.lease?.members.some(
321
+ function activeNativeResource(member) {
322
+ return resources.includes(member.nativePath);
323
+ }
324
+ );
325
+ if (active) {
326
+ continue;
327
+ }
328
+ projections.delete(id);
329
+ if (!projection.lease) {
330
+ continue;
331
+ }
332
+ const task = Promise.resolve().then(
333
+ function releaseModelProjection() {
334
+ return projection.lease.release();
335
+ }
336
+ );
337
+ releases.add(task);
338
+ task.then(
339
+ function projectionReleased() {
340
+ releases.delete(task);
341
+ },
342
+ function projectionReleaseFailed(error) {
343
+ releases.delete(task);
344
+ releaseFailures.push(error);
345
+ console.error('Local image model projection release failed.', error);
346
+ }
347
+ );
348
+ }
349
+ }
350
+
351
+ function track(task, operationSignal) {
352
+ jobs.add(task);
353
+ function settled() {
354
+ jobs.delete(task);
355
+ releaseUnusedProjections();
356
+ }
357
+ task.then(
358
+ settled,
359
+ function imageOperationFailed(error) {
360
+ if (!closing && !operationSignal.aborted) {
361
+ state = {...state, error: serializeCoreError(error)};
362
+ publish();
363
+ }
364
+ settled();
365
+ }
366
+ );
367
+ return task;
368
+ }
369
+
370
+ function subscribe(listener, {replay = true, signal: subscriptionSignal} = {}) {
371
+ if (!is.function(listener)) {
372
+ throw new TypeError('An image state listener must be a function.');
373
+ }
374
+ if (closed) {
375
+ if (replay && !subscriptionSignal?.aborted) {
376
+ notifyListener(listener, current());
377
+ }
378
+ return function closedSubscription() {};
379
+ }
380
+ const unsubscribe = events.on(
381
+ 'image.state',
382
+ function imageStateChanged(event) {
383
+ notifyListener(listener, event.detail);
384
+ },
385
+ {signal: subscriptionSignal}
386
+ );
387
+ try {
388
+ if (replay && !subscriptionSignal?.aborted) {
389
+ notifyListener(listener, current());
390
+ }
391
+ } catch (error) {
392
+ unsubscribe();
393
+ throw error;
394
+ }
395
+ return unsubscribe;
396
+ }
397
+
398
+ function close() {
399
+ if (closing) {
400
+ return closing;
401
+ }
402
+ closing = Promise.resolve().then(
403
+ async function closeImageService() {
404
+ const runtimeClosing = Promise.resolve().then(
405
+ function closeNativeImageRuntime() {
406
+ return runtime?.close();
407
+ }
408
+ );
409
+ const results = await Promise.allSettled(
410
+ [runtimeClosing, ...jobs, ...(preparation ? [preparation] : [])]
411
+ );
412
+ try {
413
+ const errors = [];
414
+ if (results[0].status === 'rejected') {
415
+ errors.push(results[0].reason);
416
+ }
417
+ // Native close joins unload or worker exit even when it
418
+ // rejects. Its files can now complete their own cleanup.
419
+ try {
420
+ releaseUnusedProjections(true);
421
+ } catch (error) {
422
+ errors.push(error);
423
+ }
424
+ await Promise.allSettled([...releases]);
425
+ errors.push(...releaseFailures);
426
+ if (errors.length === 1) {
427
+ throw errors[0];
428
+ }
429
+ if (errors.length > 1) {
430
+ throw new AggregateError(
431
+ errors,
432
+ 'Local image service cleanup failed.',
433
+ {cause: errors[0]}
434
+ );
435
+ }
436
+ } catch (error) {
437
+ state = {...state, error: serializeCoreError(error)};
438
+ throw error;
439
+ } finally {
440
+ stopRuntimeSubscription?.();
441
+ lifetimeSignal.removeEventListener('abort', abortService);
442
+ closed = true;
443
+ state = {...state, available: false, state: 'closed'};
444
+ publish();
445
+ }
446
+ }
447
+ );
448
+ lifetime.abort();
449
+ state = {...state, available: false, state: 'closing'};
450
+ publish();
451
+ return closing;
452
+ }
453
+
454
+ function abortService() {
455
+ close().catch(
456
+ function reportImageShutdownFailure(error) {
457
+ console.error('Local image service shutdown failed.', error);
458
+ }
459
+ );
460
+ }
461
+
462
+ lifetimeSignal.addEventListener(
463
+ 'abort',
464
+ abortService,
465
+ {once: true}
466
+ );
467
+
468
+ return {
469
+ name: 'image',
470
+ current,
471
+ subscribe,
472
+ start(currentContext) {
473
+ context = currentContext;
474
+ if (lifetimeSignal.aborted) {
475
+ abortService();
476
+ return;
477
+ }
478
+ beginPreparation();
479
+ publish();
480
+ },
481
+ methods: {
482
+ 'image.status': current,
483
+ 'image.load': function loadImage(
484
+ {model, context: modelContext, assetProjectionId, resourcePaths} = {},
485
+ request
486
+ ) {
487
+ const operationSignal = AbortSignal.any([lifetimeSignal, request.signal]);
488
+ return track(
489
+ (async function loadSelectedImageModel() {
490
+ const selected = await selectedRuntime(operationSignal);
491
+ const prepared = await projectedModel(model, assetProjectionId, resourcePaths, operationSignal);
492
+ await selected.load(
493
+ {model: prepared, context: modelContext, signal: operationSignal}
494
+ );
495
+ operationSignal.throwIfAborted();
496
+ return current();
497
+ })(),
498
+ operationSignal
499
+ );
500
+ },
501
+ 'image.generate': function generateImage(
502
+ {model, prompt, parameters, streamId, assetProjectionId, resourcePaths} = {},
503
+ request
504
+ ) {
505
+ const operationSignal = AbortSignal.any([lifetimeSignal, request.signal]);
506
+ operationSignal.throwIfAborted();
507
+ request.emit(
508
+ 'image.progress',
509
+ {streamId, requestId: request.requestId, status: 'Thinking', progress: {phase: 'accepted'}}
510
+ );
511
+ return track(
512
+ (async function generateSelectedImage() {
513
+ const selected = await selectedRuntime(operationSignal);
514
+ const prepared = await projectedModel(model, assetProjectionId, resourcePaths, operationSignal);
515
+ const result = await selected.generate(
516
+ {
517
+ model: prepared,
518
+ prompt,
519
+ parameters,
520
+ signal: operationSignal,
521
+ onProgress(progress) {
522
+ if (!operationSignal.aborted) {
523
+ request.emit(
524
+ 'image.progress',
525
+ {streamId, requestId: request.requestId, status: 'Thinking', progress}
526
+ );
527
+ }
528
+ }
529
+ }
530
+ );
531
+ operationSignal.throwIfAborted();
532
+ return {...result, images: result.images.map(encodeImage)};
533
+ })(),
534
+ operationSignal
535
+ );
536
+ },
537
+ 'image.edit': function editImage(
538
+ {model, image, prompt, strength, parameters, streamId, assetProjectionId, resourcePaths} = {},
539
+ request
540
+ ) {
541
+ const operationSignal = AbortSignal.any([lifetimeSignal, request.signal]);
542
+ operationSignal.throwIfAborted();
543
+ request.emit(
544
+ 'image.progress',
545
+ {streamId, requestId: request.requestId, status: 'Thinking', progress: {phase: 'accepted'}}
546
+ );
547
+ return track(
548
+ (async function editSelectedImage() {
549
+ if (image?.encoding !== 'base64' || !is.string(image.data)) {
550
+ throw new TypeError('Core image input requires complete base64 transport encoding.');
551
+ }
552
+ const input = {data: Buffer.from(image.data, 'base64'), mediaType: image.mediaType};
553
+ const selected = await selectedRuntime(operationSignal);
554
+ const prepared = await projectedModel(model, assetProjectionId, resourcePaths, operationSignal);
555
+ const result = await selected.edit(
556
+ {
557
+ model: prepared,
558
+ image: input,
559
+ prompt,
560
+ strength,
561
+ parameters,
562
+ signal: operationSignal,
563
+ onProgress(progress) {
564
+ if (!operationSignal.aborted) {
565
+ request.emit(
566
+ 'image.progress',
567
+ {streamId, requestId: request.requestId, status: 'Thinking', progress}
568
+ );
569
+ }
570
+ }
571
+ }
572
+ );
573
+ operationSignal.throwIfAborted();
574
+ return {...result, images: result.images.map(encodeImage)};
575
+ })(),
576
+ operationSignal
577
+ );
578
+ },
579
+ 'image.unload': function unloadImage(_parameters, request) {
580
+ const operationSignal = AbortSignal.any([lifetimeSignal, request.signal]);
581
+ return track(
582
+ (async function unloadSelectedImageModel() {
583
+ const selected = await selectedRuntime(operationSignal);
584
+ await selected.unload(
585
+ {signal: operationSignal}
586
+ );
587
+ operationSignal.throwIfAborted();
588
+ return current();
589
+ })(),
590
+ operationSignal
591
+ );
592
+ }
593
+ },
594
+ drain: close,
595
+ async dispose() {
596
+ try {
597
+ await close();
598
+ } finally {
599
+ events.dispose();
600
+ }
601
+ }
602
+ };
603
+ }
604
+
605
+ export default createLocalImageService;
@@ -121,7 +121,7 @@ function collectCompletion(completion, chunk) {
121
121
  }
122
122
 
123
123
  /** Core owns native processes; applications own runtime and model selection. */
124
- export function createLocalAIService(configuration, {appRoot, runtimes = [], signal, onEvent} = {}) {
124
+ export function createLocalAIService(configuration, {appRoot, runtimes = [], signal, onEvent, prepare} = {}) {
125
125
  const config = normalizeLocalAIConfig(configuration) ?? {runtimes: []};
126
126
  const lifetime = new AbortController();
127
127
  const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
@@ -136,7 +136,7 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
136
136
  let onnxRecoveryError;
137
137
  let stopONNXSubscription;
138
138
  const onnxSelected = config.runtimes.some(function selectedONNX(requirement) { return requirement.id === 'onnx'; });
139
- const onnxRecord = runtimes.find(function installedONNX(runtime) { return runtime.id === 'onnx'; });
139
+ let onnxRecord = runtimes.find(function installedONNX(runtime) { return runtime.id === 'onnx'; });
140
140
 
141
141
  function onnxStatus() {
142
142
  const current = onnx?.current();
@@ -493,8 +493,13 @@ export function createLocalAIService(configuration, {appRoot, runtimes = [], sig
493
493
  const service = {
494
494
  name: 'local-ai',
495
495
  current: snapshot,
496
- start(currentContext) {
496
+ async start(currentContext) {
497
497
  context = currentContext;
498
+ if (prepare) {
499
+ runtimes = await prepare({signal: lifetimeSignal, onEvent});
500
+ lifetimeSignal.throwIfAborted();
501
+ onnxRecord = runtimes.find(function installedONNX(runtime) { return runtime.id === 'onnx'; });
502
+ }
498
503
  for (const requirement of config.runtimes) {
499
504
  if (!['llama.cpp', 'ollama', 'onnx'].includes(requirement.id)) {
500
505
  throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', `Core runtime ${requirement.id} is not available in this service.`);
@@ -51,6 +51,8 @@ const SDK_BROWSER_SELF_IMPORTS=new Map([
51
51
  ['arcane-os/ai/browser-wasm',SDK_BROWSER_AI_ENTRY],
52
52
  ['arcane-os/ai/core-local','sdk/ai/core-local.mjs'],
53
53
  ['arcane-os/ai/core-onnx','sdk/ai/core-onnx.mjs'],
54
+ ['arcane-os/ai/core-image','sdk/ai/core-image.mjs'],
55
+ ['arcane-os/ai/core-model-assets','sdk/ai/core-model-assets.mjs'],
54
56
  ['arcane-os/ai/browser-decisions','sdk/ai/browser-decisions.mjs'],
55
57
  ['arcane-os/ai/tool-text-stream','sdk/ai/tool-text-stream.mjs'],
56
58
  ['arcane-os/ai/twin-cloud','sdk/ai/twin-cloud.mjs'],