arcane-os 0.56.0 → 0.57.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 +22 -0
- package/README.md +1 -1
- package/browser-runtime/ai/core-local.mjs +306 -0
- package/docs/reference/README.md +20 -0
- package/docs/reference/cli.md +9 -1
- package/docs/reference/core-native-packaging.md +195 -11
- package/docs/reference/inventory/package-api.json +138 -12
- package/docs/reference/local-ai.md +197 -0
- package/docs/reference/protocols.md +69 -1
- package/docs/reference/runtime-modules.md +6 -5
- package/docs/reference/sdk-api.md +165 -26
- package/package.json +5 -1
- package/runtime/arcane/modules/LocalAIReadiness.js +3 -1
- package/schemas/arcane-app.schema.json +54 -0
- package/schemas/arcane-package.schema.json +44 -0
- package/src/app-descriptor.mjs +17 -1
- package/src/cli/main.mjs +8 -2
- package/src/core/host.mjs +23 -0
- package/src/core/hosts/macos/ArcaneCoreProcess.h +85 -0
- package/src/core/hosts/macos/ArcaneCoreProcess.m +357 -0
- package/src/core/hosts/windows/ArcaneCoreProcess.cs +3 -3
- package/src/core/hosts/windows/ArcaneHost.cs +695 -0
- package/src/core/hosts/windows/ArcaneHost.manifest +22 -0
- package/src/core/hosts/windows/ArcaneLauncher.cs +239 -0
- package/src/core/services/local-ai.mjs +543 -0
- package/src/dev-server.mjs +7 -0
- package/src/import-map.mjs +1 -0
- package/src/local-ai/archive.mjs +52 -0
- package/src/local-ai/config.mjs +62 -0
- package/src/local-ai/dev.mjs +390 -0
- package/src/local-ai/discover.mjs +71 -0
- package/src/local-ai/http.mjs +106 -0
- package/src/local-ai/install.mjs +344 -0
- package/src/local-ai/server.mjs +262 -0
- package/src/native/portable-layout.mjs +29 -4
- package/src/native/portable-provider.mjs +39 -8
- package/src/native/windows-provider.mjs +262 -0
- package/src/native-provider-loader.mjs +8 -2
- package/src/packager/core.mjs +6 -2
- package/src/process.mjs +66 -8
- package/src/toolchain.mjs +33 -5
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
import {createArcaneEventSource} from '../../event-manager.mjs';
|
|
2
|
+
import Is from 'strong-type';
|
|
3
|
+
import {CoreError, serializeCoreError} from '../../../browser-runtime/core/contracts.mjs';
|
|
4
|
+
import {structuredOutputFormat, openAIResponseFormat} from '../../../browser-runtime/ai/twin-cloud.mjs';
|
|
5
|
+
import {normalizeLocalAIConfig} from '../../local-ai/config.mjs';
|
|
6
|
+
import {createLocalAIServer} from '../../local-ai/server.mjs';
|
|
7
|
+
import {requestLocalJSON, streamLocalJSON} from '../../local-ai/http.mjs';
|
|
8
|
+
|
|
9
|
+
const is = new Is(false);
|
|
10
|
+
|
|
11
|
+
function failure(code, message) {
|
|
12
|
+
return new CoreError({code, message});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function upstreamFailure(value) {
|
|
16
|
+
return new CoreError({code: 'LOCAL_AI_UPSTREAM_ERROR', message: value.error?.message ?? String(value.error), details: value});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function awaitReadiness(task, signal) {
|
|
20
|
+
if (!signal) return task;
|
|
21
|
+
signal.throwIfAborted();
|
|
22
|
+
return new Promise(function awaitSelectedEngine(resolve, reject) {
|
|
23
|
+
function abort() { signal.removeEventListener('abort', abort); reject(signal.reason); }
|
|
24
|
+
signal.addEventListener('abort', abort, {once: true});
|
|
25
|
+
task.then(function ready(value) {
|
|
26
|
+
signal.removeEventListener('abort', abort);
|
|
27
|
+
if (signal.aborted) reject(signal.reason);
|
|
28
|
+
else resolve(value);
|
|
29
|
+
}, function failed(error) {
|
|
30
|
+
signal.removeEventListener('abort', abort);
|
|
31
|
+
reject(error);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Translate SDK option names only at the upstream HTTP protocol boundary. */
|
|
37
|
+
function llamaPayload(payload, model, stream) {
|
|
38
|
+
const result = {...payload, model, stream};
|
|
39
|
+
const names = {
|
|
40
|
+
topK: 'top_k', topP: 'top_p', minP: 'min_p', repeatPenalty: 'repeat_penalty',
|
|
41
|
+
maxTokens: 'max_tokens', maxOutputTokens: 'max_tokens', templateOptions: 'chat_template_kwargs',
|
|
42
|
+
toolChoice: 'tool_choice', parallelToolCalls: 'parallel_tool_calls',
|
|
43
|
+
reasoningEffort: 'reasoning_effort'
|
|
44
|
+
};
|
|
45
|
+
for (const [source, destination] of Object.entries(names)) {
|
|
46
|
+
if (Object.hasOwn(result, source)) {
|
|
47
|
+
if (!Object.hasOwn(result, destination)) result[destination] = result[source];
|
|
48
|
+
delete result[source];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (Object.hasOwn(result, 'structuredOutput')) {
|
|
52
|
+
const format = openAIResponseFormat(structuredOutputFormat(result.structuredOutput));
|
|
53
|
+
if (format && !Object.hasOwn(result, 'response_format')) result.response_format = format;
|
|
54
|
+
delete result.structuredOutput;
|
|
55
|
+
}
|
|
56
|
+
// These fields identify the SDK request and its presentation, not model input.
|
|
57
|
+
delete result.id;
|
|
58
|
+
delete result.seeThinking;
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Assemble actual OpenAI deltas while publishing each original chunk separately. */
|
|
63
|
+
function collectCompletion(completion, chunk) {
|
|
64
|
+
const {choices = [], ...metadata} = chunk;
|
|
65
|
+
Object.assign(completion, metadata);
|
|
66
|
+
for (const value of choices) {
|
|
67
|
+
const {delta, message, logprobs, ...fields} = value;
|
|
68
|
+
let choice = completion.choices.find(function sameChoice(current) { return current.index === value.index; });
|
|
69
|
+
if (!choice) {
|
|
70
|
+
choice = {...fields, message: {}};
|
|
71
|
+
completion.choices.push(choice);
|
|
72
|
+
}
|
|
73
|
+
Object.assign(choice, fields);
|
|
74
|
+
if (logprobs) {
|
|
75
|
+
choice.logprobs ??= {};
|
|
76
|
+
for (const [key, data] of Object.entries(logprobs)) {
|
|
77
|
+
if (is.array(data)) {
|
|
78
|
+
choice.logprobs[key] ??= [];
|
|
79
|
+
for (const record of data) choice.logprobs[key].push(record);
|
|
80
|
+
} else if (data !== null || !Object.hasOwn(choice.logprobs, key)) {
|
|
81
|
+
choice.logprobs[key] = data;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} else if (Object.hasOwn(value, 'logprobs') && !Object.hasOwn(choice, 'logprobs')) {
|
|
85
|
+
choice.logprobs = logprobs;
|
|
86
|
+
}
|
|
87
|
+
if (message) choice.message = message;
|
|
88
|
+
if (!delta) continue;
|
|
89
|
+
for (const [key, data] of Object.entries(delta)) {
|
|
90
|
+
if (key === 'tool_calls') {
|
|
91
|
+
choice.message.tool_calls ??= [];
|
|
92
|
+
for (const fragment of data) {
|
|
93
|
+
let call = choice.message.tool_calls.find(function sameCall(current) { return current.index === fragment.index; });
|
|
94
|
+
if (!call) {
|
|
95
|
+
call = {index: fragment.index};
|
|
96
|
+
choice.message.tool_calls.push(call);
|
|
97
|
+
}
|
|
98
|
+
for (const [field, content] of Object.entries(fragment)) {
|
|
99
|
+
if (field === 'function') {
|
|
100
|
+
call.function ??= {};
|
|
101
|
+
for (const [member, text] of Object.entries(content)) {
|
|
102
|
+
call.function[member] = is.string(text)
|
|
103
|
+
? (call.function[member] ?? '') + text : text;
|
|
104
|
+
}
|
|
105
|
+
} else if (field === 'type') call[field] = content;
|
|
106
|
+
else if (field !== 'index' && is.string(content)) {
|
|
107
|
+
call[field] = (call[field] ?? '') + content;
|
|
108
|
+
} else call[field] = content;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} else if (key === 'role') choice.message.role = data;
|
|
112
|
+
else if (is.string(data)) choice.message[key] = (choice.message[key] ?? '') + data;
|
|
113
|
+
else if (is.array(data)) choice.message[key] = [...(choice.message[key] ?? []), ...data];
|
|
114
|
+
else if (data !== null || !Object.hasOwn(choice.message, key)) choice.message[key] = data;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return completion;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Core owns native processes; applications own runtime and model selection. */
|
|
121
|
+
export function createLocalAIService(configuration, {appRoot, runtimes = [], signal, onEvent} = {}) {
|
|
122
|
+
const config = normalizeLocalAIConfig(configuration) ?? {runtimes: []};
|
|
123
|
+
const lifetime = new AbortController();
|
|
124
|
+
const lifetimeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
|
|
125
|
+
const engines = new Map();
|
|
126
|
+
const jobs = new Set();
|
|
127
|
+
const owner = {};
|
|
128
|
+
const events = createArcaneEventSource(owner, {source: 'core-local-ai', eventTypes: ['localai.state']});
|
|
129
|
+
let context;
|
|
130
|
+
let closed = false;
|
|
131
|
+
|
|
132
|
+
function snapshot() {
|
|
133
|
+
const records = [...engines.values()].map(function runtimeRecord(engine) { return {...engine.state, models: [...engine.models]}; });
|
|
134
|
+
const ollama = records.find(function ollamaRecord(record) { return record.id === 'ollama'; });
|
|
135
|
+
return {
|
|
136
|
+
runtimes: records,
|
|
137
|
+
...(ollama ? {ollama, models: {ollama: ollama.models}} : {})
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function publish() {
|
|
142
|
+
const state = snapshot();
|
|
143
|
+
events.dispatch('localai.state', state);
|
|
144
|
+
context?.emit('localai.state', state);
|
|
145
|
+
return state;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function recordFailure(engine, error) {
|
|
149
|
+
if (!closed && error.code === 'LOCAL_AI_LOADING') {
|
|
150
|
+
engine.state = {...engine.state, available: false, state: 'loading', error: null};
|
|
151
|
+
publish();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
engine.state = {...engine.state, available: false, state: closed ? 'closed' : 'error', error: serializeCoreError(error)};
|
|
155
|
+
publish();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function refreshModels(engine, signal) {
|
|
159
|
+
const revision = engine.revision;
|
|
160
|
+
if (engine.id === 'ollama') {
|
|
161
|
+
const [catalog, running] = await Promise.all([
|
|
162
|
+
requestLocalJSON({url: engine.server.url, path: '/api/tags', signal}),
|
|
163
|
+
requestLocalJSON({url: engine.server.url, path: '/api/ps', signal})
|
|
164
|
+
]);
|
|
165
|
+
const loaded = new Set((running.models ?? []).map(function loadedId(model) { return model.model ?? model.name; }));
|
|
166
|
+
engine.models = (catalog.models ?? []).map(function ollamaModel(model) {
|
|
167
|
+
return {...model, id: model.model ?? model.name, loaded: loaded.has(model.model ?? model.name)};
|
|
168
|
+
});
|
|
169
|
+
} else {
|
|
170
|
+
const properties = await requestLocalJSON({url: engine.server.url, path: '/props', signal});
|
|
171
|
+
engine.router = properties.role === 'router';
|
|
172
|
+
let catalog;
|
|
173
|
+
try {
|
|
174
|
+
catalog = await requestLocalJSON({url: engine.server.url, path: '/models', signal});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error.status !== 404) throw error;
|
|
177
|
+
catalog = await requestLocalJSON({url: engine.server.url, path: '/v1/models', signal});
|
|
178
|
+
}
|
|
179
|
+
const models = (catalog.data ?? []).map(function llamaModel(model) {
|
|
180
|
+
return {...model,
|
|
181
|
+
...(!engine.router ? {status: {value: properties.is_sleeping ? 'sleeping' : 'loaded'}} : {}),
|
|
182
|
+
loaded: engine.router ? model.status?.value === 'loaded' : properties.is_sleeping === false
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
// A snapshot must not replace lifecycle events received after it began.
|
|
186
|
+
const current = new Map(engine.models.map(function modelEntry(model) { return [model.id, model]; }));
|
|
187
|
+
const merged = new Map(models.map(function modelEntry(model) { return [model.id, model]; }));
|
|
188
|
+
for (const [id, changedAt] of engine.modelChanges) {
|
|
189
|
+
if (changedAt <= revision) continue;
|
|
190
|
+
if (current.has(id)) merged.set(id, current.get(id));
|
|
191
|
+
else merged.delete(id);
|
|
192
|
+
}
|
|
193
|
+
engine.models = [...merged.values()];
|
|
194
|
+
}
|
|
195
|
+
publish();
|
|
196
|
+
return {...engine.state, models: [...engine.models]};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function observeModels(engine) {
|
|
200
|
+
const signal = engine.signal;
|
|
201
|
+
let opened;
|
|
202
|
+
let failed;
|
|
203
|
+
const connected = new Promise(function eventConnection(resolve, reject) { opened = resolve; failed = reject; });
|
|
204
|
+
const task = (async function watchModelLifecycle() {
|
|
205
|
+
for await (const event of streamLocalJSON({
|
|
206
|
+
url: engine.server.url, path: '/models/sse', method: 'GET', format: 'sse',
|
|
207
|
+
signal, onOpen: opened
|
|
208
|
+
})) {
|
|
209
|
+
if (event.event === 'model_status' || event.event === 'status_change') {
|
|
210
|
+
engine.modelChanges.set(event.model, ++engine.revision);
|
|
211
|
+
let model = engine.models.find(function matchingModel(value) { return value.id === event.model; });
|
|
212
|
+
if (!model) { model = {id: event.model}; engine.models.push(model); }
|
|
213
|
+
model.status = {...event.data, value: event.data.status,
|
|
214
|
+
...(event.data.status === 'unloaded' && event.data.exit_code !== undefined
|
|
215
|
+
? {failed: event.data.exit_code !== 0} : {})};
|
|
216
|
+
model.loaded = event.data.status === 'loaded';
|
|
217
|
+
publish();
|
|
218
|
+
} else if (event.event === 'model_remove') {
|
|
219
|
+
engine.modelChanges.set(event.model, ++engine.revision);
|
|
220
|
+
engine.models = engine.models.filter(function retainedModel(model) { return model.id !== event.model; });
|
|
221
|
+
publish();
|
|
222
|
+
} else if (event.event === 'models_reload') await refreshModels(engine, signal);
|
|
223
|
+
context?.emit('llama.model.event', event);
|
|
224
|
+
}
|
|
225
|
+
if (!signal.aborted) throw failure('LOCAL_AI_EVENT_CONNECTION_CLOSED', 'The llama.cpp model event connection closed.');
|
|
226
|
+
})();
|
|
227
|
+
jobs.add(task);
|
|
228
|
+
task.then(function eventStreamClosed() {
|
|
229
|
+
jobs.delete(task);
|
|
230
|
+
}, function eventStreamFailed(error) {
|
|
231
|
+
jobs.delete(task);
|
|
232
|
+
failed(error);
|
|
233
|
+
if (!signal.aborted) recordFailure(engine, error);
|
|
234
|
+
});
|
|
235
|
+
return connected;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function restartEngine(engine) {
|
|
239
|
+
if (engine.recovering) return engine.recovering;
|
|
240
|
+
const recovering = Promise.resolve().then(async function recoverSelectedEngine() {
|
|
241
|
+
lifetimeSignal.throwIfAborted();
|
|
242
|
+
engine.controller.abort();
|
|
243
|
+
await engine.server.close();
|
|
244
|
+
await Promise.allSettled([...engine.loads.values()]);
|
|
245
|
+
lifetimeSignal.throwIfAborted();
|
|
246
|
+
engine.models = [];
|
|
247
|
+
engine.modelChanges.clear();
|
|
248
|
+
engine.revision = 0;
|
|
249
|
+
engine.released = false;
|
|
250
|
+
startEngine(engine);
|
|
251
|
+
});
|
|
252
|
+
engine.recovering = recovering;
|
|
253
|
+
function releaseRecovery() {
|
|
254
|
+
if (engine.recovering === recovering) engine.recovering = null;
|
|
255
|
+
}
|
|
256
|
+
recovering.then(releaseRecovery, releaseRecovery);
|
|
257
|
+
return recovering;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function engineReady(id, signal, {retryLoading = false} = {}) {
|
|
261
|
+
signal?.throwIfAborted();
|
|
262
|
+
const engine = engines.get(id);
|
|
263
|
+
if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', `${id} is not selected in this application's localAI configuration.`);
|
|
264
|
+
if (engine.recovering) await awaitReadiness(engine.recovering, signal);
|
|
265
|
+
if (engine.stopping) await awaitReadiness(engine.stopping, signal);
|
|
266
|
+
// An external server exposes no readiness subscription during initial
|
|
267
|
+
// loading. A new explicit load makes one fresh attempt, without polling.
|
|
268
|
+
if (retryLoading && engine.state.state === 'loading') {
|
|
269
|
+
await awaitReadiness(restartEngine(engine), signal);
|
|
270
|
+
}
|
|
271
|
+
if (engine.released) { engine.released = false; startEngine(engine); }
|
|
272
|
+
await awaitReadiness(engine.ready, signal);
|
|
273
|
+
signal?.throwIfAborted();
|
|
274
|
+
if (!engine.state.available) throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', `${id} is unavailable.`);
|
|
275
|
+
return engine;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function waitForModel(engine, model, signal) {
|
|
279
|
+
return new Promise(function awaitModel(resolve, reject) {
|
|
280
|
+
let unsubscribe;
|
|
281
|
+
function cleanup() { unsubscribe?.(); signal?.removeEventListener('abort', abort); }
|
|
282
|
+
function abort() { cleanup(); reject(signal.reason); }
|
|
283
|
+
function inspect() {
|
|
284
|
+
const selected = engine.models.find(function matchingModel(value) { return value.id === model; });
|
|
285
|
+
if (signal?.aborted) return abort();
|
|
286
|
+
if (selected?.loaded) { cleanup(); resolve(); }
|
|
287
|
+
else if (engine.state.error || selected?.status?.failed) {
|
|
288
|
+
cleanup(); reject(failure('LOCAL_AI_MODEL_LOAD_FAILED', `llama.cpp could not load ${model}.`));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
unsubscribe = events.on('localai.state', inspect);
|
|
292
|
+
signal?.addEventListener('abort', abort, {once: true});
|
|
293
|
+
inspect();
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function prepareLlamaModel(engine, model, signal) {
|
|
298
|
+
if (!engine.router || !engine.models.some(function matchingModel(value) { return value.id === model; })) {
|
|
299
|
+
await refreshModels(engine, signal);
|
|
300
|
+
}
|
|
301
|
+
const selected = engine.models.find(function matchingModel(value) { return value.id === model; });
|
|
302
|
+
if (selected?.loaded) {
|
|
303
|
+
return {...engine.state, models: [...engine.models]};
|
|
304
|
+
}
|
|
305
|
+
if (!selected) throw failure('LOCAL_AI_MODEL_UNAVAILABLE', `The running llama.cpp server does not expose ${model}.`);
|
|
306
|
+
const loaded = engine.router ? waitForModel(engine, model, signal) : Promise.resolve();
|
|
307
|
+
loaded.catch(function observedModelWait() {});
|
|
308
|
+
if (selected.status?.value === 'sleeping') {
|
|
309
|
+
// Tokenization wakes upstream's sleeping model without generating a reply.
|
|
310
|
+
await requestLocalJSON({url: engine.server.url, path: '/tokenize', method: 'POST', payload: {model, content: ''}, signal});
|
|
311
|
+
} else if (selected.status?.value !== 'loading') {
|
|
312
|
+
await requestLocalJSON({url: engine.server.url, path: '/models/load', method: 'POST', payload: {model}, signal});
|
|
313
|
+
}
|
|
314
|
+
await refreshModels(engine, signal);
|
|
315
|
+
await loaded;
|
|
316
|
+
if (!engine.models.some(function loadedModel(value) { return value.id === model && value.loaded; })) {
|
|
317
|
+
throw failure('LOCAL_AI_MODEL_NOT_READY', `The llama.cpp model ${model} has not reported readiness.`);
|
|
318
|
+
}
|
|
319
|
+
return {...engine.state, models: [...engine.models]};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function loadLlama({model}, request) {
|
|
323
|
+
const engine = await engineReady('llama.cpp', request.signal, {retryLoading: true});
|
|
324
|
+
let task = engine.loads.get(model);
|
|
325
|
+
if (!task) {
|
|
326
|
+
const controller = new AbortController();
|
|
327
|
+
engine.loadControllers.set(model, controller);
|
|
328
|
+
task = prepareLlamaModel(engine, model, AbortSignal.any([engine.signal, controller.signal]));
|
|
329
|
+
engine.loads.set(model, task);
|
|
330
|
+
function releaseLoad() {
|
|
331
|
+
controller.abort();
|
|
332
|
+
if (engine.loads.get(model) === task) {
|
|
333
|
+
engine.loads.delete(model);
|
|
334
|
+
engine.loadControllers.delete(model);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
task.then(releaseLoad, releaseLoad);
|
|
338
|
+
}
|
|
339
|
+
return awaitReadiness(task, request.signal);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function chatLlama({model, payload, stream = false, streamId}, request) {
|
|
343
|
+
model = payload?.model ?? model;
|
|
344
|
+
const engine = await engineReady('llama.cpp', request.signal, {retryLoading: true});
|
|
345
|
+
await loadLlama({model}, request);
|
|
346
|
+
const controller = new AbortController();
|
|
347
|
+
const signal = AbortSignal.any([request.signal, engine.signal, controller.signal]);
|
|
348
|
+
function preserveReadiness() {
|
|
349
|
+
if (!engine.state.available || !engine.models.some(function loadedModel(value) { return value.id === model && value.loaded; })) {
|
|
350
|
+
controller.abort(failure('LOCAL_AI_MODEL_NOT_READY', `The selected llama.cpp model ${model} is not ready.`));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const unsubscribe = events.on('localai.state', preserveReadiness);
|
|
354
|
+
preserveReadiness();
|
|
355
|
+
try {
|
|
356
|
+
signal.throwIfAborted();
|
|
357
|
+
const body = llamaPayload(payload, model, stream);
|
|
358
|
+
if (!stream) {
|
|
359
|
+
const result = await requestLocalJSON({url: engine.server.url, path: '/v1/chat/completions', method: 'POST', payload: body, signal});
|
|
360
|
+
signal.throwIfAborted();
|
|
361
|
+
if (result?.error) throw upstreamFailure(result);
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
364
|
+
const completion = {choices: []};
|
|
365
|
+
for await (const chunk of streamLocalJSON({url: engine.server.url, path: '/v1/chat/completions', payload: body, signal, format: 'sse'})) {
|
|
366
|
+
request.emit('llama.chunk', {streamId, chunk});
|
|
367
|
+
if (chunk.error) throw upstreamFailure(chunk);
|
|
368
|
+
collectCompletion(completion, chunk);
|
|
369
|
+
}
|
|
370
|
+
signal.throwIfAborted();
|
|
371
|
+
return completion;
|
|
372
|
+
} finally { unsubscribe(); }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function ollamaRequest(operation, parameters = {}, request, method = 'POST') {
|
|
376
|
+
const engine = await engineReady('ollama', request.signal);
|
|
377
|
+
const signal = AbortSignal.any([request.signal, engine.signal]);
|
|
378
|
+
const {streamId, ...payload} = parameters;
|
|
379
|
+
if (payload.stream !== true) {
|
|
380
|
+
const streamingAPI = ['chat', 'generate', 'pull', 'push', 'create'].includes(operation);
|
|
381
|
+
const body = streamingAPI ? {...payload, stream: false} : payload;
|
|
382
|
+
const result = await requestLocalJSON({url: engine.server.url, path: `/api/${operation}`, method, payload: method === 'GET' ? undefined : body, signal});
|
|
383
|
+
if (result?.error) throw upstreamFailure(result);
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
let result;
|
|
387
|
+
const message = {};
|
|
388
|
+
let content = '';
|
|
389
|
+
let thinking = '';
|
|
390
|
+
let response = '';
|
|
391
|
+
let generatedThinking;
|
|
392
|
+
let hasLogprobs = false;
|
|
393
|
+
const logprobs = [];
|
|
394
|
+
const calls = [];
|
|
395
|
+
for await (const chunk of streamLocalJSON({url: engine.server.url, path: `/api/${operation}`, payload, signal, format: 'ndjson'})) {
|
|
396
|
+
request.emit('ollama.chunk', {streamId, chunk});
|
|
397
|
+
if (chunk.error) throw upstreamFailure(chunk);
|
|
398
|
+
result = chunk;
|
|
399
|
+
if (chunk.message) Object.assign(message, chunk.message);
|
|
400
|
+
if (is.string(chunk.response)) response += chunk.response;
|
|
401
|
+
if (is.string(chunk.thinking)) generatedThinking = (generatedThinking ?? '') + chunk.thinking;
|
|
402
|
+
if (is.array(chunk.logprobs)) {
|
|
403
|
+
hasLogprobs = true;
|
|
404
|
+
for (const record of chunk.logprobs) logprobs.push(record);
|
|
405
|
+
}
|
|
406
|
+
if (is.string(chunk.message?.content)) content += chunk.message.content;
|
|
407
|
+
if (is.string(chunk.message?.thinking)) thinking += chunk.message.thinking;
|
|
408
|
+
if (chunk.message?.tool_calls) calls.push(...chunk.message.tool_calls);
|
|
409
|
+
}
|
|
410
|
+
if (!result) throw failure('LOCAL_AI_EMPTY_STREAM', 'Ollama closed the stream without a response.');
|
|
411
|
+
if (operation === 'generate') return {...result, response,
|
|
412
|
+
...(generatedThinking !== undefined ? {thinking: generatedThinking} : {}),
|
|
413
|
+
...(hasLogprobs ? {logprobs} : {})};
|
|
414
|
+
if (operation === 'chat') return {...result, ...(hasLogprobs ? {logprobs} : {}),
|
|
415
|
+
message: {...message, content, ...(thinking ? {thinking} : {}), ...(calls.length ? {tool_calls: calls} : {})}};
|
|
416
|
+
return result;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function startEngine(engine) {
|
|
420
|
+
engine.controller = new AbortController();
|
|
421
|
+
engine.signal = AbortSignal.any([lifetimeSignal, engine.controller.signal]);
|
|
422
|
+
const signal = engine.signal;
|
|
423
|
+
engine.server = createLocalAIServer({
|
|
424
|
+
id: engine.id, appRoot, runtime: runtimes.find(function runtimeRecord(record) { return record.id === engine.id; }),
|
|
425
|
+
configuration: engine.id === 'llama.cpp' ? config.llamaCpp : config.ollama,
|
|
426
|
+
signal, onEvent,
|
|
427
|
+
onState(state) { engine.state = {...engine.state, ...state, error: state.error ?? null}; publish(); }
|
|
428
|
+
});
|
|
429
|
+
engine.ready = engine.server.ready.then(async function prepareModelCatalog() {
|
|
430
|
+
await refreshModels(engine, signal);
|
|
431
|
+
if (engine.id === 'llama.cpp' && engine.router) {
|
|
432
|
+
await observeModels(engine);
|
|
433
|
+
await refreshModels(engine, signal);
|
|
434
|
+
}
|
|
435
|
+
return engine;
|
|
436
|
+
});
|
|
437
|
+
engine.ready.catch(function startupFailed(error) { if (!signal.aborted) recordFailure(engine, error); });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const service = {
|
|
441
|
+
name: 'local-ai',
|
|
442
|
+
current: snapshot,
|
|
443
|
+
start(currentContext) {
|
|
444
|
+
context = currentContext;
|
|
445
|
+
for (const requirement of config.runtimes) {
|
|
446
|
+
if (!['llama.cpp', 'ollama'].includes(requirement.id)) {
|
|
447
|
+
throw failure('LOCAL_AI_RUNTIME_UNAVAILABLE', `Core runtime ${requirement.id} is not available in this service.`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (const requirement of config.runtimes) {
|
|
451
|
+
if (engines.has(requirement.id)) continue;
|
|
452
|
+
const engine = {
|
|
453
|
+
id: requirement.id, models: [], revision: 0, modelChanges: new Map(), loads: new Map(), loadControllers: new Map(),
|
|
454
|
+
state: {id: requirement.id, installed: runtimes.some(function installed(record) { return record.id === requirement.id && record.executable; }), available: false, state: 'starting'}
|
|
455
|
+
};
|
|
456
|
+
engines.set(engine.id, engine);
|
|
457
|
+
startEngine(engine);
|
|
458
|
+
}
|
|
459
|
+
publish();
|
|
460
|
+
},
|
|
461
|
+
methods: {
|
|
462
|
+
'localai.services.recover': async function recoverServices({runtimes: selected = [...engines.keys()]} = {}, request) {
|
|
463
|
+
const selectedEngines = selected.map(function selectedEngine(id) {
|
|
464
|
+
const engine = engines.get(id);
|
|
465
|
+
if (!engine) throw failure('LOCAL_AI_RUNTIME_NOT_SELECTED', `${id} is not selected in this application's localAI configuration.`);
|
|
466
|
+
return engine;
|
|
467
|
+
});
|
|
468
|
+
await Promise.all(selectedEngines.map(async function recoverEngine(engine) {
|
|
469
|
+
await awaitReadiness(restartEngine(engine), request.signal);
|
|
470
|
+
await awaitReadiness(engine.ready, request.signal);
|
|
471
|
+
}));
|
|
472
|
+
return snapshot();
|
|
473
|
+
},
|
|
474
|
+
'localai.status': async function currentStatus(_parameters, request) {
|
|
475
|
+
await Promise.all([...engines.values()].map(async function refreshEngine(engine) {
|
|
476
|
+
if (!engine.state.available) return;
|
|
477
|
+
try {
|
|
478
|
+
await refreshModels(engine, request.signal);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
request.signal.throwIfAborted();
|
|
481
|
+
recordFailure(engine, error);
|
|
482
|
+
}
|
|
483
|
+
}));
|
|
484
|
+
return snapshot();
|
|
485
|
+
},
|
|
486
|
+
'llama.status': async function llamaStatus(_parameters, request) {
|
|
487
|
+
const engine = await engineReady('llama.cpp', request.signal);
|
|
488
|
+
return refreshModels(engine, request.signal);
|
|
489
|
+
},
|
|
490
|
+
'llama.models': async function llamaModels(_parameters, request) {
|
|
491
|
+
const engine = await engineReady('llama.cpp', request.signal);
|
|
492
|
+
const status = await refreshModels(engine, request.signal);
|
|
493
|
+
return {models: status.models};
|
|
494
|
+
},
|
|
495
|
+
'llama.load': loadLlama,
|
|
496
|
+
'llama.unload': async function unloadLlama({model}, request) {
|
|
497
|
+
const engine = await engineReady('llama.cpp', request.signal);
|
|
498
|
+
engine.loadControllers.get(model)?.abort(failure('LOCAL_AI_MODEL_UNLOADED', `The application unloaded ${model}.`));
|
|
499
|
+
if (engine.loads.has(model)) await Promise.allSettled([engine.loads.get(model)]);
|
|
500
|
+
if (!engine.router) {
|
|
501
|
+
if (!engine.server.owned) return {...engine.state, models: [...engine.models], released: true, unloaded: false};
|
|
502
|
+
engine.controller.abort();
|
|
503
|
+
engine.stopping = engine.server.close();
|
|
504
|
+
await engine.stopping;
|
|
505
|
+
engine.stopping = null;
|
|
506
|
+
engine.released = true;
|
|
507
|
+
engine.models = engine.models.map(function unloadedModel(value) { return {...value, loaded: false}; });
|
|
508
|
+
publish();
|
|
509
|
+
return {...engine.state, models: [...engine.models], released: true, unloaded: true};
|
|
510
|
+
}
|
|
511
|
+
await requestLocalJSON({url: engine.server.url, path: '/models/unload', method: 'POST', payload: {model}, signal: request.signal});
|
|
512
|
+
return refreshModels(engine, request.signal);
|
|
513
|
+
},
|
|
514
|
+
'llama.chat': chatLlama,
|
|
515
|
+
'ollama.version': function version(parameters, request) { return ollamaRequest('version', parameters, request, 'GET'); },
|
|
516
|
+
'ollama.models': function models(parameters, request) { return ollamaRequest('tags', parameters, request, 'GET'); },
|
|
517
|
+
'ollama.running': function running(parameters, request) { return ollamaRequest('ps', parameters, request, 'GET'); },
|
|
518
|
+
'ollama.show': function show(parameters, request) { return ollamaRequest('show', parameters, request); },
|
|
519
|
+
'ollama.chat': function chat(parameters, request) { return ollamaRequest('chat', parameters, request); },
|
|
520
|
+
'ollama.generate': function generate(parameters, request) { return ollamaRequest('generate', parameters, request); },
|
|
521
|
+
'ollama.embed': function embed(parameters, request) { return ollamaRequest('embed', parameters, request); },
|
|
522
|
+
'ollama.pull': function pull(parameters, request) { return ollamaRequest('pull', parameters, request); },
|
|
523
|
+
'ollama.push': function push(parameters, request) { return ollamaRequest('push', parameters, request); },
|
|
524
|
+
'ollama.create': function create(parameters, request) { return ollamaRequest('create', parameters, request); },
|
|
525
|
+
'ollama.copy': function copy(parameters, request) { return ollamaRequest('copy', parameters, request); },
|
|
526
|
+
'ollama.delete': function remove(parameters, request) { return ollamaRequest('delete', parameters, request, 'DELETE'); }
|
|
527
|
+
},
|
|
528
|
+
async dispose() {
|
|
529
|
+
closed = true;
|
|
530
|
+
lifetime.abort();
|
|
531
|
+
const results = await Promise.allSettled([...engines.values()].map(function stopEngine(engine) { return engine.server.close(); }));
|
|
532
|
+
await Promise.allSettled([...jobs, ...[...engines.values()].flatMap(function pendingWork(engine) {
|
|
533
|
+
return [...engine.loads.values(), ...(engine.recovering ? [engine.recovering] : [])];
|
|
534
|
+
})]);
|
|
535
|
+
events.dispose();
|
|
536
|
+
const failures = results.filter(function failed(result) { return result.status === 'rejected'; }).map(function cause(result) { return result.reason; });
|
|
537
|
+
if (failures.length) throw new AggregateError(failures, 'Local AI shutdown failed.');
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
return service;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export default createLocalAIService;
|
package/src/dev-server.mjs
CHANGED
|
@@ -854,6 +854,7 @@ async function startOwnedDevServer({
|
|
|
854
854
|
tls,
|
|
855
855
|
certPath,
|
|
856
856
|
keyPath,
|
|
857
|
+
localAI,
|
|
857
858
|
signal,
|
|
858
859
|
sdkRuntimeSourceRoot
|
|
859
860
|
}={},events,releaseSignal){
|
|
@@ -1263,6 +1264,7 @@ async function startOwnedDevServer({
|
|
|
1263
1264
|
response.end();
|
|
1264
1265
|
return;
|
|
1265
1266
|
}
|
|
1267
|
+
if (localAI && await localAI.handler(request, response)) return;
|
|
1266
1268
|
if(request.method!=='GET'&&request.method!=='HEAD'){
|
|
1267
1269
|
deny(response,405,'Method not allowed.');
|
|
1268
1270
|
return;
|
|
@@ -1498,6 +1500,7 @@ async function startOwnedDevServer({
|
|
|
1498
1500
|
const cleanUrl=`${origin}${routeSet.startPath}`;
|
|
1499
1501
|
const url=cleanUrl;
|
|
1500
1502
|
let closeInitiated=false;
|
|
1503
|
+
let localAIClosing=Promise.resolve();
|
|
1501
1504
|
let lifecycleSettlementStarted=false;
|
|
1502
1505
|
let operationalError=null;
|
|
1503
1506
|
let resolveLifecycle;
|
|
@@ -1518,6 +1521,8 @@ async function startOwnedDevServer({
|
|
|
1518
1521
|
signal?.removeEventListener('abort',abort);
|
|
1519
1522
|
for (const listener of listeners) listener.removeListener('error', serverFailed);
|
|
1520
1523
|
try{
|
|
1524
|
+
const [localAIResult]=await Promise.allSettled([localAIClosing]);
|
|
1525
|
+
if(localAIResult.status==='rejected')operationalError??=localAIResult.reason;
|
|
1521
1526
|
while(requestTasks.size>0){
|
|
1522
1527
|
await Promise.allSettled([...requestTasks]);
|
|
1523
1528
|
}
|
|
@@ -1556,6 +1561,8 @@ async function startOwnedDevServer({
|
|
|
1556
1561
|
}
|
|
1557
1562
|
if(!closeInitiated){
|
|
1558
1563
|
closeInitiated=true;
|
|
1564
|
+
localAIClosing=Promise.resolve().then(function closeLocalAI(){return localAI?.close(error);});
|
|
1565
|
+
localAIClosing.catch(function observeLocalAICloseFailure(closeError){operationalError??=closeError;});
|
|
1559
1566
|
try{
|
|
1560
1567
|
closeDevelopmentListeners(fileServer, tlsServer).then(
|
|
1561
1568
|
function developmentServerClosed() {
|
package/src/import-map.mjs
CHANGED
|
@@ -49,6 +49,7 @@ const SDK_BROWSER_SELF_IMPORTS=new Map([
|
|
|
49
49
|
['arcane-os/pwa','sdk/pwa.mjs'],
|
|
50
50
|
['arcane-os/speech-text','sdk/speech-text.mjs'],
|
|
51
51
|
['arcane-os/ai/browser-wasm',SDK_BROWSER_AI_ENTRY],
|
|
52
|
+
['arcane-os/ai/core-local','sdk/ai/core-local.mjs'],
|
|
52
53
|
['arcane-os/ai/browser-decisions','sdk/ai/browser-decisions.mjs'],
|
|
53
54
|
['arcane-os/ai/tool-text-stream','sdk/ai/tool-text-stream.mjs'],
|
|
54
55
|
['arcane-os/ai/twin-cloud','sdk/ai/twin-cloud.mjs'],
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {createReadStream} from 'node:fs';
|
|
2
|
+
import {mkdir,open,unlink} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {pipeline} from 'node:stream/promises';
|
|
5
|
+
import {createZstdDecompress} from 'node:zlib';
|
|
6
|
+
import {throwIfAborted} from '../errors.mjs';
|
|
7
|
+
import {runProcess} from '../process.mjs';
|
|
8
|
+
|
|
9
|
+
export async function extractLocalAIArchive({archive,directory,signal,onEvent}){
|
|
10
|
+
throwIfAborted(signal);
|
|
11
|
+
await mkdir(directory,{recursive:true});
|
|
12
|
+
const expanded=archive.toLowerCase().endsWith('.zst')?`${archive}.tar`:null;
|
|
13
|
+
let expandedOwned=false;
|
|
14
|
+
let failure;
|
|
15
|
+
try{
|
|
16
|
+
if(expanded){
|
|
17
|
+
const handle=await open(expanded,'wx');
|
|
18
|
+
expandedOwned=true;
|
|
19
|
+
await pipeline(
|
|
20
|
+
createReadStream(archive),
|
|
21
|
+
createZstdDecompress(),
|
|
22
|
+
handle.createWriteStream(),
|
|
23
|
+
{signal}
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
throwIfAborted(signal);
|
|
27
|
+
// The host's archive utility preserves upstream directories, executable
|
|
28
|
+
// modes and library links, including archives for a different target.
|
|
29
|
+
if(archive.toLowerCase().endsWith('.zip')&&process.platform==='linux'){
|
|
30
|
+
// GNU tar does not read ZIP. Use the host's unzip utility for a
|
|
31
|
+
// Windows target selected on Linux; a missing utility is reported.
|
|
32
|
+
await runProcess('unzip',['-q',archive,'-d',path.resolve(directory)],{signal,onEvent});
|
|
33
|
+
}else{
|
|
34
|
+
await runProcess(process.platform==='win32'?'tar.exe':'tar',[
|
|
35
|
+
'-xf',expanded??archive,'-C',path.resolve(directory)
|
|
36
|
+
],{signal,onEvent});
|
|
37
|
+
}
|
|
38
|
+
}catch(error){
|
|
39
|
+
failure=error;
|
|
40
|
+
}finally{
|
|
41
|
+
if(expandedOwned){
|
|
42
|
+
try{
|
|
43
|
+
await unlink(expanded);
|
|
44
|
+
}catch(error){
|
|
45
|
+
if(error.code!=='ENOENT'){
|
|
46
|
+
failure=failure?new AggregateError([failure,error],'Local AI archive extraction and temporary-file cleanup failed.'):error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if(failure)throw failure;
|
|
52
|
+
}
|