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,62 @@
|
|
|
1
|
+
import {readFile, writeFile} from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import Is from 'strong-type';
|
|
4
|
+
import {ArcaneError, ERROR_CODES, throwIfAborted} from '../errors.mjs';
|
|
5
|
+
|
|
6
|
+
const is = new Is(false);
|
|
7
|
+
export const LOCAL_AI_RUNTIME_IDS = ['llama.cpp', 'ollama'];
|
|
8
|
+
|
|
9
|
+
/** Runtime requirements are application configuration, separate from models. */
|
|
10
|
+
export function normalizeLocalAIConfig(value) {
|
|
11
|
+
if (value === undefined) return undefined;
|
|
12
|
+
if (!value || !is.object(value) || is.array(value) || !is.array(value.runtimes)) {
|
|
13
|
+
throw new ArcaneError(ERROR_CODES.usage, 'localAI requires a runtimes array.');
|
|
14
|
+
}
|
|
15
|
+
const runtimes = value.runtimes.map(function runtimeRequirement(item) {
|
|
16
|
+
const record = is.string(item) ? {id: item} : item;
|
|
17
|
+
if (!record || !LOCAL_AI_RUNTIME_IDS.includes(record.id)) {
|
|
18
|
+
throw new ArcaneError(ERROR_CODES.usage, `Unknown local AI runtime: ${String(record?.id ?? item)}.`);
|
|
19
|
+
}
|
|
20
|
+
return {...record};
|
|
21
|
+
});
|
|
22
|
+
return {...value, runtimes};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseLocalAIRuntimeSelection(value) {
|
|
26
|
+
if (value === undefined) return undefined;
|
|
27
|
+
const selected = is.string(value) ? value.split(',').map(function runtimeName(name) {
|
|
28
|
+
return name.trim();
|
|
29
|
+
}) : value;
|
|
30
|
+
return normalizeLocalAIConfig({runtimes: selected}).runtimes;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Add explicitly requested development runtimes to the authored app owner. */
|
|
34
|
+
export async function configureDevelopmentLocalAI({appRoot, runtimes, signal} = {}) {
|
|
35
|
+
throwIfAborted(signal);
|
|
36
|
+
if (runtimes === undefined) return;
|
|
37
|
+
const selected = parseLocalAIRuntimeSelection(runtimes);
|
|
38
|
+
let filename = path.join(appRoot, 'arcane-app.json');
|
|
39
|
+
let record;
|
|
40
|
+
let authored = true;
|
|
41
|
+
try {
|
|
42
|
+
record = JSON.parse(await readFile(filename, {encoding: 'utf8', signal}));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code !== 'ENOENT') throw error;
|
|
45
|
+
authored = false;
|
|
46
|
+
filename = path.join(appRoot, 'arcane-package.json');
|
|
47
|
+
record = JSON.parse(await readFile(filename, {encoding: 'utf8', signal}));
|
|
48
|
+
}
|
|
49
|
+
const current = normalizeLocalAIConfig(authored ? record.native?.localAI : record.localAI);
|
|
50
|
+
const requirements = new Map((current?.runtimes ?? []).map(function runtimePair(runtime) {
|
|
51
|
+
return [runtime.id, runtime];
|
|
52
|
+
}));
|
|
53
|
+
for (const runtime of selected) {
|
|
54
|
+
requirements.set(runtime.id, {...requirements.get(runtime.id), ...runtime});
|
|
55
|
+
}
|
|
56
|
+
const localAI = {...current, runtimes: [...requirements.values()]};
|
|
57
|
+
if (authored) record.native = {...record.native, localAI};
|
|
58
|
+
else record.localAI = localAI;
|
|
59
|
+
throwIfAborted(signal);
|
|
60
|
+
await writeFile(filename, `${JSON.stringify(record, null, 2)}\n`, {encoding: 'utf8', signal});
|
|
61
|
+
return {path: filename, localAI};
|
|
62
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import Is from 'strong-type';
|
|
2
|
+
import {createCoreRuntime} from '../core/runtime.mjs';
|
|
3
|
+
import {createLocalAIService} from '../core/services/local-ai.mjs';
|
|
4
|
+
import {CORE_PROTOCOL, CoreError, serializeCoreError} from '../../browser-runtime/core/contracts.mjs';
|
|
5
|
+
import {normalizeLocalAIConfig} from './config.mjs';
|
|
6
|
+
import {discoverLocalAIRuntimes} from './discover.mjs';
|
|
7
|
+
import {ensureLocalAIRuntimes} from './install.mjs';
|
|
8
|
+
|
|
9
|
+
const is = new Is(false);
|
|
10
|
+
|
|
11
|
+
// The managed application import map owns this public module's actual URL.
|
|
12
|
+
const bootstrap = `import {getInstalledCoreClient,installCoreClient} from 'arcane-os/core/client';
|
|
13
|
+
let client=getInstalledCoreClient();
|
|
14
|
+
const installedRuntime=client?.runtime.current();
|
|
15
|
+
if(installedRuntime?.connected===false&&installedRuntime.transport==='standalone'){
|
|
16
|
+
client.close();
|
|
17
|
+
client=null;
|
|
18
|
+
}
|
|
19
|
+
if(!client){
|
|
20
|
+
globalThis.__ARCANE_DEV_HTTP__=true;
|
|
21
|
+
const id=crypto.randomUUID();
|
|
22
|
+
let receive;
|
|
23
|
+
let events;
|
|
24
|
+
let resolveOpen;
|
|
25
|
+
let rejectOpen;
|
|
26
|
+
const pending=new Set();
|
|
27
|
+
const opened=new Promise((resolve,reject)=>{resolveOpen=resolve;rejectOpen=reject;});
|
|
28
|
+
opened.catch(()=>{});
|
|
29
|
+
const transport={
|
|
30
|
+
name:'development-http',
|
|
31
|
+
subscribe(listener){
|
|
32
|
+
receive=listener;
|
|
33
|
+
events=new EventSource('/events?client='+encodeURIComponent(id));
|
|
34
|
+
events.onopen=()=>resolveOpen();
|
|
35
|
+
events.onmessage=event=>{
|
|
36
|
+
const frame=JSON.parse(event.data);
|
|
37
|
+
if(frame.type==='response')pending.delete(frame.id);
|
|
38
|
+
receive(frame);
|
|
39
|
+
};
|
|
40
|
+
events.onerror=event=>{
|
|
41
|
+
const error=new Error('The local AI development connection closed. Reload to reconnect.');
|
|
42
|
+
error.code='LOCAL_AI_DEV_CONNECTION_CLOSED';
|
|
43
|
+
error.event=event;
|
|
44
|
+
events.close();
|
|
45
|
+
rejectOpen(error);
|
|
46
|
+
client?.failTransport(error);
|
|
47
|
+
};
|
|
48
|
+
return ()=>events.close();
|
|
49
|
+
},
|
|
50
|
+
async send(frame){
|
|
51
|
+
if(frame.type==='request')pending.add(frame.id);
|
|
52
|
+
if(frame.type==='control'&&frame.control==='requests.cancelAll')frame={...frame,requestIds:[...pending]};
|
|
53
|
+
try{
|
|
54
|
+
await opened;
|
|
55
|
+
const response=await fetch('/rpc?client='+encodeURIComponent(id),{
|
|
56
|
+
method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(frame)
|
|
57
|
+
});
|
|
58
|
+
const result=await response.json();
|
|
59
|
+
if(!response.ok)throw Object.assign(new Error(result.error?.message??'The local AI request failed.'),result.error);
|
|
60
|
+
// Responses arrive after their chunks on this same SSE connection.
|
|
61
|
+
}catch(error){
|
|
62
|
+
if(frame.type==='request')pending.delete(frame.id);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
client=installCoreClient(globalThis,{transport,replayRuntimeState:true});
|
|
68
|
+
}
|
|
69
|
+
export {client};
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/** Start local preparation without delaying the application's HTTP listener. */
|
|
73
|
+
export function createDevelopmentLocalAI({config, appRoot, directory, application, version, signal, onEvent} = {}) {
|
|
74
|
+
const selected = normalizeLocalAIConfig(config) ?? {runtimes: []};
|
|
75
|
+
const lifetime = new AbortController();
|
|
76
|
+
const operationSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
|
|
77
|
+
const sessions = new Map();
|
|
78
|
+
const requestOwners = new Map();
|
|
79
|
+
const streamOwners = new Map();
|
|
80
|
+
const operations = new Set();
|
|
81
|
+
const incoming = new Set();
|
|
82
|
+
let runtime;
|
|
83
|
+
let service;
|
|
84
|
+
let stopFrames;
|
|
85
|
+
let preparationError = null;
|
|
86
|
+
let closing = null;
|
|
87
|
+
let closed = false;
|
|
88
|
+
|
|
89
|
+
function diagnostic(error) {
|
|
90
|
+
if (operationSignal.aborted && (error?.name === 'AbortError' || error?.code === 'ARCANE_CANCELLED')) return;
|
|
91
|
+
if (!is.function(onEvent)) {
|
|
92
|
+
console.error('Local AI development failed.', error);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
Promise.resolve(onEvent({type: 'local-ai.development.error', message: error.message, error: serializeCoreError(error)}))
|
|
97
|
+
.catch(function observerFailed(cause) { console.error('Local AI development observer failed.', cause, error); });
|
|
98
|
+
} catch (cause) { console.error('Local AI development observer failed.', cause, error); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sessionFor(id) {
|
|
102
|
+
let session = sessions.get(id);
|
|
103
|
+
if (!session) {
|
|
104
|
+
session = {id, connections: new Set(), requests: new Map(), cancelledRequests: new Set()};
|
|
105
|
+
sessions.set(id, session);
|
|
106
|
+
}
|
|
107
|
+
return session;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeFrame(connection, frame) {
|
|
111
|
+
const text = `data: ${JSON.stringify(frame)}\n\n`;
|
|
112
|
+
connection.writes = connection.writes.then(function writeNextFrame() {
|
|
113
|
+
if (connection.closed) return;
|
|
114
|
+
return new Promise(function awaitWrite(resolve, reject) {
|
|
115
|
+
function finish(error) {
|
|
116
|
+
connection.response.removeListener('close', disconnected);
|
|
117
|
+
if (error) reject(error);
|
|
118
|
+
else resolve();
|
|
119
|
+
}
|
|
120
|
+
function disconnected() { finish(); }
|
|
121
|
+
connection.response.once('close', disconnected);
|
|
122
|
+
connection.response.write(text, finish);
|
|
123
|
+
});
|
|
124
|
+
}).catch(function streamWriteFailed(error) {
|
|
125
|
+
diagnostic(error);
|
|
126
|
+
connection.response.destroy(error);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sendSession(session, frame) {
|
|
131
|
+
for (const connection of session.connections) writeFrame(connection, frame);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function event(event, data) {
|
|
135
|
+
return {protocol: CORE_PROTOCOL, type: 'event', event, data, time: new Date().toISOString()};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function routeFrame(frame) {
|
|
139
|
+
if (frame.type === 'response') {
|
|
140
|
+
const owner = requestOwners.get(frame.id);
|
|
141
|
+
if (owner) sendSession(owner, frame);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (frame.type !== 'event') return;
|
|
145
|
+
if (frame.data?.streamId !== undefined) {
|
|
146
|
+
const owner = streamOwners.get(frame.data.streamId);
|
|
147
|
+
if (owner) sendSession(owner, frame);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const session of sessions.values()) sendSession(session, frame);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function replay(connection) {
|
|
154
|
+
if (!runtime || connection.closed) return;
|
|
155
|
+
const current = runtime.current();
|
|
156
|
+
if (current.state === 'ready') writeFrame(connection, event('core.ready', {version: current.version, app: current.application}));
|
|
157
|
+
writeFrame(connection, event('core.state', current));
|
|
158
|
+
for (const record of current.services) writeFrame(connection, event('core.service.state', record));
|
|
159
|
+
writeFrame(connection, event('localai.state', service.current()));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ready = Promise.resolve().then(async function prepareLocalAI() {
|
|
163
|
+
operationSignal.throwIfAborted();
|
|
164
|
+
const discovered = await discoverLocalAIRuntimes({config: selected, appRoot, signal: operationSignal});
|
|
165
|
+
operationSignal.throwIfAborted();
|
|
166
|
+
const installed = await ensureLocalAIRuntimes({runtimes: discovered.missing, directory, signal: operationSignal, onEvent});
|
|
167
|
+
const runtimes = [...discovered.available, ...installed];
|
|
168
|
+
operationSignal.throwIfAborted();
|
|
169
|
+
service = createLocalAIService(selected, {appRoot, runtimes, signal: operationSignal, onEvent});
|
|
170
|
+
runtime = createCoreRuntime({application, version, services: [service]});
|
|
171
|
+
stopFrames = runtime.onFrame(routeFrame);
|
|
172
|
+
runtime.start();
|
|
173
|
+
return runtime;
|
|
174
|
+
});
|
|
175
|
+
ready.catch(function preparationFailed(error) {
|
|
176
|
+
preparationError = error;
|
|
177
|
+
diagnostic(error);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
function waitForReady(signal) {
|
|
181
|
+
const waiting = AbortSignal.any([signal, operationSignal]);
|
|
182
|
+
if (waiting.aborted) return Promise.reject(waiting.reason);
|
|
183
|
+
return new Promise(function awaitPreparedCore(resolve, reject) {
|
|
184
|
+
function abort() {
|
|
185
|
+
waiting.removeEventListener('abort', abort);
|
|
186
|
+
reject(waiting.reason);
|
|
187
|
+
}
|
|
188
|
+
waiting.addEventListener('abort', abort, {once: true});
|
|
189
|
+
ready.then(function prepared(value) {
|
|
190
|
+
waiting.removeEventListener('abort', abort);
|
|
191
|
+
if (waiting.aborted) reject(waiting.reason);
|
|
192
|
+
else resolve(value);
|
|
193
|
+
}, function failed(error) {
|
|
194
|
+
waiting.removeEventListener('abort', abort);
|
|
195
|
+
reject(error);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function observe(operation) {
|
|
201
|
+
operations.add(operation);
|
|
202
|
+
operation.then(function operationDone() { operations.delete(operation); }, function operationFailed() { operations.delete(operation); });
|
|
203
|
+
return operation;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function cancelRequest(id) {
|
|
207
|
+
if (!runtime) return;
|
|
208
|
+
const task = runtime.handle({protocol: CORE_PROTOCOL, type: 'control', control: 'request.cancel', requestId: id});
|
|
209
|
+
observe(task).catch(diagnostic);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function disconnect(connection) {
|
|
213
|
+
if (connection.closed) return;
|
|
214
|
+
connection.closed = true;
|
|
215
|
+
const session = connection.session;
|
|
216
|
+
session.connections.delete(connection);
|
|
217
|
+
if (session.connections.size) return;
|
|
218
|
+
for (const request of session.requests.values()) {
|
|
219
|
+
request.controller.abort(new CoreError({code: 'LOCAL_AI_DEV_DISCONNECTED', message: 'The browser disconnected from local AI.'}));
|
|
220
|
+
}
|
|
221
|
+
if (!session.requests.size) sessions.delete(session.id);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function json(response, status, value) {
|
|
225
|
+
if (response.destroyed || response.writableEnded) return;
|
|
226
|
+
response.writeHead(status, {'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store'});
|
|
227
|
+
response.end(JSON.stringify(value));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function dispatch(frame, session, controller) {
|
|
231
|
+
if (frame?.protocol !== CORE_PROTOCOL) throw new CoreError({code: 'INVALID_RPC_REQUEST', message: 'Unknown Core protocol.'});
|
|
232
|
+
if (frame.type === 'control' && frame.control === 'requests.cancelAll') {
|
|
233
|
+
for (const id of frame.requestIds ?? []) session.cancelledRequests.add(id);
|
|
234
|
+
for (const request of session.requests.values()) {
|
|
235
|
+
request.controller.abort();
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (frame.type === 'control' && frame.control === 'request.cancel') {
|
|
240
|
+
session.cancelledRequests.add(frame.requestId);
|
|
241
|
+
const request = session.requests.get(frame.requestId);
|
|
242
|
+
if (request) {
|
|
243
|
+
request.controller.abort();
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (frame.type !== 'request') {
|
|
248
|
+
await waitForReady(controller.signal);
|
|
249
|
+
controller.signal.throwIfAborted();
|
|
250
|
+
operationSignal.throwIfAborted();
|
|
251
|
+
if (frame.type === 'control' && frame.control === 'runtime.replay') {
|
|
252
|
+
for (const connection of session.connections) replay(connection);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
return runtime.handle(frame);
|
|
256
|
+
}
|
|
257
|
+
if (requestOwners.has(frame.id)) throw new CoreError({code: 'RPC_REQUEST_ID_ACTIVE', message: 'The request id is already active.'});
|
|
258
|
+
const owned = {id: frame.id, controller};
|
|
259
|
+
const streamId = frame.parameters?.streamId;
|
|
260
|
+
session.requests.set(frame.id, owned);
|
|
261
|
+
requestOwners.set(frame.id, session);
|
|
262
|
+
if (session.cancelledRequests.delete(frame.id)) controller.abort();
|
|
263
|
+
if (streamId !== undefined) streamOwners.set(streamId, session);
|
|
264
|
+
function cancelled() { cancelRequest(frame.id); }
|
|
265
|
+
controller.signal.addEventListener('abort', cancelled, {once: true});
|
|
266
|
+
try {
|
|
267
|
+
await waitForReady(controller.signal);
|
|
268
|
+
controller.signal.throwIfAborted();
|
|
269
|
+
operationSignal.throwIfAborted();
|
|
270
|
+
return await runtime.handle(frame);
|
|
271
|
+
} finally {
|
|
272
|
+
controller.signal.removeEventListener('abort', cancelled);
|
|
273
|
+
session.requests.delete(frame.id);
|
|
274
|
+
session.cancelledRequests.delete(frame.id);
|
|
275
|
+
requestOwners.delete(frame.id);
|
|
276
|
+
if (streamId !== undefined) streamOwners.delete(streamId);
|
|
277
|
+
if (!session.connections.size && !session.requests.size) sessions.delete(session.id);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function handler(request, response) {
|
|
282
|
+
const url = new URL(request.url, 'http://arcane.local');
|
|
283
|
+
if (!['/rpc', '/events', '/arcane-local-ai.js'].includes(url.pathname)) return false;
|
|
284
|
+
if (closed) {
|
|
285
|
+
json(response, 503, {error: {code: 'LOCAL_AI_DEV_CLOSED', message: 'Local AI development is closed.'}});
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
if (url.pathname === '/arcane-local-ai.js') {
|
|
289
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
290
|
+
json(response, 405, {error: {message: 'Use GET to load the local AI bootstrap.'}});
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
response.writeHead(200, {'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store'});
|
|
294
|
+
response.end(request.method === 'HEAD' ? undefined : bootstrap);
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
const clientId = url.searchParams.get('client');
|
|
298
|
+
if (!clientId) {
|
|
299
|
+
json(response, 400, {error: {code: 'LOCAL_AI_DEV_CLIENT_REQUIRED', message: 'Load the local AI development bootstrap before connecting.'}});
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
const method = url.pathname === '/events' ? 'GET' : 'POST';
|
|
303
|
+
if (request.method !== method) {
|
|
304
|
+
json(response, 405, {error: {message: `Use ${method} for ${url.pathname}.`}});
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
const session = sessionFor(clientId);
|
|
308
|
+
if (url.pathname === '/events') {
|
|
309
|
+
const connection = {response, session, closed: false, writes: Promise.resolve()};
|
|
310
|
+
session.connections.add(connection);
|
|
311
|
+
response.on('close', function eventConnectionClosed() { disconnect(connection); });
|
|
312
|
+
response.on('error', diagnostic);
|
|
313
|
+
response.writeHead(200, {'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', Connection: 'keep-alive'});
|
|
314
|
+
response.flushHeaders();
|
|
315
|
+
ready.then(function replayReadyRuntime() { replay(connection); }, function reportPreparationFailure(error) {
|
|
316
|
+
if (!connection.closed) writeFrame(connection, event('core.error', serializeCoreError(error)));
|
|
317
|
+
}).catch(diagnostic);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
const controller = new AbortController();
|
|
321
|
+
const rpc = {request, response, controller};
|
|
322
|
+
incoming.add(rpc);
|
|
323
|
+
const abortOnClose = function rpcConnectionClosed() {
|
|
324
|
+
if (!response.writableEnded) controller.abort(new CoreError({code: 'LOCAL_AI_DEV_DISCONNECTED', message: 'The local AI request connection closed.'}));
|
|
325
|
+
};
|
|
326
|
+
response.once('close', abortOnClose);
|
|
327
|
+
let frame;
|
|
328
|
+
try {
|
|
329
|
+
request.setEncoding('utf8');
|
|
330
|
+
let body = '';
|
|
331
|
+
for await (const part of request) body += part;
|
|
332
|
+
frame = JSON.parse(body);
|
|
333
|
+
const result = await observe(dispatch(frame, session, controller));
|
|
334
|
+
json(response, 200, result ?? {ok: true});
|
|
335
|
+
} catch (error) {
|
|
336
|
+
diagnostic(error);
|
|
337
|
+
if (frame?.type === 'request' && frame.id) {
|
|
338
|
+
const result = {protocol: CORE_PROTOCOL, type: 'response', id: frame.id, ok: false, error: serializeCoreError(error), time: new Date().toISOString()};
|
|
339
|
+
sendSession(session, result);
|
|
340
|
+
json(response, 200, result);
|
|
341
|
+
} else json(response, 400, {error: serializeCoreError(error)});
|
|
342
|
+
} finally {
|
|
343
|
+
incoming.delete(rpc);
|
|
344
|
+
response.removeListener('close', abortOnClose);
|
|
345
|
+
if (!session.connections.size && !session.requests.size) sessions.delete(session.id);
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function current() {
|
|
351
|
+
return {state: closed ? 'closed' : preparationError ? 'error' : runtime ? 'ready' : 'preparing',
|
|
352
|
+
error: preparationError ? serializeCoreError(preparationError) : null,
|
|
353
|
+
core: runtime?.current() ?? null, localAI: service?.current() ?? null};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function close() {
|
|
357
|
+
if (closing) return closing;
|
|
358
|
+
closed = true;
|
|
359
|
+
lifetime.abort();
|
|
360
|
+
for (const rpc of incoming) {
|
|
361
|
+
rpc.controller.abort(lifetime.signal.reason);
|
|
362
|
+
rpc.request.destroy();
|
|
363
|
+
rpc.response.destroy();
|
|
364
|
+
}
|
|
365
|
+
for (const session of sessions.values()) {
|
|
366
|
+
for (const connection of [...session.connections]) {
|
|
367
|
+
disconnect(connection);
|
|
368
|
+
connection.response.destroy();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
closing = (async function closeLocalAI() {
|
|
372
|
+
try { await ready; } catch {}
|
|
373
|
+
try {
|
|
374
|
+
await runtime?.close();
|
|
375
|
+
} finally {
|
|
376
|
+
await Promise.allSettled([...operations]);
|
|
377
|
+
stopFrames?.();
|
|
378
|
+
sessions.clear();
|
|
379
|
+
signal?.removeEventListener('abort', abortDevelopment);
|
|
380
|
+
}
|
|
381
|
+
return current();
|
|
382
|
+
})();
|
|
383
|
+
return closing;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function abortDevelopment() { close().catch(diagnostic); }
|
|
387
|
+
signal?.addEventListener('abort', abortDevelopment, {once: true});
|
|
388
|
+
if (signal?.aborted) abortDevelopment();
|
|
389
|
+
return {ready, handler, current, close};
|
|
390
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {constants} from 'node:fs';
|
|
2
|
+
import {access,stat} from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {throwIfAborted} from '../errors.mjs';
|
|
5
|
+
import {normalizeLocalAIConfig} from './config.mjs';
|
|
6
|
+
import {inspectLocalAIServer,localAIConnectionRefused,localAIServerURL} from './server.mjs';
|
|
7
|
+
|
|
8
|
+
async function pathExecutable(id,appRoot,signal){
|
|
9
|
+
const filename=`${id==='llama.cpp'?'llama-server':'ollama'}${process.platform==='win32'?'.exe':''}`;
|
|
10
|
+
for(const directory of (process.env.PATH??'').split(path.delimiter)){
|
|
11
|
+
throwIfAborted(signal);
|
|
12
|
+
if(!directory)continue;
|
|
13
|
+
const candidate=path.resolve(appRoot??process.cwd(),directory.replace(/^"|"$/gu,''),filename);
|
|
14
|
+
try{
|
|
15
|
+
await access(candidate,process.platform==='win32'?constants.F_OK:constants.X_OK);
|
|
16
|
+
if((await stat(candidate)).isFile())return candidate;
|
|
17
|
+
}catch(error){
|
|
18
|
+
if(!['ENOENT','ENOTDIR','EACCES','EPERM'].includes(error.code))throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Discover development availability without installing or starting a process. */
|
|
25
|
+
export async function discoverLocalAIRuntimes({config,appRoot,signal}={}){
|
|
26
|
+
const selected=normalizeLocalAIConfig(config)?.runtimes??[];
|
|
27
|
+
const outcomes=await Promise.allSettled(selected.map(async function discoverRuntime(requirement){
|
|
28
|
+
throwIfAborted(signal);
|
|
29
|
+
if(!['llama.cpp','ollama'].includes(requirement.id))return {missing:requirement};
|
|
30
|
+
const configuration=config[requirement.id==='llama.cpp'?'llamaCpp':'ollama']??{};
|
|
31
|
+
const url=localAIServerURL(requirement.id,configuration);
|
|
32
|
+
const concreteVersion=requirement.version&&requirement.version!=='latest';
|
|
33
|
+
let discovery;
|
|
34
|
+
try{
|
|
35
|
+
discovery=await inspectLocalAIServer({id:requirement.id,url,signal});
|
|
36
|
+
}catch(error){
|
|
37
|
+
throwIfAborted(signal);
|
|
38
|
+
if(!localAIConnectionRefused(error))throw error;
|
|
39
|
+
}
|
|
40
|
+
if(discovery){
|
|
41
|
+
const matches=!concreteVersion||(discovery.ready&&discovery.version?.replace(/^v/u,'')===requirement.version.replace(/^v/u,''));
|
|
42
|
+
if(!matches)return {missing:requirement};
|
|
43
|
+
// A response from an occupied endpoint is retained in full, even
|
|
44
|
+
// while loading or failing. It never triggers a competing install
|
|
45
|
+
// or process and is never presented as inference readiness.
|
|
46
|
+
return {available:{
|
|
47
|
+
id:requirement.id,version:discovery.version??'system',external:true,
|
|
48
|
+
url,platform:process.platform,architecture:process.arch,
|
|
49
|
+
...(concreteVersion?{requestedVersion:requirement.version}:{}),discovery
|
|
50
|
+
}};
|
|
51
|
+
}
|
|
52
|
+
if(concreteVersion)return {missing:requirement};
|
|
53
|
+
const executable=await pathExecutable(requirement.id,appRoot,signal);
|
|
54
|
+
if(!executable)return {missing:requirement};
|
|
55
|
+
return {available:{
|
|
56
|
+
id:requirement.id,version:'system',external:true,executable,root:path.dirname(executable),
|
|
57
|
+
platform:process.platform,architecture:process.arch
|
|
58
|
+
}};
|
|
59
|
+
}));
|
|
60
|
+
const failures=outcomes.filter(function failedDiscovery(outcome){return outcome.status==='rejected';});
|
|
61
|
+
if(failures.length===1)throw failures[0].reason;
|
|
62
|
+
if(failures.length>1)throw new AggregateError(failures.map(function discoveryFailure(outcome){return outcome.reason;}),'Local AI runtime discovery failed.');
|
|
63
|
+
throwIfAborted(signal);
|
|
64
|
+
const available=[];
|
|
65
|
+
const missing=[];
|
|
66
|
+
for(const outcome of outcomes){
|
|
67
|
+
if(outcome.value.available)available.push(outcome.value.available);
|
|
68
|
+
else missing.push(outcome.value.missing);
|
|
69
|
+
}
|
|
70
|
+
return {available,missing};
|
|
71
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Send the caller's complete payload using only JSON transport encoding. */
|
|
2
|
+
export async function requestLocalJSON({url, path = '', method = 'GET', payload, signal}) {
|
|
3
|
+
const response = await requestLocalResponse({url, path, method, payload, signal, accept: 'application/json'});
|
|
4
|
+
const body = await response.text();
|
|
5
|
+
return body === '' ? undefined : parseLocalJSON(body, response);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Yield upstream JSON records unchanged; the caller owns completion assembly. */
|
|
9
|
+
export async function* streamLocalJSON({url, path = '', method = 'POST', payload, format = 'sse', signal, onOpen}) {
|
|
10
|
+
if (format !== 'sse' && format !== 'ndjson') {
|
|
11
|
+
throw new TypeError(`Unsupported local AI stream format: ${format}.`);
|
|
12
|
+
}
|
|
13
|
+
const response = await requestLocalResponse({
|
|
14
|
+
url, path, method, payload, signal,
|
|
15
|
+
accept: format === 'sse' ? 'text/event-stream' : 'application/x-ndjson'
|
|
16
|
+
});
|
|
17
|
+
let data = [];
|
|
18
|
+
for await (const line of readLocalLines(response, onOpen)) {
|
|
19
|
+
if (format === 'ndjson') {
|
|
20
|
+
if (line !== '') yield parseLocalJSON(line, response);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (line === '') {
|
|
24
|
+
if (!data.length) continue;
|
|
25
|
+
const body = data.join('\n');
|
|
26
|
+
data = [];
|
|
27
|
+
if (body === '[DONE]') return;
|
|
28
|
+
yield parseLocalJSON(body, response);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const separator = line.indexOf(':');
|
|
32
|
+
const field = separator < 0 ? line : line.slice(0, separator);
|
|
33
|
+
if (field !== 'data') continue;
|
|
34
|
+
const value = separator < 0 ? '' : line.slice(separator + 1);
|
|
35
|
+
data.push(value.startsWith(' ') ? value.slice(1) : value);
|
|
36
|
+
}
|
|
37
|
+
if (data.length) {
|
|
38
|
+
const body = data.join('\n');
|
|
39
|
+
if (body !== '[DONE]') yield parseLocalJSON(body, response);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function requestLocalResponse({url, path, method, payload, signal, accept}) {
|
|
44
|
+
const headers = {Accept: accept};
|
|
45
|
+
if (payload !== undefined) headers['Content-Type'] = 'application/json';
|
|
46
|
+
const response = await fetch(new URL(path, url), {
|
|
47
|
+
method,
|
|
48
|
+
headers,
|
|
49
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
50
|
+
signal
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
const body = await response.text();
|
|
54
|
+
const error = new Error(`Local AI HTTP ${response.status} ${response.statusText}: ${body}`);
|
|
55
|
+
error.status = response.status;
|
|
56
|
+
error.body = body;
|
|
57
|
+
error.url = response.url;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return response;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseLocalJSON(body, response) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(body);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
error.status = response.status;
|
|
68
|
+
error.body = body;
|
|
69
|
+
error.url = response.url;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Remove only SSE/NDJSON line framing, including delimiters across reads. */
|
|
75
|
+
async function* readLocalLines(response, onOpen) {
|
|
76
|
+
const chunks = response.body[Symbol.asyncIterator]();
|
|
77
|
+
const decoder = new TextDecoder('utf-8', {fatal: true});
|
|
78
|
+
let pending = '';
|
|
79
|
+
let precedingCarriageReturn = false;
|
|
80
|
+
try {
|
|
81
|
+
await onOpen?.(response);
|
|
82
|
+
while (true) {
|
|
83
|
+
const {value, done} = await chunks.next();
|
|
84
|
+
let text = done ? decoder.decode() : decoder.decode(value, {stream: true});
|
|
85
|
+
if (precedingCarriageReturn && text !== '') {
|
|
86
|
+
if (text.startsWith('\n')) text = text.slice(1);
|
|
87
|
+
precedingCarriageReturn = false;
|
|
88
|
+
}
|
|
89
|
+
let start = 0;
|
|
90
|
+
for (const delimiter of text.matchAll(/\r\n|\r|\n/g)) {
|
|
91
|
+
const line = pending + text.slice(start, delimiter.index);
|
|
92
|
+
pending = '';
|
|
93
|
+
start = delimiter.index + delimiter[0].length;
|
|
94
|
+
precedingCarriageReturn = delimiter[0] === '\r' && start === text.length;
|
|
95
|
+
yield line;
|
|
96
|
+
}
|
|
97
|
+
pending += text.slice(start);
|
|
98
|
+
if (done) {
|
|
99
|
+
if (pending !== '') yield pending;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
await chunks.return();
|
|
105
|
+
}
|
|
106
|
+
}
|