arcane-os 0.1.2 → 0.2.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/NOTICE +5 -3
  3. package/README.md +73 -24
  4. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +67 -18
  5. package/browser-runtime/ai/ARCANE_AI_BROWSER_WASM_COMPONENTS.json +16 -5
  6. package/browser-runtime/ai/browser-kokoro-worker.mjs +3 -0
  7. package/browser-runtime/ai/browser-speech-artifacts.mjs +1108 -0
  8. package/browser-runtime/ai/browser-speech-providers.mjs +475 -0
  9. package/browser-runtime/ai/browser-speech.mjs +9 -0
  10. package/browser-runtime/ai/browser-wasm-llm-provider.mjs +1537 -167
  11. package/browser-runtime/ai/browser-wasm.mjs +46 -1
  12. package/browser-runtime/ai/browser-whisper-worker.mjs +3 -0
  13. package/browser-runtime/ai/browser-wllama-runtime.mjs +677 -132
  14. package/browser-runtime/ai/model-controller.mjs +138 -12
  15. package/browser-runtime/ai/speech-worker-client.mjs +207 -0
  16. package/browser-runtime/ai/speech-worker-runtime.mjs +516 -0
  17. package/browser-runtime/ai/wllama/index.mjs +389 -0
  18. package/docs/architecture.md +132 -22
  19. package/docs/reference/README.md +1 -1
  20. package/docs/reference/ai/browser-wasm.md +101 -42
  21. package/docs/reference/availability-and-normalization.md +19 -5
  22. package/docs/reference/behavioral-testing.md +18 -5
  23. package/docs/reference/cli.md +2 -2
  24. package/docs/reference/inventory/package-api.json +14 -14
  25. package/docs/reference/protocols.md +4 -4
  26. package/docs/reference/sdk-api.md +68 -38
  27. package/docs/work-amplification.md +8 -4
  28. package/package.json +7 -3
  29. package/runtime/ARCANE_RUNTIME_RELEASE.json +50 -20
  30. package/runtime/arcane/components/chat.html +280 -62
  31. package/runtime/arcane/components/speech.html +1113 -265
  32. package/runtime/arcane/entities/Chat.js +246 -43
  33. package/runtime/arcane/modules/AI.js +713 -162
  34. package/runtime/arcane/modules/AIProviderRuntime.js +2289 -0
  35. package/runtime/arcane/modules/AIRuntimeState.js +872 -0
  36. package/runtime/arcane/modules/ConfiguredAIChatSession.js +293 -27
  37. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +682 -0
  38. package/runtime/arcane/modules/DocumentLexicalSearch.js +292 -0
  39. package/runtime/arcane/modules/PersistentAIChatSession.js +268 -0
  40. package/runtime/arcane/modules/StaticDocumentCatalog.js +25 -206
  41. package/schemas/arcane-lock.schema.json +6 -4
  42. package/src/cli/main.mjs +14 -2
  43. package/src/constants.mjs +1 -1
  44. package/src/dev-server.mjs +244 -13
  45. package/src/import-map.mjs +59 -1
  46. package/src/packager/core.mjs +2 -2
  47. package/src/runtime.mjs +14 -4
  48. package/src/sdk-browser-runtime.mjs +28 -75
  49. package/src/templates/workspace-template.mjs +4 -4
  50. package/src/toolchain.mjs +3 -0
  51. package/src/workspace-runtime.mjs +1 -1
  52. package/src/workspace.mjs +1 -1
@@ -1,79 +1,72 @@
1
1
  import './DBOPFS.js';
2
2
  import UserEntity from '../entities/User.js';
3
3
  import {getAIPreferencesForRuntime} from './AIPreferenceRuntime.js';
4
+ import {getAIProviderRuntime} from './AIProviderRuntime.js';
4
5
  import {normalizeOllamaModelIdentifier} from './OllamaModelIdentifier.js';
5
6
 
6
7
  let credentials='include';
7
8
  credentials='omit';
8
9
 
9
- const ARCANE_AI_REQUEST_DIAGNOSTIC_LABEL=
10
- '[Arcane AI] exact outbound inference request';
11
- const ARCANE_AI_RESPONSE_DIAGNOSTIC_LABEL=
12
- '[Arcane AI] exact inbound inference response';
13
- const ARCANE_AI_DIAGNOSTIC_WARNING=
14
- 'may contain private conversation or document content.';
15
-
16
- function snapshotAIConsolePayload(payload){
17
- const serialized=JSON.stringify(payload);
18
- return serialized===undefined?payload:JSON.parse(serialized);
19
- }
20
-
21
- function reportAIExchangeToConsole(label,{
22
- id,
23
- operation='',
24
- service='',
25
- transport='',
26
- destination='',
27
- payload
28
- }={}){
29
- try{
30
- console.info(
31
- `${label}; ${ARCANE_AI_DIAGNOSTIC_WARNING}`,
32
- {
33
- id,
34
- operation,
35
- service,
36
- transport,
37
- destination,
38
- payload:snapshotAIConsolePayload(payload)
39
- }
40
- );
41
- }catch(error){
42
- console.warn('Arcane AI console instrumentation failed:',error);
43
- }
44
- }
45
-
46
- function reportAIRequestToConsole({request={},...metadata}={}){
47
- reportAIExchangeToConsole(
48
- ARCANE_AI_REQUEST_DIAGNOSTIC_LABEL,
49
- {...metadata,payload:request}
50
- );
51
- }
52
-
53
- function reportAIResponseToConsole({response,...metadata}={}){
54
- reportAIExchangeToConsole(
55
- ARCANE_AI_RESPONSE_DIAGNOSTIC_LABEL,
56
- {...metadata,payload:response}
57
- );
58
- }
10
+ const LEGACY_AI_SERVICES=new Set(['OPENAI','OLLAMA','LOCAL_SPEACH']);
59
11
 
60
12
  function isAIRequestAbort(error,signal){
61
13
  return signal?.aborted
62
14
  ||error?.name==='AbortError'
63
15
  ||error?.code==='ARCANE_REQUEST_ABORTED'
16
+ ||error?.code==='ARCANE_AI_REQUEST_ABORTED'
64
17
  ||error?.code==='AI_REQUEST_ABORTED';
65
18
  }
66
19
 
67
20
  function normalizeAIRequestAbort(error){
68
- if(error?.code==='AI_REQUEST_ABORTED'){
21
+ if(error?.code==='ARCANE_AI_REQUEST_ABORTED'){
69
22
  return error;
70
23
  }
71
24
  const normalized=new Error('The AI request was cancelled.',{cause:error});
72
25
  normalized.name='AbortError';
73
- normalized.code='AI_REQUEST_ABORTED';
26
+ normalized.code='ARCANE_AI_REQUEST_ABORTED';
74
27
  return normalized;
75
28
  }
76
29
 
30
+ function normalizeAIStartupOptions(options){
31
+ if(options===undefined){
32
+ return Object.freeze({startMuted:true,signal:null});
33
+ }
34
+ if(!options||typeof options!=='object'||Array.isArray(options)){
35
+ throw new TypeError('AI startup options must be a plain object.');
36
+ }
37
+ const prototype=Object.getPrototypeOf(options);
38
+ if(prototype!==Object.prototype&&prototype!==null){
39
+ throw new TypeError('AI startup options must be a plain object.');
40
+ }
41
+ const descriptors=Object.getOwnPropertyDescriptors(options);
42
+ for(const key of Reflect.ownKeys(descriptors)){
43
+ if(typeof key==='symbol'||(key!=='startMuted'&&key!=='signal')){
44
+ throw new TypeError('AI startup options contain an unknown option.');
45
+ }
46
+ if(!Object.hasOwn(descriptors[key],'value')){
47
+ throw new TypeError(`AI startup options.${key} must be a data property.`);
48
+ }
49
+ }
50
+ const startMuted=Object.hasOwn(descriptors,'startMuted')
51
+ ?descriptors.startMuted.value
52
+ :true;
53
+ const signal=Object.hasOwn(descriptors,'signal')
54
+ ?descriptors.signal.value
55
+ :null;
56
+ if(typeof startMuted!=='boolean'){
57
+ throw new TypeError('AI startup startMuted must be a boolean.');
58
+ }
59
+ if(signal!==null&&signal!==undefined&&(
60
+ typeof signal!=='object'
61
+ ||typeof signal.aborted!=='boolean'
62
+ ||typeof signal.addEventListener!=='function'
63
+ ||typeof signal.removeEventListener!=='function'
64
+ )){
65
+ throw new TypeError('AI startup signal must be an AbortSignal.');
66
+ }
67
+ return Object.freeze({startMuted,signal});
68
+ }
69
+
77
70
  class AI {
78
71
  // This is the enum section for inference configuration
79
72
  #service = {
@@ -155,7 +148,7 @@ class AI {
155
148
  }
156
149
 
157
150
  ready=false;
158
- muted=false;
151
+ muted=true;
159
152
 
160
153
 
161
154
  llmService = '';
@@ -186,16 +179,33 @@ class AI {
186
179
  return window.ai;
187
180
  }
188
181
 
182
+ const preferences=[
183
+ llmService||'OPENAI',
184
+ sttService||'OPENAI',
185
+ ttsService||'OPENAI',
186
+ model||'OPENAI',
187
+ modelTTS||'OPENAI',
188
+ modelSTT||'OPENAI'
189
+ ];
189
190
  this.setAI(
190
- llmService || 'OPENAI',
191
- sttService || 'OPENAI',
192
- ttsService || 'OPENAI',
193
- model || 'OPENAI',
194
- modelTTS || 'OPENAI',
195
- modelSTT || 'OPENAI'
191
+ ...preferences
196
192
  );
197
193
  }
198
194
 
195
+ #providerRuntime=getAIProviderRuntime();
196
+ #preferenceTuple=Object.freeze([
197
+ 'OPENAI',
198
+ 'OPENAI',
199
+ 'OPENAI',
200
+ 'OPENAI',
201
+ 'OPENAI',
202
+ 'OPENAI'
203
+ ]);
204
+
205
+ get providerRuntime(){
206
+ return this.#providerRuntime;
207
+ }
208
+
199
209
  get url() {
200
210
  return `${this.#service.baseURL[this.llmService]}${this.#paths.chat[this.llmService]}`
201
211
  }
@@ -234,6 +244,10 @@ class AI {
234
244
  }
235
245
 
236
246
  get configured(){
247
+ if(this.#usesProviderRuntime('llm',this.llmService)){
248
+ const state=this.#providerRuntime.status('llm');
249
+ return state.state==='ready'&&state.loaded===true;
250
+ }
237
251
  if(this.llmService==='OLLAMA'){
238
252
  return Boolean(this.model)&&Boolean(this.#nativeOllama());
239
253
  }
@@ -243,9 +257,12 @@ class AI {
243
257
  &&Boolean(this.license);
244
258
  }
245
259
 
246
- #assertServiceConfigured(service=this.llmService){
260
+ #assertServiceConfigured(service=this.llmService,role='llm'){
261
+ if(this.#usesProviderRuntime(role,service)){
262
+ return true;
263
+ }
247
264
  if(service==='OLLAMA'){
248
- if(this.#nativeOllama()){
265
+ if(role==='llm'&&this.#nativeOllama()){
249
266
  return true;
250
267
  }
251
268
 
@@ -255,8 +272,16 @@ class AI {
255
272
  error.code='AI_NATIVE_LOCAL_REQUIRED';
256
273
  throw error;
257
274
  }
258
- if(service&&service!=='OPENAI'){
259
- return true;
275
+ if(service==='LOCAL_SPEACH'){
276
+ if(this.#nativeSpeech(service,role)){
277
+ return true;
278
+ }
279
+
280
+ const error=new Error(
281
+ `Local ${role.toUpperCase()} requires the capability-gated Arcane API.`
282
+ );
283
+ error.code='AI_NATIVE_LOCAL_REQUIRED';
284
+ throw error;
260
285
  }
261
286
 
262
287
  if(service==='OPENAI'&&this.license){
@@ -268,6 +293,35 @@ class AI {
268
293
  throw error;
269
294
  }
270
295
 
296
+ #usesProviderRuntime(role,service){
297
+ return Boolean(this.#providerRuntime.selection(role));
298
+ }
299
+
300
+ #shouldUseProviderRuntime(role,service,localOnly=false){
301
+ if(!localOnly){
302
+ return this.#usesProviderRuntime(role,service);
303
+ }
304
+ const selection=this.#providerRuntime.selection(
305
+ role,
306
+ {localOnly:true}
307
+ );
308
+ return Boolean(selection);
309
+ }
310
+
311
+ #hasLocalRoute(role,service){
312
+ const selection=this.#providerRuntime.selection(
313
+ role,
314
+ {localOnly:true}
315
+ );
316
+ if(selection){
317
+ return selection.localOnly===true;
318
+ }
319
+ if(this.#providerRuntime.selection(role)){
320
+ return false;
321
+ }
322
+ return role==='llm'&&service==='OLLAMA';
323
+ }
324
+
271
325
  audioMessageChunks='';
272
326
  sourceNodes=[];
273
327
  isSpeaking=false;
@@ -282,6 +336,140 @@ class AI {
282
336
  speechSynthesisTail=Promise.resolve();
283
337
  speechUnlockHandler=null;
284
338
 
339
+ #nextPreferenceTuple(values){
340
+ const current=this.#preferenceTuple;
341
+ const next=values.map(function normalizeAIPreference(value,index){
342
+ if(value===undefined||value===null||value===''){
343
+ return current[index];
344
+ }
345
+ if(typeof value!=='string'||value.trim()!==value||!value){
346
+ throw new TypeError('AI preferences must be nonempty trimmed strings.');
347
+ }
348
+ return value;
349
+ });
350
+ return Object.freeze(next);
351
+ }
352
+
353
+ #assertValidProviderTuple(tuple){
354
+ if(tuple[0]==='OLLAMA'){
355
+ const mappedModel=tuple[3]==='OPENAI'?null:this.#models[tuple[3]];
356
+ if(!mappedModel&&!normalizeOllamaModelIdentifier(tuple[3])){
357
+ const error=new TypeError('The Ollama model preference is invalid.');
358
+ error.code='AI_MODEL_INVALID';
359
+ throw error;
360
+ }
361
+ }
362
+ }
363
+
364
+ #applyPreferenceTuple(tuple){
365
+ const [
366
+ llmService,
367
+ sttService,
368
+ ttsService,
369
+ model,
370
+ modelTTS,
371
+ modelSTT
372
+ ]=tuple;
373
+ let normalizedLLMModel=model;
374
+ if(llmService==='OLLAMA'){
375
+ const mappedModel=model==='OPENAI'?null:this.#models[model];
376
+ normalizedLLMModel=mappedModel
377
+ ||normalizeOllamaModelIdentifier(model)
378
+ ||model;
379
+ }else if(llmService==='OPENAI'){
380
+ normalizedLLMModel=this.#models.OPENAI;
381
+ }
382
+ this.llmService=llmService;
383
+ this.sttService=sttService;
384
+ this.ttsService=ttsService;
385
+ this.model=normalizedLLMModel;
386
+ this.modelTTS=this.#ttsModels[modelTTS]||modelTTS;
387
+ this.modelSTT=this.#sttModels[modelSTT]||modelSTT;
388
+ this.reasoningEffort='';
389
+ this.#preferenceTuple=Object.freeze(tuple.slice());
390
+ }
391
+
392
+ #tupleFromProviderRoutes(selections){
393
+ const llm=selections.llm.default;
394
+ const stt=selections.stt.default;
395
+ const tts=selections.tts.default;
396
+ return Object.freeze([
397
+ llm?.providerId||'',
398
+ stt?.providerId||'',
399
+ tts?.providerId||'',
400
+ llm?.modelId||'',
401
+ tts?.modelId||'',
402
+ stt?.modelId||''
403
+ ]);
404
+ }
405
+
406
+ #routesFromPreferenceTuple(tuple){
407
+ const roles={
408
+ llm:[tuple[0],tuple[3]],
409
+ stt:[tuple[1],tuple[5]],
410
+ tts:[tuple[2],tuple[4]]
411
+ };
412
+ const selections={};
413
+ for(const role of ['llm','stt','tts']){
414
+ const [providerId,modelId]=roles[role];
415
+ const identity=providerId&&modelId
416
+ ?this.#providerRuntime.providerIdentity(role,providerId)
417
+ :null;
418
+ const pendingNonLegacy=Boolean(
419
+ providerId
420
+ &&modelId
421
+ &&!LEGACY_AI_SERVICES.has(providerId)
422
+ );
423
+ if(!identity&&!pendingNonLegacy){
424
+ selections[role]={default:null,localOnly:null};
425
+ continue;
426
+ }
427
+ const selection={
428
+ providerId,
429
+ modelId,
430
+ localOnly:identity?.localOnly??null
431
+ };
432
+ selections[role]={
433
+ default:selection,
434
+ localOnly:identity?.localOnly===true
435
+ ?{...selection,localOnly:true}
436
+ :null
437
+ };
438
+ }
439
+ return selections;
440
+ }
441
+
442
+ #assertRegisteredLegacyRoutes(selections){
443
+ for(const role of ['llm','stt','tts']){
444
+ for(const routeName of ['default','localOnly']){
445
+ const selection=selections?.[role]?.[routeName];
446
+ if(selection
447
+ &&LEGACY_AI_SERVICES.has(selection.providerId)
448
+ &&!this.#providerRuntime.hasProvider(role,selection.providerId)){
449
+ const error=new Error(
450
+ `Legacy AI provider ${selection.providerId} requires an explicit ${role} adapter before routing.`
451
+ );
452
+ error.code='ARCANE_AI_PROVIDER_UNAVAILABLE';
453
+ throw error;
454
+ }
455
+ }
456
+ }
457
+ }
458
+
459
+ async #unloadProviderRolesForTransition(){
460
+ const settlements=await Promise.allSettled([
461
+ this.#providerRuntime.unload('llm'),
462
+ this.#providerRuntime.unload('stt'),
463
+ this.#providerRuntime.unload('tts')
464
+ ]);
465
+ const failure=settlements.find(function findAITransitionCleanupFailure(result){
466
+ return result.status==='rejected';
467
+ });
468
+ if(failure){
469
+ throw failure.reason;
470
+ }
471
+ }
472
+
285
473
  // Set models to be used by the AI.
286
474
  // Note: Only those that are defined are set.
287
475
  setAI(
@@ -304,29 +492,82 @@ class AI {
304
492
  ) {
305
493
  return false;
306
494
  }
495
+ const tuple=this.#nextPreferenceTuple([
496
+ llmService,
497
+ sttService,
498
+ ttsService,
499
+ model,
500
+ modelTTS,
501
+ modelSTT
502
+ ]);
503
+ this.#assertValidProviderTuple(tuple);
504
+ this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
505
+ this.#applyPreferenceTuple(tuple);
506
+ return true;
507
+ }
307
508
 
308
- this.llmService=llmService;
309
- this.sttService=sttService;
310
- this.ttsService=ttsService;
311
- if(llmService==='OLLAMA'){
312
- const mappedModel=model==='OPENAI'?null:this.#models[model];
313
- this.model=mappedModel||normalizeOllamaModelIdentifier(model)||'';
509
+ configureProviders(selections){
510
+ this.#assertRegisteredLegacyRoutes(selections);
511
+ const configured=this.#providerRuntime.configure(selections);
512
+ this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
513
+ return configured;
514
+ }
314
515
 
315
- if(!this.model){
316
- const error=new TypeError('The Ollama model preference is invalid.');
317
- error.code='AI_MODEL_INVALID';
318
- throw error;
319
- }
320
- }else if(llmService==='OPENAI'){
321
- this.model=this.#models.OPENAI;
322
- }else{
323
- this.model='';
516
+ async transitionAI(
517
+ llmService,
518
+ sttService,
519
+ ttsService,
520
+ model,
521
+ modelTTS,
522
+ modelSTT
523
+ ){
524
+ const tuple=this.#nextPreferenceTuple([
525
+ llmService,
526
+ sttService,
527
+ ttsService,
528
+ model,
529
+ modelTTS,
530
+ modelSTT
531
+ ]);
532
+ this.#assertValidProviderTuple(tuple);
533
+ this.stopAudio();
534
+ await this.#unloadProviderRolesForTransition();
535
+ this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
536
+ this.#applyPreferenceTuple(tuple);
537
+ return this.#providerRuntime.status();
538
+ }
539
+
540
+ async transitionProviders(selections){
541
+ this.#assertRegisteredLegacyRoutes(selections);
542
+ const prepared=this.#providerRuntime.validateConfiguration(selections);
543
+ this.stopAudio();
544
+ await this.#unloadProviderRolesForTransition();
545
+ const configured=this.#providerRuntime.configure(prepared);
546
+ this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
547
+ return configured;
548
+ }
549
+
550
+ async startProviders(options){
551
+ const normalized=normalizeAIStartupOptions(options);
552
+ this.muted=normalized.startMuted;
553
+ if(normalized.startMuted){
554
+ this.stopAudio();
555
+ }
556
+ return this.#providerRuntime.start(normalized);
557
+ }
558
+
559
+ async setSpeechMuted(muted){
560
+ if(typeof muted!=='boolean'){
561
+ throw new TypeError('AI speech muted state must be a boolean.');
562
+ }
563
+ this.muted=muted;
564
+ if(muted){
565
+ this.stopAudio();
566
+ }
567
+ if(!this.#usesProviderRuntime('tts',this.ttsService)){
568
+ return true;
324
569
  }
325
- this.modelTTS=this.#ttsModels[modelTTS];
326
- this.modelTTS=this.#ttsModels[modelTTS];
327
- this.modelSTT=this.#sttModels[modelSTT];
328
- this.reasoningEffort='';
329
-
570
+ await this.#providerRuntime.setSpeechMuted(muted);
330
571
  return true;
331
572
  }
332
573
 
@@ -376,14 +617,19 @@ class AI {
376
617
  :null;
377
618
  }
378
619
 
379
- #nativeSpeech(service){
620
+ #nativeSpeech(service,role){
380
621
  const client=globalThis.Arcane?.speech;
381
622
 
382
- return service==='LOCAL_SPEACH'
383
- &&typeof client?.synthesize==='function'
384
- &&typeof client?.transcribe==='function'
385
- ?client
386
- :null;
623
+ if(service!=='LOCAL_SPEACH'){
624
+ return null;
625
+ }
626
+ if(role==='tts'&&typeof client?.synthesize==='function'){
627
+ return client;
628
+ }
629
+ if(role==='stt'&&typeof client?.transcribe==='function'){
630
+ return client;
631
+ }
632
+ return null;
387
633
  }
388
634
 
389
635
  async #androidNativeHost(){
@@ -589,34 +835,81 @@ class AI {
589
835
  return null;
590
836
  }
591
837
 
592
- #reportRequest(requestHandler,request,id,metadata={}){
838
+ async #reportRequest(requestHandler,request,id){
593
839
  if(typeof requestHandler!=='function'){
594
- throw new TypeError('AI request diagnostics require a function.');
840
+ throw new TypeError('AI onRequest callback must be a function.');
595
841
  }
842
+ await requestHandler(request,id);
843
+ }
596
844
 
597
- try{
598
- Promise.resolve(requestHandler(request,id)).catch(
599
- error=>console.warn('AI request diagnostics failed:',error)
600
- );
601
- }catch(error){
602
- console.warn('AI request diagnostics failed:',error);
845
+ #providerStreamEmissions(chunk,seeThinking){
846
+ const chunks=[];
847
+ const toolNames=[];
848
+ const choices=Array.isArray(chunk?.choices)?chunk.choices:[];
849
+ for(const choice of choices){
850
+ const delta=choice?.delta||{};
851
+ if(seeThinking&&typeof delta.reasoning_content==='string'){
852
+ chunks.push({text:delta.reasoning_content,thinking:true});
853
+ }
854
+ if(typeof delta.content==='string'){
855
+ chunks.push({text:delta.content,thinking:false});
856
+ }
857
+ for(const call of Array.isArray(delta.tool_calls)?delta.tool_calls:[]){
858
+ const name=call?.function?.name;
859
+ if(typeof name==='string'&&name){
860
+ toolNames.push(name);
861
+ }
862
+ }
603
863
  }
604
-
605
- reportAIRequestToConsole({
606
- id,
607
- service:this.llmService,
608
- request,
609
- ...metadata
610
- });
864
+ if(!choices.length){
865
+ if(seeThinking&&typeof chunk?.thinking==='string'){
866
+ chunks.push({text:chunk.thinking,thinking:true});
867
+ }
868
+ const text=typeof chunk?.text==='string'
869
+ ?chunk.text
870
+ :typeof chunk?.content==='string'
871
+ ?chunk.content
872
+ :'';
873
+ if(text){
874
+ chunks.push({text,thinking:false});
875
+ }
876
+ const calls=Array.isArray(chunk?.toolCalls)
877
+ ?chunk.toolCalls
878
+ :Array.isArray(chunk?.tool_calls)
879
+ ?chunk.tool_calls
880
+ :[];
881
+ for(const call of calls){
882
+ const name=call?.function?.name||call?.name;
883
+ if(typeof name==='string'&&name){
884
+ toolNames.push(name);
885
+ }
886
+ }
887
+ }
888
+ return {chunks,toolNames};
611
889
  }
612
890
 
613
- #reportResponse(response,id,metadata={}){
614
- reportAIResponseToConsole({
615
- id,
616
- service:this.llmService,
617
- response,
618
- ...metadata
619
- });
891
+ #providerCompletionOutput(completion){
892
+ if(typeof completion==='string'){
893
+ return completion;
894
+ }
895
+ const toolRecord={};
896
+ let toolCount=0;
897
+ for(const choice of Array.isArray(completion?.choices)?completion.choices:[]){
898
+ for(const call of Array.isArray(choice?.message?.tool_calls)
899
+ ?choice.message.tool_calls
900
+ :[]){
901
+ const name=call?.function?.name;
902
+ if(typeof name==='string'&&name){
903
+ toolRecord[name]=call.function.arguments;
904
+ toolCount+=1;
905
+ }
906
+ }
907
+ }
908
+ if(toolCount){
909
+ return toolRecord;
910
+ }
911
+ const content=completion?.choices?.[0]?.message?.content;
912
+ return typeof content==='string'?content:completion;
620
913
  }
621
914
 
622
915
  #assertRequiredOllamaToolCall(toolCalls=[],toolChoice='auto'){
@@ -701,13 +994,116 @@ class AI {
701
994
  if(localOnly!==true&&localOnly!==false){
702
995
  throw new TypeError('AI localOnly must be a boolean.');
703
996
  }
704
- if(localOnly&&this.llmService!=='OLLAMA'){
997
+ if(localOnly&&!this.#hasLocalRoute('llm',this.llmService)){
705
998
  const error=new Error(
706
999
  'This AI request requires a configured local model.'
707
1000
  );
708
1001
  error.code='AI_LOCAL_MODEL_REQUIRED';
709
1002
  throw error;
710
1003
  }
1004
+ if(this.#shouldUseProviderRuntime('llm',this.llmService,localOnly)){
1005
+ const request={
1006
+ messages,
1007
+ structuredOutput,
1008
+ tools,
1009
+ toolChoice,
1010
+ parallelToolCalls,
1011
+ id,
1012
+ seeThinking
1013
+ };
1014
+ const displayId=`M-${id}`;
1015
+ const announcedTools=new Set();
1016
+ let handle=null;
1017
+ try{
1018
+ if(signal?.aborted){
1019
+ throw normalizeAIRequestAbort();
1020
+ }
1021
+ await this.#reportRequest(onRequest,request,id);
1022
+ if(signal?.aborted){
1023
+ throw normalizeAIRequestAbort();
1024
+ }
1025
+ handle=await this.#providerRuntime.request(
1026
+ 'llm',
1027
+ {
1028
+ operation:'stream',
1029
+ payload:request,
1030
+ localOnly,
1031
+ signal
1032
+ }
1033
+ );
1034
+ for await(const chunk of handle){
1035
+ if(signal?.aborted){
1036
+ throw normalizeAIRequestAbort();
1037
+ }
1038
+ const emissions=this.#providerStreamEmissions(chunk,seeThinking);
1039
+ for(const emission of emissions.chunks){
1040
+ if(signal?.aborted){
1041
+ throw normalizeAIRequestAbort();
1042
+ }
1043
+ await onChunk(
1044
+ emission.text,
1045
+ displayId,
1046
+ emission.thinking
1047
+ );
1048
+ if(signal?.aborted){
1049
+ throw normalizeAIRequestAbort();
1050
+ }
1051
+ }
1052
+ for(const name of emissions.toolNames){
1053
+ if(signal?.aborted){
1054
+ throw normalizeAIRequestAbort();
1055
+ }
1056
+ if(!announcedTools.has(name)){
1057
+ announcedTools.add(name);
1058
+ await onToolCall(name);
1059
+ if(signal?.aborted){
1060
+ throw normalizeAIRequestAbort();
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+ const completion=await handle.result;
1066
+ if(signal?.aborted){
1067
+ throw normalizeAIRequestAbort();
1068
+ }
1069
+ for(const choice of Array.isArray(completion?.choices)
1070
+ ?completion.choices
1071
+ :[]){
1072
+ for(const call of Array.isArray(choice?.message?.tool_calls)
1073
+ ?choice.message.tool_calls
1074
+ :[]){
1075
+ const name=call?.function?.name;
1076
+ if(typeof name==='string'&&name&&!announcedTools.has(name)){
1077
+ if(signal?.aborted){
1078
+ throw normalizeAIRequestAbort();
1079
+ }
1080
+ announcedTools.add(name);
1081
+ await onToolCall(name);
1082
+ if(signal?.aborted){
1083
+ throw normalizeAIRequestAbort();
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ const result=this.#providerCompletionOutput(completion);
1089
+ await onComplete(result,displayId,false);
1090
+ if(signal?.aborted){
1091
+ throw normalizeAIRequestAbort();
1092
+ }
1093
+ this.finishTTS();
1094
+ return result;
1095
+ }catch(error){
1096
+ if(handle){
1097
+ await handle.cancel(error).catch(
1098
+ function retainProviderStreamCleanupFailure() {}
1099
+ );
1100
+ }
1101
+ this.stopAudio();
1102
+ throw isAIRequestAbort(error,signal)
1103
+ ?normalizeAIRequestAbort(error)
1104
+ :error;
1105
+ }
1106
+ }
711
1107
 
712
1108
  return this.streamMessage(
713
1109
  messages,
@@ -739,6 +1135,23 @@ class AI {
739
1135
  requestHandler=function ignoreStreamRequest(){},
740
1136
  structuredOutput=false
741
1137
  ){
1138
+ if(this.#shouldUseProviderRuntime('llm',this.llmService,false)){
1139
+ return this.streamRequest({
1140
+ messages,
1141
+ structuredOutput,
1142
+ localOnly:false,
1143
+ onChunk:streamHandler,
1144
+ onComplete:streamComplete,
1145
+ tools,
1146
+ toolChoice:tool_choice,
1147
+ onToolCall:earlyFunctionTrigger,
1148
+ onRequest:requestHandler,
1149
+ parallelToolCalls:parallel_tool_calls,
1150
+ id,
1151
+ seeThinking,
1152
+ signal
1153
+ });
1154
+ }
742
1155
  let speechTurnCompleted=false;
743
1156
 
744
1157
  try{
@@ -788,7 +1201,6 @@ class AI {
788
1201
  if(nativeOllama){
789
1202
  let nativeContent='';
790
1203
  const nativeToolCalls={};
791
- const nativeResponseChunks=[];
792
1204
  const triggeredTools=new Set();
793
1205
  const ollamaTools=this.#ollamaTools(tools,tool_choice);
794
1206
  const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
@@ -802,7 +1214,7 @@ class AI {
802
1214
  };
803
1215
 
804
1216
  function reportEarlyFunctionFailure(error){
805
- console.error('Early tool trigger failed:',error);
1217
+ console.error('Early tool trigger failed.');
806
1218
  }
807
1219
 
808
1220
  function receiveNativeToolCalls(message={}){
@@ -832,7 +1244,7 @@ class AI {
832
1244
  }
833
1245
  }
834
1246
 
835
- this.#reportRequest(requestHandler,ollamaRequest,id,{
1247
+ await this.#reportRequest(requestHandler,ollamaRequest,id,{
836
1248
  operation:'stream',
837
1249
  transport:'native',
838
1250
  destination:'Arcane.ollama.chat'
@@ -844,7 +1256,6 @@ class AI {
844
1256
  if(signal?.aborted){
845
1257
  return;
846
1258
  }
847
- nativeResponseChunks.push(chunk);
848
1259
  const message=chunk?.message||{};
849
1260
  const thinking=seeThinking
850
1261
  ?String(message.thinking||'')
@@ -866,15 +1277,6 @@ class AI {
866
1277
  signal
867
1278
  }
868
1279
  );
869
- this.#reportResponse(
870
- {chunks:nativeResponseChunks,final:nativeResponse},
871
- id,
872
- {
873
- operation:'stream',
874
- transport:'native',
875
- destination:'Arcane.ollama.chat'
876
- }
877
- );
878
1280
  if(signal?.aborted){
879
1281
  throw normalizeAIRequestAbort();
880
1282
  }
@@ -898,7 +1300,7 @@ class AI {
898
1300
  return nativeResult;
899
1301
  }
900
1302
 
901
- this.#reportRequest(requestHandler,request,id,{
1303
+ await this.#reportRequest(requestHandler,request,id,{
902
1304
  operation:'stream',
903
1305
  transport:'http',
904
1306
  destination:this.url
@@ -921,7 +1323,7 @@ class AI {
921
1323
  if(signal?.aborted||err?.name==='AbortError'){
922
1324
  const error=new Error('The AI request was cancelled.',{cause:err});
923
1325
  error.name='AbortError';
924
- error.code='AI_REQUEST_ABORTED';
1326
+ error.code='ARCANE_AI_REQUEST_ABORTED';
925
1327
  throw error;
926
1328
  }
927
1329
  const error=new Error(
@@ -936,7 +1338,6 @@ class AI {
936
1338
 
937
1339
  let chunkString='';
938
1340
  let chunkCache='';
939
- const responseEvents=[];
940
1341
  const streamedToolCalls=new Map();
941
1342
  const triggeredTools=new Set();
942
1343
  const decoder = new TextDecoder('utf-8');
@@ -969,7 +1370,7 @@ class AI {
969
1370
  Promise.resolve(
970
1371
  earlyFunctionTrigger(record.name)
971
1372
  ).catch(
972
- error=>console.error('Early tool trigger failed:',error)
1373
+ ()=>console.error('Early tool trigger failed.')
973
1374
  );
974
1375
  }
975
1376
  }
@@ -1007,14 +1408,12 @@ class AI {
1007
1408
  chunkCache+=delta;
1008
1409
 
1009
1410
  if (chunkCache.trim() === '[DONE]') {
1010
- responseEvents.push('[DONE]');
1011
1411
  chunkCache = '';
1012
1412
  return;
1013
1413
  }
1014
1414
 
1015
1415
  try{
1016
1416
  const resp=JSON.parse(chunkCache)||{};
1017
- responseEvents.push(resp);
1018
1417
  //console.log(JSON.stringify(resp));
1019
1418
  //console.log(resp)
1020
1419
  const choice = resp.choices?.[0] || {};
@@ -1058,7 +1457,7 @@ class AI {
1058
1457
  receiveStreamedToolCalls(tool_calls);
1059
1458
  }
1060
1459
  } catch(err) {
1061
- console.warn(err);
1460
+ console.warn('AI stream callback failed.');
1062
1461
  }
1063
1462
  }
1064
1463
  );
@@ -1072,12 +1471,6 @@ class AI {
1072
1471
  reader.releaseLock();
1073
1472
  }
1074
1473
 
1075
- this.#reportResponse(responseEvents,id,{
1076
- operation:'stream',
1077
- transport:'http',
1078
- destination:this.url
1079
- });
1080
-
1081
1474
  const tool_funcs={};
1082
1475
  const orderedToolCalls=[...streamedToolCalls.values()].sort(
1083
1476
  function sortStreamedToolCalls(a,b){
@@ -1136,13 +1529,47 @@ class AI {
1136
1529
  if(localOnly!==true&&localOnly!==false){
1137
1530
  throw new TypeError('AI localOnly must be a boolean.');
1138
1531
  }
1139
- if(localOnly&&this.llmService!=='OLLAMA'){
1532
+ if(localOnly&&!this.#hasLocalRoute('llm',this.llmService)){
1140
1533
  const error=new Error(
1141
1534
  'This AI request requires a configured local model.'
1142
1535
  );
1143
1536
  error.code='AI_LOCAL_MODEL_REQUIRED';
1144
1537
  throw error;
1145
1538
  }
1539
+ if(this.#shouldUseProviderRuntime('llm',this.llmService,localOnly)){
1540
+ if(signal?.aborted){
1541
+ throw normalizeAIRequestAbort();
1542
+ }
1543
+ const request={
1544
+ messages,
1545
+ structuredOutput,
1546
+ tools,
1547
+ toolChoice,
1548
+ parallelToolCalls,
1549
+ id
1550
+ };
1551
+ await this.#reportRequest(onRequest,request,id);
1552
+ if(signal?.aborted){
1553
+ throw normalizeAIRequestAbort();
1554
+ }
1555
+ const response=await this.#providerRuntime.request(
1556
+ 'llm',
1557
+ {
1558
+ operation:'chat',
1559
+ payload:request,
1560
+ localOnly,
1561
+ signal
1562
+ }
1563
+ );
1564
+ if(signal?.aborted){
1565
+ throw normalizeAIRequestAbort();
1566
+ }
1567
+ await onResponse(response,id,false);
1568
+ if(signal?.aborted){
1569
+ throw normalizeAIRequestAbort();
1570
+ }
1571
+ return response;
1572
+ }
1146
1573
 
1147
1574
  return this.fetch(
1148
1575
  messages,
@@ -1168,6 +1595,20 @@ class AI {
1168
1595
  requestHandler=function ignoreFetchRequest(){},
1169
1596
  signal=null,
1170
1597
  ){
1598
+ if(this.#shouldUseProviderRuntime('llm',this.llmService,false)){
1599
+ return this.fetchRequest({
1600
+ messages,
1601
+ structuredOutput,
1602
+ localOnly:false,
1603
+ tools,
1604
+ toolChoice:tool_choice,
1605
+ parallelToolCalls:parallel_tool_calls,
1606
+ id,
1607
+ signal,
1608
+ onRequest:requestHandler,
1609
+ onResponse:responseHandler
1610
+ });
1611
+ }
1171
1612
  this.#assertServiceConfigured(this.llmService);
1172
1613
  if(signal&&(
1173
1614
  typeof signal.aborted!=='boolean'
@@ -1215,7 +1656,7 @@ class AI {
1215
1656
  ...(structuredOutputFormat?{format:structuredOutputFormat}:{}),
1216
1657
  ...(ollamaTools.length?{tools:ollamaTools}:{})
1217
1658
  };
1218
- this.#reportRequest(requestHandler,nativeRequest,id,{
1659
+ await this.#reportRequest(requestHandler,nativeRequest,id,{
1219
1660
  operation:'fetch',
1220
1661
  transport:'native',
1221
1662
  destination:'Arcane.ollama.chat'
@@ -1232,11 +1673,6 @@ class AI {
1232
1673
  }
1233
1674
  throw error;
1234
1675
  }
1235
- this.#reportResponse(nativeResponse,id,{
1236
- operation:'fetch',
1237
- transport:'native',
1238
- destination:'Arcane.ollama.chat'
1239
- });
1240
1676
  if(signal?.aborted){
1241
1677
  throw normalizeAIRequestAbort();
1242
1678
  }
@@ -1258,7 +1694,7 @@ class AI {
1258
1694
  return responseJSON;
1259
1695
  }
1260
1696
 
1261
- this.#reportRequest(requestHandler,request,id,{
1697
+ await this.#reportRequest(requestHandler,request,id,{
1262
1698
  operation:'fetch',
1263
1699
  transport:'http',
1264
1700
  destination:this.url
@@ -1309,11 +1745,6 @@ class AI {
1309
1745
  }
1310
1746
  throw error;
1311
1747
  }
1312
- this.#reportResponse(responseJSON,id,{
1313
- operation:'fetch',
1314
- transport:'http',
1315
- destination:this.url
1316
- });
1317
1748
  if(signal?.aborted){
1318
1749
  throw normalizeAIRequestAbort();
1319
1750
  }
@@ -1348,9 +1779,9 @@ class AI {
1348
1779
  }
1349
1780
 
1350
1781
  try{
1351
- this.#assertServiceConfigured(this.ttsService);
1782
+ this.#assertServiceConfigured(this.ttsService,'tts');
1352
1783
  }catch(error){
1353
- console.warn('Error preparing speech from AI:',error);
1784
+ console.warn('AI speech provider is unavailable.');
1354
1785
  return Promise.resolve(false);
1355
1786
  }
1356
1787
 
@@ -1498,7 +1929,27 @@ class AI {
1498
1929
  }
1499
1930
 
1500
1931
  async #requestSpeechAudio(job){
1501
- const nativeSpeech=this.#nativeSpeech(this.ttsService);
1932
+ if(this.#usesProviderRuntime('tts',this.ttsService)){
1933
+ job.abortController=new AbortController();
1934
+ const response=await this.#providerRuntime.request(
1935
+ 'tts',
1936
+ {
1937
+ operation:'synthesize',
1938
+ payload:{
1939
+ model:this.#providerRuntime.selection('tts')?.modelId,
1940
+ voice:String(window.user?.AI_voice||'af_heart'),
1941
+ input:job.text,
1942
+ responseFormat:this.audioFormat,
1943
+ speed:this.voiceSpeed
1944
+ },
1945
+ localOnly:false,
1946
+ signal:job.abortController.signal
1947
+ }
1948
+ );
1949
+ return this.#normalizeProviderSpeechAudio(response);
1950
+ }
1951
+
1952
+ const nativeSpeech=this.#nativeSpeech(this.ttsService,'tts');
1502
1953
 
1503
1954
  if(nativeSpeech){
1504
1955
  const response=await nativeSpeech.synthesize({
@@ -1577,6 +2028,66 @@ class AI {
1577
2028
  return {chunks,type:this.audioType};
1578
2029
  }
1579
2030
 
2031
+ async #normalizeProviderSpeechAudio(response){
2032
+ if(response instanceof Blob){
2033
+ return {
2034
+ chunks:[new Uint8Array(await response.arrayBuffer())],
2035
+ type:response.type||this.audioType
2036
+ };
2037
+ }
2038
+
2039
+ if(response instanceof ArrayBuffer||ArrayBuffer.isView(response)){
2040
+ const bytes=response instanceof ArrayBuffer
2041
+ ?new Uint8Array(response)
2042
+ :new Uint8Array(
2043
+ response.buffer,
2044
+ response.byteOffset,
2045
+ response.byteLength
2046
+ );
2047
+ return {
2048
+ chunks:[bytes],
2049
+ type:this.audioType
2050
+ };
2051
+ }
2052
+
2053
+ if(response&&typeof response==='object'){
2054
+ if(typeof response.audioBase64==='string'){
2055
+ return {
2056
+ chunks:[this.#base64ToBytes(response.audioBase64)],
2057
+ type:typeof response.contentType==='string'
2058
+ ?response.contentType
2059
+ :this.audioType
2060
+ };
2061
+ }
2062
+ if(response.audio instanceof Blob){
2063
+ return {
2064
+ chunks:[new Uint8Array(await response.audio.arrayBuffer())],
2065
+ type:response.audio.type
2066
+ ||response.contentType
2067
+ ||this.audioType
2068
+ };
2069
+ }
2070
+ if(response.audio instanceof ArrayBuffer
2071
+ ||ArrayBuffer.isView(response.audio)){
2072
+ const bytes=response.audio instanceof ArrayBuffer
2073
+ ?new Uint8Array(response.audio)
2074
+ :new Uint8Array(
2075
+ response.audio.buffer,
2076
+ response.audio.byteOffset,
2077
+ response.audio.byteLength
2078
+ );
2079
+ return {
2080
+ chunks:[bytes],
2081
+ type:typeof response.contentType==='string'
2082
+ ?response.contentType
2083
+ :this.audioType
2084
+ };
2085
+ }
2086
+ }
2087
+
2088
+ throw new TypeError('Arcane returned an invalid provider speech response.');
2089
+ }
2090
+
1580
2091
  #getSpeechAudioContext(){
1581
2092
  if(this.audioContext&&this.audioContext.state!=='closed'){
1582
2093
  return this.audioContext;
@@ -1594,11 +2105,50 @@ class AI {
1594
2105
 
1595
2106
  async fetchSTT(
1596
2107
  audioFile,
1597
- responseHandler=(text='')=>{}
2108
+ responseHandler=(text='')=>{},
2109
+ signal=null
1598
2110
  ){
1599
- this.#assertServiceConfigured(this.sttService);
2111
+ this.#assertServiceConfigured(this.sttService,'stt');
2112
+ if(signal&&(
2113
+ typeof signal.aborted!=='boolean'
2114
+ ||typeof signal.addEventListener!=='function'
2115
+ )){
2116
+ throw new TypeError('AI request signal must be an AbortSignal.');
2117
+ }
2118
+ if(signal?.aborted){
2119
+ throw normalizeAIRequestAbort();
2120
+ }
2121
+
2122
+ if(this.#usesProviderRuntime('stt',this.sttService)){
2123
+ if(!audioFile||typeof audioFile.arrayBuffer!=='function'){
2124
+ throw new TypeError('Speech transcription requires an audio Blob or File.');
2125
+ }
2126
+ const response=await this.#providerRuntime.request(
2127
+ 'stt',
2128
+ {
2129
+ operation:'transcribe',
2130
+ payload:{
2131
+ audio:audioFile,
2132
+ mimeType:String(audioFile.type||'audio/webm'),
2133
+ model:this.#providerRuntime.selection('stt')?.modelId
2134
+ },
2135
+ localOnly:false,
2136
+ signal
2137
+ }
2138
+ );
2139
+ const text=typeof response==='string'
2140
+ ?response
2141
+ :response?.text;
2142
+ if(typeof text!=='string'){
2143
+ throw new TypeError(
2144
+ 'Arcane returned an invalid provider speech transcription.'
2145
+ );
2146
+ }
2147
+ await responseHandler(text);
2148
+ return text;
2149
+ }
1600
2150
 
1601
- const nativeSpeech=this.#nativeSpeech(this.sttService);
2151
+ const nativeSpeech=this.#nativeSpeech(this.sttService,'stt');
1602
2152
 
1603
2153
  if(nativeSpeech){
1604
2154
  if(!audioFile||typeof audioFile.arrayBuffer!=='function'){
@@ -1632,7 +2182,8 @@ class AI {
1632
2182
  method: 'POST',
1633
2183
  credentials: credentials,
1634
2184
  headers: this.#sttHeaders[this.sttService],
1635
- body: formData
2185
+ body: formData,
2186
+ signal
1636
2187
  }
1637
2188
  );
1638
2189
 
@@ -1672,7 +2223,7 @@ class AI {
1672
2223
  try{
1673
2224
  sourceNode.stop();
1674
2225
  }catch(error){
1675
- console.warn('Error stopping AI audio:',error);
2226
+ console.warn('AI audio could not be stopped cleanly.');
1676
2227
  }
1677
2228
  }
1678
2229
 
@@ -1798,7 +2349,7 @@ class AI {
1798
2349
  #requestSpeechPlayback(){
1799
2350
  this.#pumpSpeechPlayback().catch(
1800
2351
  function reportSpeechPlaybackFailure(error){
1801
- console.warn('Error playing audio data:',error);
2352
+ console.warn('AI audio playback failed.');
1802
2353
  }
1803
2354
  );
1804
2355
  }
@@ -1925,7 +2476,7 @@ class AI {
1925
2476
  }
1926
2477
 
1927
2478
  if(job.generation===this.speechGeneration&&error?.name!=='AbortError'){
1928
- console.warn('Error preparing audio from AI:',error);
2479
+ console.warn('AI speech synthesis failed.');
1929
2480
  }
1930
2481
 
1931
2482
  this.#requestSpeechPlayback();
@@ -1974,7 +2525,7 @@ class AI {
1974
2525
  );
1975
2526
 
1976
2527
  if(error?.name&&error.name!=='NotAllowedError'){
1977
- console.info('AI speech is waiting for audio playback permission:',error);
2528
+ console.info('AI speech is waiting for audio playback permission.');
1978
2529
  }
1979
2530
 
1980
2531
  return true;