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
|
@@ -153,21 +153,26 @@ function nativeAdapter(){
|
|
|
153
153
|
return preferences;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
function isUnsupportedNativeAdapter(error){
|
|
157
|
-
return error?.code==='ANDROID_CAPABILITY_UNSUPPORTED'
|
|
156
|
+
function isUnsupportedNativeAdapter(error,method){
|
|
157
|
+
return error?.code==='ANDROID_CAPABILITY_UNSUPPORTED'
|
|
158
|
+
||(error?.code==='METHOD_NOT_ALLOWED'
|
|
159
|
+
&&error.reason==='core-namespace-unavailable'
|
|
160
|
+
&&error.namespace==='preferences'
|
|
161
|
+
&&error.method===`preferences.${method}`);
|
|
158
162
|
}
|
|
159
163
|
|
|
160
|
-
function preferenceAdapter(){
|
|
164
|
+
function preferenceAdapter(assertActive){
|
|
161
165
|
const local=localAdapter('arcane.preferences');
|
|
162
166
|
if(is.function(globalThis.arcaneAndroid?.postMessage)) return local;
|
|
163
167
|
const native=nativeAdapter();
|
|
164
168
|
if(!native) return local;
|
|
165
169
|
let active=native;
|
|
166
|
-
async function call(method,args){
|
|
170
|
+
async function call(method,args,context){
|
|
167
171
|
try{
|
|
168
172
|
return await active[method](...args);
|
|
169
173
|
}catch(error){
|
|
170
|
-
if(active!==native||!isUnsupportedNativeAdapter(error)) throw error;
|
|
174
|
+
if(active!==native||!isUnsupportedNativeAdapter(error,method)) throw error;
|
|
175
|
+
assertActive(context?.signal);
|
|
171
176
|
active=local;
|
|
172
177
|
delete adapter.setMany;
|
|
173
178
|
if(!is.function(active[method])) throw error;
|
|
@@ -175,19 +180,19 @@ function preferenceAdapter(){
|
|
|
175
180
|
}
|
|
176
181
|
}
|
|
177
182
|
const adapter={
|
|
178
|
-
async get(key){
|
|
179
|
-
return call('get',[key]);
|
|
183
|
+
async get(key,context){
|
|
184
|
+
return call('get',[key,context],context);
|
|
180
185
|
},
|
|
181
|
-
async set(key,value){
|
|
182
|
-
return call('set',[key,value]);
|
|
186
|
+
async set(key,value,context){
|
|
187
|
+
return call('set',[key,value,context],context);
|
|
183
188
|
},
|
|
184
|
-
async delete(key){
|
|
185
|
-
return call('delete',[key]);
|
|
189
|
+
async delete(key,context){
|
|
190
|
+
return call('delete',[key,context],context);
|
|
186
191
|
}
|
|
187
192
|
};
|
|
188
193
|
if(is.function(native.setMany)){
|
|
189
194
|
adapter.setMany=async function setMany(entries,context){
|
|
190
|
-
return call('setMany',[entries,context]);
|
|
195
|
+
return call('setMany',[entries,context],context);
|
|
191
196
|
};
|
|
192
197
|
}
|
|
193
198
|
return adapter;
|
|
@@ -204,7 +209,7 @@ export default class PreferenceStore extends EventTarget{
|
|
|
204
209
|
super();
|
|
205
210
|
this.namespace=String(namespace||'arcane');
|
|
206
211
|
this.schema=preferenceSchema(schema);
|
|
207
|
-
this.adapter=validateAdapter(adapter||preferenceAdapter());
|
|
212
|
+
this.adapter=validateAdapter(adapter||preferenceAdapter(this.#assertOperationActive.bind(this)));
|
|
208
213
|
this.values=this.defaults();
|
|
209
214
|
this.#events=createArcaneEventSource(
|
|
210
215
|
this,
|
|
@@ -8,16 +8,34 @@ export default class SystemAppearance{
|
|
|
8
8
|
|
|
9
9
|
async current(){
|
|
10
10
|
if(!this.api||!is.function(this.api.current)) return {supported:false,platform:'browser'};
|
|
11
|
-
|
|
11
|
+
try{
|
|
12
|
+
return await this.api.current();
|
|
13
|
+
}catch(error){
|
|
14
|
+
return unsupportedNamespace(error,'current');
|
|
15
|
+
}
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
async apply(input={}){
|
|
15
19
|
if(!this.available()) return {supported:false,platform:'browser'};
|
|
16
20
|
const scheme=['system','light','dark'].includes(input.scheme)?input.scheme:'system';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
try{
|
|
22
|
+
return await this.api.apply({
|
|
23
|
+
scheme,
|
|
24
|
+
captionColor:scheme==='system'?null:input.captionColor||null,
|
|
25
|
+
textColor:scheme==='system'?null:input.textColor||null
|
|
26
|
+
});
|
|
27
|
+
}catch(error){
|
|
28
|
+
return unsupportedNamespace(error,'apply');
|
|
29
|
+
}
|
|
22
30
|
}
|
|
23
31
|
}
|
|
32
|
+
|
|
33
|
+
function unsupportedNamespace(error,method){
|
|
34
|
+
if(error?.code==='METHOD_NOT_ALLOWED'
|
|
35
|
+
&&error.reason==='core-namespace-unavailable'
|
|
36
|
+
&&error.namespace==='appearance'
|
|
37
|
+
&&error.method===`appearance.${method}`){
|
|
38
|
+
return {supported:false,reason:'core-namespace-unavailable',error};
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
@@ -4,7 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import Is from 'strong-type';
|
|
5
5
|
import {ArcaneError, throwIfAborted} from '../errors.mjs';
|
|
6
6
|
import {runProcess} from '../process.mjs';
|
|
7
|
-
import {repositoryPull, repositoryPush,
|
|
7
|
+
import {captureRepositoryTarget, repositoryConfiguration, repositoryPull, repositoryPush,
|
|
8
|
+
repositoryPushArguments, repositoryStatus} from '../repository.mjs';
|
|
8
9
|
import {createGitIdentityRunner} from '../git-identity.mjs';
|
|
9
10
|
|
|
10
11
|
export {readGitIdentity} from '../git-identity.mjs';
|
|
@@ -152,29 +153,46 @@ export function createRepositoryWorkspace(
|
|
|
152
153
|
);
|
|
153
154
|
}
|
|
154
155
|
|
|
155
|
-
function
|
|
156
|
+
function configuration({signal} = {}) {
|
|
157
|
+
return accept(
|
|
158
|
+
function readRepositoryConfiguration() {
|
|
159
|
+
return repositoryConfiguration({workspaceRoot: repositoryDirectory, signal, onEvent, run: execute});
|
|
160
|
+
},
|
|
161
|
+
signal
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function pull({target, signal} = {}) {
|
|
166
|
+
let selectedTarget;
|
|
167
|
+
try { selectedTarget = captureRepositoryTarget(target); }
|
|
168
|
+
catch (error) { return Promise.reject(error); }
|
|
156
169
|
return accept(
|
|
157
170
|
async function pullRepository() {
|
|
158
171
|
await prepare(signal);
|
|
159
|
-
return repositoryPull({workspaceRoot: repositoryDirectory, signal, onEvent, run: execute});
|
|
172
|
+
return repositoryPull({workspaceRoot: repositoryDirectory, target: selectedTarget, signal, onEvent, run: execute});
|
|
160
173
|
},
|
|
161
174
|
signal
|
|
162
175
|
);
|
|
163
176
|
}
|
|
164
177
|
|
|
165
|
-
function push({signal} = {}) {
|
|
178
|
+
function push({target, signal} = {}) {
|
|
179
|
+
let selectedTarget;
|
|
180
|
+
try { selectedTarget = captureRepositoryTarget(target); }
|
|
181
|
+
catch (error) { return Promise.reject(error); }
|
|
166
182
|
return accept(
|
|
167
183
|
async function pushRepository() {
|
|
168
184
|
await prepare(signal);
|
|
169
|
-
return repositoryPush({workspaceRoot: repositoryDirectory, signal, onEvent, run: execute});
|
|
185
|
+
return repositoryPush({workspaceRoot: repositoryDirectory, target: selectedTarget, signal, onEvent, run: execute});
|
|
170
186
|
},
|
|
171
187
|
signal
|
|
172
188
|
);
|
|
173
189
|
}
|
|
174
190
|
|
|
175
|
-
function write({files, message, signal} = {}) {
|
|
191
|
+
function write({files, message, target, signal} = {}) {
|
|
176
192
|
let selectedFiles;
|
|
193
|
+
let selectedTarget;
|
|
177
194
|
try {
|
|
195
|
+
selectedTarget = captureRepositoryTarget(target);
|
|
178
196
|
requireString(message, 'message');
|
|
179
197
|
if (!message.isWellFormed()) throw new TypeError('message cannot be represented completely as UTF-8 text.');
|
|
180
198
|
if (!is.array(files) || files.length === 0) {
|
|
@@ -210,6 +228,7 @@ export function createRepositoryWorkspace(
|
|
|
210
228
|
async function writeRepositoryFiles() {
|
|
211
229
|
const outcome = {
|
|
212
230
|
directory: repositoryDirectory,
|
|
231
|
+
...(selectedTarget === undefined ? {} : {target: {...selectedTarget}}),
|
|
213
232
|
state: 'local',
|
|
214
233
|
stage: 'prepare',
|
|
215
234
|
paths: selectedFiles.map(function selectedPath(file) { return file.path; }),
|
|
@@ -321,7 +340,7 @@ export function createRepositoryWorkspace(
|
|
|
321
340
|
['--literal-pathspecs', 'commit', '--only', '--cleanup=verbatim', '--file', '-', '--', ...outcome.paths],
|
|
322
341
|
commitMessage()
|
|
323
342
|
);
|
|
324
|
-
await executeGit('push',
|
|
343
|
+
await executeGit('push', repositoryPushArguments(selectedTarget));
|
|
325
344
|
outcome.stage = 'complete';
|
|
326
345
|
return outcome;
|
|
327
346
|
} catch (cause) {
|
|
@@ -357,7 +376,7 @@ export function createRepositoryWorkspace(
|
|
|
357
376
|
return closing;
|
|
358
377
|
}
|
|
359
378
|
|
|
360
|
-
return {directory: repositoryDirectory, open, status, pull, push, write, close, drain: close, dispose: close};
|
|
379
|
+
return {directory: repositoryDirectory, open, status, configuration, pull, push, write, close, drain: close, dispose: close};
|
|
361
380
|
}
|
|
362
381
|
|
|
363
382
|
function requireString(value, name) {
|
package/src/core/runtime.mjs
CHANGED
|
@@ -150,11 +150,22 @@ class CoreRuntime {
|
|
|
150
150
|
};
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
async getService(name) {
|
|
154
|
+
if (this.#closing) throw failure('CORE_CLOSING', 'Core is closing.');
|
|
155
|
+
const service = this.#services.get(name);
|
|
156
|
+
if (!service) throw failure('CORE_SERVICE_UNAVAILABLE', `Core service ${String(name)} is not registered.`);
|
|
157
|
+
await this.#startService(service);
|
|
158
|
+
return service.definition;
|
|
159
|
+
}
|
|
160
|
+
|
|
153
161
|
#serviceContext(service) {
|
|
154
162
|
const runtime = this;
|
|
155
163
|
return {
|
|
156
164
|
application: this.#application,
|
|
157
165
|
service: service.name,
|
|
166
|
+
getService(name) {
|
|
167
|
+
return runtime.getService(name);
|
|
168
|
+
},
|
|
158
169
|
emit(event, data) {
|
|
159
170
|
return runtime.emit(event, data);
|
|
160
171
|
}
|
|
@@ -256,7 +267,21 @@ class CoreRuntime {
|
|
|
256
267
|
|
|
257
268
|
#request(frame) {
|
|
258
269
|
const registration = this.#methods.get(frame.method);
|
|
259
|
-
if (!registration)
|
|
270
|
+
if (!registration) {
|
|
271
|
+
const namespace = frame.method.split('.')[0];
|
|
272
|
+
const prefix = `${namespace}.`;
|
|
273
|
+
const namespacePresent = this.#services.has(namespace)
|
|
274
|
+
|| [...this.#methods.keys(), 'system.ping', 'app.current', 'version.current'].some(
|
|
275
|
+
function inNamespace(method) { return method.startsWith(prefix); }
|
|
276
|
+
);
|
|
277
|
+
throw new CoreError({
|
|
278
|
+
code: 'METHOD_NOT_ALLOWED',
|
|
279
|
+
message: `Core does not expose ${frame.method}.`,
|
|
280
|
+
reason: namespacePresent ? 'core-method-unavailable' : 'core-namespace-unavailable',
|
|
281
|
+
namespace,
|
|
282
|
+
method: frame.method
|
|
283
|
+
});
|
|
284
|
+
}
|
|
260
285
|
const {service, handle, lifetime} = registration;
|
|
261
286
|
const controller = new AbortController();
|
|
262
287
|
const request = {id: frame.id, method: frame.method, lifetime, controller, task: null};
|
|
@@ -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(
|
|
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
|
|
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) {
|
|
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
|
-
|
|
637
|
-
return refreshModels(engine, request.signal);
|
|
659
|
+
return observeLlama(request.signal);
|
|
638
660
|
},
|
|
639
661
|
'llama.models': async function llamaModels(_parameters, request) {
|
|
640
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
return record ? releasePreparation(record) : {id, state: 'released'};
|
|
344
|
+
return release(id);
|
|
260
345
|
}
|
|
261
346
|
}
|
|
262
347
|
},
|