arcane-os 0.1.2 → 0.2.1

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 (54) hide show
  1. package/CHANGELOG.md +35 -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 +780 -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 +551 -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 +1394 -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 +382 -31
  37. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +1106 -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 +10 -6
  42. package/src/cli/main.mjs +14 -2
  43. package/src/constants.mjs +1 -1
  44. package/src/dev-server.mjs +273 -26
  45. package/src/doctor.mjs +1 -3
  46. package/src/import-map.mjs +193 -84
  47. package/src/packager/core.mjs +313 -41
  48. package/src/runtime.mjs +14 -4
  49. package/src/scaffold.mjs +45 -17
  50. package/src/sdk-browser-runtime.mjs +28 -75
  51. package/src/templates/workspace-template.mjs +27 -8
  52. package/src/toolchain.mjs +13 -2
  53. package/src/workspace-runtime.mjs +1 -1
  54. package/src/workspace.mjs +178 -25
@@ -1,79 +1,219 @@
1
1
  import './DBOPFS.js';
2
2
  import UserEntity from '../entities/User.js';
3
3
  import {getAIPreferencesForRuntime} from './AIPreferenceRuntime.js';
4
+ import {
5
+ AI_MODEL_AUTHORITY_PROTOCOL,
6
+ AI_PROVIDER_PROTOCOL,
7
+ getAIProviderRuntime
8
+ } from './AIProviderRuntime.js';
4
9
  import {normalizeOllamaModelIdentifier} from './OllamaModelIdentifier.js';
5
10
 
6
11
  let credentials='include';
12
+ const LEGACY_TTS_RESPONSE_FORMAT='opus';
7
13
  credentials='omit';
8
14
 
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
- }
15
+ const LEGACY_AI_SERVICES=new Set(['OPENAI','OLLAMA','LOCAL_SPEACH']);
59
16
 
60
17
  function isAIRequestAbort(error,signal){
61
18
  return signal?.aborted
62
19
  ||error?.name==='AbortError'
63
20
  ||error?.code==='ARCANE_REQUEST_ABORTED'
21
+ ||error?.code==='ARCANE_AI_REQUEST_ABORTED'
64
22
  ||error?.code==='AI_REQUEST_ABORTED';
65
23
  }
66
24
 
67
25
  function normalizeAIRequestAbort(error){
68
- if(error?.code==='AI_REQUEST_ABORTED'){
26
+ if(error?.code==='ARCANE_AI_REQUEST_ABORTED'){
69
27
  return error;
70
28
  }
71
29
  const normalized=new Error('The AI request was cancelled.',{cause:error});
72
30
  normalized.name='AbortError';
73
- normalized.code='AI_REQUEST_ABORTED';
31
+ normalized.code='ARCANE_AI_REQUEST_ABORTED';
74
32
  return normalized;
75
33
  }
76
34
 
35
+ function legacyAIProviderError(message,code,cause){
36
+ const error=cause===undefined
37
+ ?new Error(message)
38
+ :new Error(message,{cause});
39
+ error.code=code;
40
+ return error;
41
+ }
42
+
43
+ function createLegacyAIStreamBridge(execute,sourceSignal){
44
+ const controller=new AbortController();
45
+ const queue=[];
46
+ const waiters=[];
47
+ let complete=false;
48
+ let failure=null;
49
+ let detached=false;
50
+
51
+ function forwardLegacyAIStreamAbort(){
52
+ if(!controller.signal.aborted){
53
+ controller.abort(sourceSignal?.reason);
54
+ }
55
+ }
56
+
57
+ function detachLegacyAIStreamAbort(){
58
+ if(detached){
59
+ return;
60
+ }
61
+ detached=true;
62
+ sourceSignal?.removeEventListener?.(
63
+ 'abort',
64
+ forwardLegacyAIStreamAbort
65
+ );
66
+ }
67
+
68
+ if(sourceSignal?.aborted){
69
+ forwardLegacyAIStreamAbort();
70
+ }else{
71
+ sourceSignal?.addEventListener?.(
72
+ 'abort',
73
+ forwardLegacyAIStreamAbort,
74
+ {once:true}
75
+ );
76
+ }
77
+
78
+ function emitLegacyAIStreamChunk(chunk){
79
+ if(complete){
80
+ return false;
81
+ }
82
+ const waiter=waiters.shift();
83
+ if(waiter){
84
+ waiter.resolve({value:chunk,done:false});
85
+ }else{
86
+ queue.push(chunk);
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function finishLegacyAIStream(error){
92
+ if(complete){
93
+ return;
94
+ }
95
+ complete=true;
96
+ failure=error||null;
97
+ detachLegacyAIStreamAbort();
98
+ while(waiters.length){
99
+ const waiter=waiters.shift();
100
+ if(failure){
101
+ waiter.reject(failure);
102
+ }else{
103
+ waiter.resolve({value:undefined,done:true});
104
+ }
105
+ }
106
+ }
107
+
108
+ const result=Promise.resolve().then(
109
+ function executeLegacyAIStream(){
110
+ if(controller.signal.aborted){
111
+ throw normalizeAIRequestAbort(controller.signal.reason);
112
+ }
113
+ return execute({
114
+ emit:emitLegacyAIStreamChunk,
115
+ signal:controller.signal
116
+ });
117
+ }
118
+ ).then(
119
+ function acceptLegacyAIStreamResult(value){
120
+ finishLegacyAIStream(null);
121
+ return value;
122
+ },
123
+ function rejectLegacyAIStreamResult(error){
124
+ const normalized=isAIRequestAbort(error,controller.signal)
125
+ ?normalizeAIRequestAbort(error)
126
+ :error;
127
+ finishLegacyAIStream(normalized);
128
+ throw normalized;
129
+ }
130
+ );
131
+ result.catch(function retainLegacyAIStreamFailure() {});
132
+
133
+ async function cancelLegacyAIStream(reason){
134
+ if(!controller.signal.aborted){
135
+ controller.abort(reason);
136
+ }
137
+ await result.catch(function retainCancelledLegacyAIStream() {});
138
+ return true;
139
+ }
140
+
141
+ const handle={
142
+ result,
143
+ cancel:cancelLegacyAIStream,
144
+ next:function readLegacyAIStreamChunk(){
145
+ if(queue.length){
146
+ return Promise.resolve({value:queue.shift(),done:false});
147
+ }
148
+ if(complete){
149
+ return failure
150
+ ?Promise.reject(failure)
151
+ :Promise.resolve({value:undefined,done:true});
152
+ }
153
+ return new Promise(function waitForLegacyAIStreamChunk(resolve,reject){
154
+ waiters.push({resolve,reject});
155
+ });
156
+ },
157
+ return:async function returnLegacyAIStream(value){
158
+ await cancelLegacyAIStream(
159
+ legacyAIProviderError(
160
+ 'The legacy AI stream consumer stopped before completion.',
161
+ 'ARCANE_AI_REQUEST_ABORTED'
162
+ )
163
+ );
164
+ return {value,done:true};
165
+ },
166
+ throw:async function throwLegacyAIStream(error){
167
+ await cancelLegacyAIStream(error);
168
+ throw error;
169
+ },
170
+ [Symbol.asyncIterator]:function iterateLegacyAIStream(){
171
+ return this;
172
+ }
173
+ };
174
+ return Object.freeze(handle);
175
+ }
176
+
177
+ function normalizeAIStartupOptions(options){
178
+ if(options===undefined){
179
+ return Object.freeze({startMuted:true,signal:null});
180
+ }
181
+ if(!options||typeof options!=='object'||Array.isArray(options)){
182
+ throw new TypeError('AI startup options must be a plain object.');
183
+ }
184
+ const prototype=Object.getPrototypeOf(options);
185
+ if(prototype!==Object.prototype&&prototype!==null){
186
+ throw new TypeError('AI startup options must be a plain object.');
187
+ }
188
+ const descriptors=Object.getOwnPropertyDescriptors(options);
189
+ for(const key of Reflect.ownKeys(descriptors)){
190
+ if(typeof key==='symbol'||(key!=='startMuted'&&key!=='signal')){
191
+ throw new TypeError('AI startup options contain an unknown option.');
192
+ }
193
+ if(!Object.hasOwn(descriptors[key],'value')){
194
+ throw new TypeError(`AI startup options.${key} must be a data property.`);
195
+ }
196
+ }
197
+ const startMuted=Object.hasOwn(descriptors,'startMuted')
198
+ ?descriptors.startMuted.value
199
+ :true;
200
+ const signal=Object.hasOwn(descriptors,'signal')
201
+ ?descriptors.signal.value
202
+ :null;
203
+ if(typeof startMuted!=='boolean'){
204
+ throw new TypeError('AI startup startMuted must be a boolean.');
205
+ }
206
+ if(signal!==null&&signal!==undefined&&(
207
+ typeof signal!=='object'
208
+ ||typeof signal.aborted!=='boolean'
209
+ ||typeof signal.addEventListener!=='function'
210
+ ||typeof signal.removeEventListener!=='function'
211
+ )){
212
+ throw new TypeError('AI startup signal must be an AbortSignal.');
213
+ }
214
+ return Object.freeze({startMuted,signal});
215
+ }
216
+
77
217
  class AI {
78
218
  // This is the enum section for inference configuration
79
219
  #service = {
@@ -155,7 +295,7 @@ class AI {
155
295
  }
156
296
 
157
297
  ready=false;
158
- muted=false;
298
+ muted=true;
159
299
 
160
300
 
161
301
  llmService = '';
@@ -186,16 +326,45 @@ class AI {
186
326
  return window.ai;
187
327
  }
188
328
 
329
+ const preferences=[
330
+ llmService||'OPENAI',
331
+ sttService||'OPENAI',
332
+ ttsService||'OPENAI',
333
+ model||'OPENAI',
334
+ modelTTS||'OPENAI',
335
+ modelSTT||'OPENAI'
336
+ ];
189
337
  this.setAI(
190
- llmService || 'OPENAI',
191
- sttService || 'OPENAI',
192
- ttsService || 'OPENAI',
193
- model || 'OPENAI',
194
- modelTTS || 'OPENAI',
195
- modelSTT || 'OPENAI'
338
+ ...preferences
339
+ );
340
+
341
+ const runtime=this;
342
+ globalThis.addEventListener?.(
343
+ 'arcane-ollama-ready',
344
+ function reconcileLegacyOllamaReadiness(){
345
+ runtime.#retainLegacyLLMReadiness(
346
+ runtime.#reconcileLegacyLLMReadiness()
347
+ );
348
+ }
196
349
  );
197
350
  }
198
351
 
352
+ #providerRuntime=getAIProviderRuntime();
353
+ #legacyLLMProviders=new Map();
354
+ #legacyLLMReadiness=Promise.resolve(null);
355
+ #preferenceTuple=Object.freeze([
356
+ 'OPENAI',
357
+ 'OPENAI',
358
+ 'OPENAI',
359
+ 'OPENAI',
360
+ 'OPENAI',
361
+ 'OPENAI'
362
+ ]);
363
+
364
+ get providerRuntime(){
365
+ return this.#providerRuntime;
366
+ }
367
+
199
368
  get url() {
200
369
  return `${this.#service.baseURL[this.llmService]}${this.#paths.chat[this.llmService]}`
201
370
  }
@@ -230,10 +399,314 @@ class AI {
230
399
 
231
400
  set license(value){
232
401
  this.#license=typeof value==='string' ? value.trim():'';
402
+ this.#retainLegacyLLMReadiness(
403
+ this.#reconcileLegacyLLMReadiness()
404
+ );
233
405
  return this.#license;
234
406
  }
235
407
 
408
+ #legacyLLMCapability(providerId){
409
+ if(providerId==='OPENAI'){
410
+ return this.llmService==='OPENAI'
411
+ &&Boolean(this.model)
412
+ &&Boolean(this.license)
413
+ &&typeof globalThis.fetch==='function';
414
+ }
415
+ if(providerId==='OLLAMA'){
416
+ return this.llmService==='OLLAMA'
417
+ &&Boolean(this.model)
418
+ &&Boolean(this.#nativeOllama());
419
+ }
420
+ return false;
421
+ }
422
+
423
+ #legacyLLMInspection(providerId,selection){
424
+ const localOnly=providerId==='OLLAMA';
425
+ if(!selection
426
+ ||selection.providerId!==providerId
427
+ ||selection.modelId!==this.model
428
+ ||selection.localOnly!==localOnly
429
+ ||this.llmService!==providerId){
430
+ return Object.freeze({
431
+ available:false,
432
+ code:'ARCANE_AI_MODEL_AUTHORITY_REQUIRED',
433
+ message:'The selected legacy LLM route does not match the active AI configuration.'
434
+ });
435
+ }
436
+ if(!this.#legacyLLMCapability(providerId)){
437
+ return Object.freeze({
438
+ available:false,
439
+ code:providerId==='OLLAMA'
440
+ ?'AI_NATIVE_LOCAL_REQUIRED'
441
+ :'AI_PROVIDER_NOT_CONFIGURED',
442
+ message:providerId==='OLLAMA'
443
+ ?'Local AI requires the capability-gated Arcane API.'
444
+ :'AI provider is not configured.'
445
+ });
446
+ }
447
+ return Object.freeze({
448
+ available:true,
449
+ authority:Object.freeze({
450
+ protocol:AI_MODEL_AUTHORITY_PROTOCOL,
451
+ providerId,
452
+ modelId:selection.modelId,
453
+ admitted:true
454
+ })
455
+ });
456
+ }
457
+
458
+ #createLegacyLLMProvider(providerId){
459
+ const runtime=this;
460
+ const localOnly=providerId==='OLLAMA';
461
+ let state='unloaded';
462
+ let busy=false;
463
+
464
+ function statusLegacyLLMProvider(){
465
+ if(state==='ready'
466
+ &&!busy
467
+ &&!runtime.#legacyLLMCapability(providerId)){
468
+ state='unloaded';
469
+ }
470
+ return Object.freeze({
471
+ state,
472
+ loaded:state==='ready',
473
+ busy
474
+ });
475
+ }
476
+
477
+ function assertLegacyLLMSelection(selection){
478
+ const inspection=runtime.#legacyLLMInspection(
479
+ providerId,
480
+ selection
481
+ );
482
+ if(!inspection.available){
483
+ throw legacyAIProviderError(
484
+ inspection.message,
485
+ inspection.code
486
+ );
487
+ }
488
+ return inspection;
489
+ }
490
+
491
+ function releaseLegacyLLMRequest(){
492
+ busy=false;
493
+ }
494
+
495
+ return Object.freeze({
496
+ protocol:AI_PROVIDER_PROTOCOL,
497
+ role:'llm',
498
+ id:providerId,
499
+ localOnly,
500
+ catalog:function catalogLegacyLLMProvider(){
501
+ if(runtime.llmService!==providerId||!runtime.model){
502
+ return Object.freeze([]);
503
+ }
504
+ return Object.freeze([
505
+ Object.freeze({id:runtime.model})
506
+ ]);
507
+ },
508
+ inspect:function inspectLegacyLLMProvider(selection,{signal}={}){
509
+ if(signal?.aborted){
510
+ throw normalizeAIRequestAbort(signal.reason);
511
+ }
512
+ return runtime.#legacyLLMInspection(providerId,selection);
513
+ },
514
+ status:statusLegacyLLMProvider,
515
+ load:function loadLegacyLLMProvider(context={}){
516
+ if(context.signal?.aborted){
517
+ throw normalizeAIRequestAbort(context.signal.reason);
518
+ }
519
+ if(state==='disposed'){
520
+ throw legacyAIProviderError(
521
+ 'The legacy LLM provider is disposed.',
522
+ 'ARCANE_AI_PROVIDER_DISPOSED'
523
+ );
524
+ }
525
+ if(busy){
526
+ throw legacyAIProviderError(
527
+ 'The legacy LLM provider owns an active request.',
528
+ 'ARCANE_AI_ROLE_BUSY'
529
+ );
530
+ }
531
+ if(typeof context.progress!=='function'){
532
+ throw new TypeError(
533
+ 'Legacy LLM provider load progress must be a function.'
534
+ );
535
+ }
536
+ const inspection=assertLegacyLLMSelection(context.selection);
537
+ state='loading';
538
+ context.progress({
539
+ phase:'capability',
540
+ completed:0,
541
+ total:1,
542
+ unit:'items',
543
+ heartbeat:false
544
+ });
545
+ if(context.signal?.aborted){
546
+ state='unloaded';
547
+ throw normalizeAIRequestAbort(context.signal.reason);
548
+ }
549
+ state='ready';
550
+ context.progress({
551
+ phase:'capability',
552
+ completed:1,
553
+ total:1,
554
+ unit:'items',
555
+ heartbeat:false
556
+ });
557
+ return Object.freeze({
558
+ authority:inspection.authority,
559
+ status:statusLegacyLLMProvider()
560
+ });
561
+ },
562
+ request:function requestLegacyLLMProvider(context={}){
563
+ if(context.signal?.aborted){
564
+ throw normalizeAIRequestAbort(context.signal.reason);
565
+ }
566
+ assertLegacyLLMSelection(context.selection);
567
+ const current=statusLegacyLLMProvider();
568
+ if(current.state!=='ready'||!current.loaded){
569
+ throw legacyAIProviderError(
570
+ 'The legacy LLM provider is not ready.',
571
+ 'ARCANE_AI_ROLE_NOT_READY'
572
+ );
573
+ }
574
+ if(busy){
575
+ throw legacyAIProviderError(
576
+ 'The legacy LLM provider owns an active request.',
577
+ 'ARCANE_AI_ROLE_BUSY'
578
+ );
579
+ }
580
+ busy=true;
581
+ if(context.operation==='chat'){
582
+ return Promise.resolve(
583
+ runtime.#requestLegacyLLMChat(
584
+ context.payload,
585
+ context.signal
586
+ )
587
+ ).finally(releaseLegacyLLMRequest);
588
+ }
589
+ if(context.operation==='stream'){
590
+ const handle=createLegacyAIStreamBridge(
591
+ function executeLegacyLLMProviderStream(bridge){
592
+ return runtime.#requestLegacyLLMStream(
593
+ context.payload,
594
+ bridge
595
+ );
596
+ },
597
+ context.signal
598
+ );
599
+ handle.result.then(
600
+ releaseLegacyLLMRequest,
601
+ releaseLegacyLLMRequest
602
+ );
603
+ return handle;
604
+ }
605
+ busy=false;
606
+ throw legacyAIProviderError(
607
+ 'The legacy LLM provider operation is unsupported.',
608
+ 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
609
+ );
610
+ },
611
+ unload:function unloadLegacyLLMProvider(context={}){
612
+ if(context.signal?.aborted){
613
+ throw normalizeAIRequestAbort(context.signal.reason);
614
+ }
615
+ state='unloaded';
616
+ busy=false;
617
+ return statusLegacyLLMProvider();
618
+ },
619
+ dispose:function disposeLegacyLLMProvider(context={}){
620
+ if(context.signal?.aborted){
621
+ throw normalizeAIRequestAbort(context.signal.reason);
622
+ }
623
+ state='disposed';
624
+ busy=false;
625
+ return statusLegacyLLMProvider();
626
+ }
627
+ });
628
+ }
629
+
630
+ #ensureLegacyLLMProvider(providerId){
631
+ if(providerId!=='OPENAI'&&providerId!=='OLLAMA'){
632
+ return false;
633
+ }
634
+ if(this.#providerRuntime.hasProvider('llm',providerId)){
635
+ return false;
636
+ }
637
+ const provider=this.#createLegacyLLMProvider(providerId);
638
+ const unregister=this.#providerRuntime.register(provider);
639
+ this.#legacyLLMProviders.set(
640
+ providerId,
641
+ Object.freeze({provider,unregister})
642
+ );
643
+ return true;
644
+ }
645
+
646
+ #releaseInactiveLegacyLLMProviders(activeProviderId){
647
+ for(const [providerId,record] of this.#legacyLLMProviders){
648
+ if(providerId===activeProviderId){
649
+ continue;
650
+ }
651
+ if(record.unregister()){
652
+ this.#legacyLLMProviders.delete(providerId);
653
+ }
654
+ }
655
+ }
656
+
657
+ #internalLegacyLLMSelection(localOnly=false){
658
+ const selection=this.#providerRuntime.selection(
659
+ 'llm',
660
+ {localOnly}
661
+ );
662
+ if(!selection
663
+ ||!this.#legacyLLMProviders.has(selection.providerId)
664
+ ||selection.providerId!==this.llmService
665
+ ||selection.modelId!==this.model){
666
+ return null;
667
+ }
668
+ return selection;
669
+ }
670
+
671
+ #retainLegacyLLMReadiness(operation){
672
+ this.#legacyLLMReadiness=Promise.resolve(operation).catch(
673
+ function retainLegacyLLMReadinessFailure(){
674
+ return null;
675
+ }
676
+ );
677
+ return this.#legacyLLMReadiness;
678
+ }
679
+
680
+ #reconcileLegacyLLMReadiness(){
681
+ const selection=this.#internalLegacyLLMSelection(false);
682
+ if(!selection){
683
+ return Promise.resolve(this.#providerRuntime.status('llm'));
684
+ }
685
+ const status=this.#providerRuntime.status('llm');
686
+ if(this.#legacyLLMCapability(selection.providerId)){
687
+ if(status.state==='ready'&&status.loaded===true){
688
+ return Promise.resolve(status);
689
+ }
690
+ return this.#providerRuntime.load('llm');
691
+ }
692
+ if(status.loaded===true
693
+ ||status.busy===true
694
+ ||status.state==='loading'
695
+ ||status.state==='unloading'){
696
+ return this.#providerRuntime.unload('llm');
697
+ }
698
+ return Promise.resolve(status);
699
+ }
700
+
236
701
  get configured(){
702
+ if(this.#usesProviderRuntime('llm',this.llmService)){
703
+ if(this.#internalLegacyLLMSelection(false)
704
+ &&!this.#legacyLLMCapability(this.llmService)){
705
+ return false;
706
+ }
707
+ const state=this.#providerRuntime.status('llm');
708
+ return state.state==='ready'&&state.loaded===true;
709
+ }
237
710
  if(this.llmService==='OLLAMA'){
238
711
  return Boolean(this.model)&&Boolean(this.#nativeOllama());
239
712
  }
@@ -243,9 +716,25 @@ class AI {
243
716
  &&Boolean(this.license);
244
717
  }
245
718
 
246
- #assertServiceConfigured(service=this.llmService){
719
+ #assertServiceConfigured(service=this.llmService,role='llm'){
720
+ if(this.#usesProviderRuntime(role,service)){
721
+ const internal=role==='llm'
722
+ ?this.#internalLegacyLLMSelection(false)
723
+ :null;
724
+ if(internal&&!this.#legacyLLMCapability(internal.providerId)){
725
+ const inspection=this.#legacyLLMInspection(
726
+ internal.providerId,
727
+ internal
728
+ );
729
+ throw legacyAIProviderError(
730
+ inspection.message,
731
+ inspection.code
732
+ );
733
+ }
734
+ return true;
735
+ }
247
736
  if(service==='OLLAMA'){
248
- if(this.#nativeOllama()){
737
+ if(role==='llm'&&this.#nativeOllama()){
249
738
  return true;
250
739
  }
251
740
 
@@ -255,8 +744,16 @@ class AI {
255
744
  error.code='AI_NATIVE_LOCAL_REQUIRED';
256
745
  throw error;
257
746
  }
258
- if(service&&service!=='OPENAI'){
259
- return true;
747
+ if(service==='LOCAL_SPEACH'){
748
+ if(this.#nativeSpeech(service,role)){
749
+ return true;
750
+ }
751
+
752
+ const error=new Error(
753
+ `Local ${role.toUpperCase()} requires the capability-gated Arcane API.`
754
+ );
755
+ error.code='AI_NATIVE_LOCAL_REQUIRED';
756
+ throw error;
260
757
  }
261
758
 
262
759
  if(service==='OPENAI'&&this.license){
@@ -268,6 +765,40 @@ class AI {
268
765
  throw error;
269
766
  }
270
767
 
768
+ #usesProviderRuntime(role,service){
769
+ return Boolean(this.#providerRuntime.selection(role));
770
+ }
771
+
772
+ #shouldUseProviderRuntime(role,service,localOnly=false){
773
+ // Legacy adapters publish lifecycle without replacing established
774
+ // public transport callbacks or their cancellation behavior.
775
+ if(role==='llm'&&this.#internalLegacyLLMSelection(localOnly)){
776
+ return false;
777
+ }
778
+ if(!localOnly){
779
+ return this.#usesProviderRuntime(role,service);
780
+ }
781
+ const selection=this.#providerRuntime.selection(
782
+ role,
783
+ {localOnly:true}
784
+ );
785
+ return Boolean(selection);
786
+ }
787
+
788
+ #hasLocalRoute(role,service){
789
+ const selection=this.#providerRuntime.selection(
790
+ role,
791
+ {localOnly:true}
792
+ );
793
+ if(selection){
794
+ return selection.localOnly===true;
795
+ }
796
+ if(this.#providerRuntime.selection(role)){
797
+ return false;
798
+ }
799
+ return role==='llm'&&service==='OLLAMA';
800
+ }
801
+
271
802
  audioMessageChunks='';
272
803
  sourceNodes=[];
273
804
  isSpeaking=false;
@@ -282,6 +813,151 @@ class AI {
282
813
  speechSynthesisTail=Promise.resolve();
283
814
  speechUnlockHandler=null;
284
815
 
816
+ #nextPreferenceTuple(values){
817
+ const current=this.#preferenceTuple;
818
+ const next=values.map(function normalizeAIPreference(value,index){
819
+ if(value===undefined||value===null||value===''){
820
+ return current[index];
821
+ }
822
+ if(typeof value!=='string'||value.trim()!==value||!value){
823
+ throw new TypeError('AI preferences must be nonempty trimmed strings.');
824
+ }
825
+ return value;
826
+ });
827
+ return Object.freeze(next);
828
+ }
829
+
830
+ #assertValidProviderTuple(tuple){
831
+ if(tuple[0]==='OLLAMA'){
832
+ const mappedModel=tuple[3]==='OPENAI'?null:this.#models[tuple[3]];
833
+ if(!mappedModel&&!normalizeOllamaModelIdentifier(tuple[3])){
834
+ const error=new TypeError('The Ollama model preference is invalid.');
835
+ error.code='AI_MODEL_INVALID';
836
+ throw error;
837
+ }
838
+ }
839
+ }
840
+
841
+ #normalizedLLMModel(service,model){
842
+ if(service==='OLLAMA'){
843
+ const mappedModel=model==='OPENAI'?null:this.#models[model];
844
+ return mappedModel
845
+ ||normalizeOllamaModelIdentifier(model)
846
+ ||model;
847
+ }
848
+ if(service==='OPENAI'){
849
+ return this.#models.OPENAI;
850
+ }
851
+ return model;
852
+ }
853
+
854
+ #applyPreferenceTuple(tuple){
855
+ const [
856
+ llmService,
857
+ sttService,
858
+ ttsService,
859
+ model,
860
+ modelTTS,
861
+ modelSTT
862
+ ]=tuple;
863
+ const normalizedLLMModel=this.#normalizedLLMModel(
864
+ llmService,
865
+ model
866
+ );
867
+ this.llmService=llmService;
868
+ this.sttService=sttService;
869
+ this.ttsService=ttsService;
870
+ this.model=normalizedLLMModel;
871
+ this.modelTTS=this.#ttsModels[modelTTS]||modelTTS;
872
+ this.modelSTT=this.#sttModels[modelSTT]||modelSTT;
873
+ this.reasoningEffort='';
874
+ this.#preferenceTuple=Object.freeze(tuple.slice());
875
+ }
876
+
877
+ #tupleFromProviderRoutes(selections){
878
+ const llm=selections.llm.default;
879
+ const stt=selections.stt.default;
880
+ const tts=selections.tts.default;
881
+ return Object.freeze([
882
+ llm?.providerId||'',
883
+ stt?.providerId||'',
884
+ tts?.providerId||'',
885
+ llm?.modelId||'',
886
+ tts?.modelId||'',
887
+ stt?.modelId||''
888
+ ]);
889
+ }
890
+
891
+ #routesFromPreferenceTuple(tuple){
892
+ const roles={
893
+ llm:[
894
+ tuple[0],
895
+ this.#normalizedLLMModel(tuple[0],tuple[3])
896
+ ],
897
+ stt:[tuple[1],tuple[5]],
898
+ tts:[tuple[2],tuple[4]]
899
+ };
900
+ const selections={};
901
+ for(const role of ['llm','stt','tts']){
902
+ const [providerId,modelId]=roles[role];
903
+ const identity=providerId&&modelId
904
+ ?this.#providerRuntime.providerIdentity(role,providerId)
905
+ :null;
906
+ const pendingNonLegacy=Boolean(
907
+ providerId
908
+ &&modelId
909
+ &&!LEGACY_AI_SERVICES.has(providerId)
910
+ );
911
+ if(!identity&&!pendingNonLegacy){
912
+ selections[role]={default:null,localOnly:null};
913
+ continue;
914
+ }
915
+ const selection={
916
+ providerId,
917
+ modelId,
918
+ localOnly:identity?.localOnly??null
919
+ };
920
+ selections[role]={
921
+ default:selection,
922
+ localOnly:identity?.localOnly===true
923
+ ?{...selection,localOnly:true}
924
+ :null
925
+ };
926
+ }
927
+ return selections;
928
+ }
929
+
930
+ #assertRegisteredLegacyRoutes(selections){
931
+ for(const role of ['llm','stt','tts']){
932
+ for(const routeName of ['default','localOnly']){
933
+ const selection=selections?.[role]?.[routeName];
934
+ if(selection
935
+ &&LEGACY_AI_SERVICES.has(selection.providerId)
936
+ &&!this.#providerRuntime.hasProvider(role,selection.providerId)){
937
+ const error=new Error(
938
+ `Legacy AI provider ${selection.providerId} requires an explicit ${role} adapter before routing.`
939
+ );
940
+ error.code='ARCANE_AI_PROVIDER_UNAVAILABLE';
941
+ throw error;
942
+ }
943
+ }
944
+ }
945
+ }
946
+
947
+ async #unloadProviderRolesForTransition(){
948
+ const settlements=await Promise.allSettled([
949
+ this.#providerRuntime.unload('llm'),
950
+ this.#providerRuntime.unload('stt'),
951
+ this.#providerRuntime.unload('tts')
952
+ ]);
953
+ const failure=settlements.find(function findAITransitionCleanupFailure(result){
954
+ return result.status==='rejected';
955
+ });
956
+ if(failure){
957
+ throw failure.reason;
958
+ }
959
+ }
960
+
285
961
  // Set models to be used by the AI.
286
962
  // Note: Only those that are defined are set.
287
963
  setAI(
@@ -304,29 +980,107 @@ class AI {
304
980
  ) {
305
981
  return false;
306
982
  }
983
+ const tuple=this.#nextPreferenceTuple([
984
+ llmService,
985
+ sttService,
986
+ ttsService,
987
+ model,
988
+ modelTTS,
989
+ modelSTT
990
+ ]);
991
+ this.#assertValidProviderTuple(tuple);
992
+ this.#ensureLegacyLLMProvider(tuple[0]);
993
+ this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
994
+ this.#applyPreferenceTuple(tuple);
995
+ this.#releaseInactiveLegacyLLMProviders(tuple[0]);
996
+ this.#retainLegacyLLMReadiness(
997
+ this.#reconcileLegacyLLMReadiness()
998
+ );
999
+ return true;
1000
+ }
307
1001
 
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)||'';
1002
+ configureProviders(selections){
1003
+ const prepared=this.#providerRuntime.validateConfiguration(selections);
1004
+ this.#ensureLegacyLLMProvider(
1005
+ prepared.llm.default?.providerId
1006
+ );
1007
+ this.#assertRegisteredLegacyRoutes(prepared);
1008
+ const configured=this.#providerRuntime.configure(prepared);
1009
+ this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1010
+ this.#releaseInactiveLegacyLLMProviders(
1011
+ configured.llm.default?.providerId
1012
+ );
1013
+ this.#retainLegacyLLMReadiness(
1014
+ this.#reconcileLegacyLLMReadiness()
1015
+ );
1016
+ return configured;
1017
+ }
314
1018
 
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='';
1019
+ async transitionAI(
1020
+ llmService,
1021
+ sttService,
1022
+ ttsService,
1023
+ model,
1024
+ modelTTS,
1025
+ modelSTT
1026
+ ){
1027
+ const tuple=this.#nextPreferenceTuple([
1028
+ llmService,
1029
+ sttService,
1030
+ ttsService,
1031
+ model,
1032
+ modelTTS,
1033
+ modelSTT
1034
+ ]);
1035
+ this.#assertValidProviderTuple(tuple);
1036
+ this.stopAudio();
1037
+ await this.#unloadProviderRolesForTransition();
1038
+ this.#ensureLegacyLLMProvider(tuple[0]);
1039
+ this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
1040
+ this.#applyPreferenceTuple(tuple);
1041
+ this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1042
+ await this.#reconcileLegacyLLMReadiness();
1043
+ return this.#providerRuntime.status();
1044
+ }
1045
+
1046
+ async transitionProviders(selections){
1047
+ const prepared=this.#providerRuntime.validateConfiguration(selections);
1048
+ this.#ensureLegacyLLMProvider(
1049
+ prepared.llm.default?.providerId
1050
+ );
1051
+ this.#assertRegisteredLegacyRoutes(prepared);
1052
+ this.stopAudio();
1053
+ await this.#unloadProviderRolesForTransition();
1054
+ const configured=this.#providerRuntime.configure(prepared);
1055
+ this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1056
+ this.#releaseInactiveLegacyLLMProviders(
1057
+ configured.llm.default?.providerId
1058
+ );
1059
+ await this.#reconcileLegacyLLMReadiness();
1060
+ return configured;
1061
+ }
1062
+
1063
+ async startProviders(options){
1064
+ const normalized=normalizeAIStartupOptions(options);
1065
+ this.muted=normalized.startMuted;
1066
+ if(normalized.startMuted){
1067
+ this.stopAudio();
324
1068
  }
325
- this.modelTTS=this.#ttsModels[modelTTS];
326
- this.modelTTS=this.#ttsModels[modelTTS];
327
- this.modelSTT=this.#sttModels[modelSTT];
328
- this.reasoningEffort='';
1069
+ return this.#providerRuntime.start(normalized);
1070
+ }
329
1071
 
1072
+ async setSpeechMuted(muted){
1073
+ if(typeof muted!=='boolean'){
1074
+ throw new TypeError('AI speech muted state must be a boolean.');
1075
+ }
1076
+ this.muted=muted;
1077
+ if(muted){
1078
+ this.stopAudio();
1079
+ }
1080
+ if(!this.#usesProviderRuntime('tts',this.ttsService)){
1081
+ return true;
1082
+ }
1083
+ await this.#providerRuntime.setSpeechMuted(muted);
330
1084
  return true;
331
1085
  }
332
1086
 
@@ -376,14 +1130,19 @@ class AI {
376
1130
  :null;
377
1131
  }
378
1132
 
379
- #nativeSpeech(service){
1133
+ #nativeSpeech(service,role){
380
1134
  const client=globalThis.Arcane?.speech;
381
1135
 
382
- return service==='LOCAL_SPEACH'
383
- &&typeof client?.synthesize==='function'
384
- &&typeof client?.transcribe==='function'
385
- ?client
386
- :null;
1136
+ if(service!=='LOCAL_SPEACH'){
1137
+ return null;
1138
+ }
1139
+ if(role==='tts'&&typeof client?.synthesize==='function'){
1140
+ return client;
1141
+ }
1142
+ if(role==='stt'&&typeof client?.transcribe==='function'){
1143
+ return client;
1144
+ }
1145
+ return null;
387
1146
  }
388
1147
 
389
1148
  async #androidNativeHost(){
@@ -589,34 +1348,130 @@ class AI {
589
1348
  return null;
590
1349
  }
591
1350
 
592
- #reportRequest(requestHandler,request,id,metadata={}){
1351
+ async #reportRequest(requestHandler,request,id){
593
1352
  if(typeof requestHandler!=='function'){
594
- throw new TypeError('AI request diagnostics require a function.');
1353
+ throw new TypeError('AI onRequest callback must be a function.');
595
1354
  }
1355
+ await requestHandler(request,id);
1356
+ }
596
1357
 
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);
1358
+ #providerStreamEmissions(chunk,seeThinking){
1359
+ const chunks=[];
1360
+ const toolNames=[];
1361
+ const choices=Array.isArray(chunk?.choices)?chunk.choices:[];
1362
+ for(const choice of choices){
1363
+ const delta=choice?.delta||{};
1364
+ if(seeThinking&&typeof delta.reasoning_content==='string'){
1365
+ chunks.push({text:delta.reasoning_content,thinking:true});
1366
+ }
1367
+ if(typeof delta.content==='string'){
1368
+ chunks.push({text:delta.content,thinking:false});
1369
+ }
1370
+ for(const call of Array.isArray(delta.tool_calls)?delta.tool_calls:[]){
1371
+ const name=call?.function?.name;
1372
+ if(typeof name==='string'&&name){
1373
+ toolNames.push(name);
1374
+ }
1375
+ }
1376
+ }
1377
+ if(!choices.length){
1378
+ if(seeThinking&&typeof chunk?.thinking==='string'){
1379
+ chunks.push({text:chunk.thinking,thinking:true});
1380
+ }
1381
+ const text=typeof chunk?.text==='string'
1382
+ ?chunk.text
1383
+ :typeof chunk?.content==='string'
1384
+ ?chunk.content
1385
+ :'';
1386
+ if(text){
1387
+ chunks.push({text,thinking:false});
1388
+ }
1389
+ const calls=Array.isArray(chunk?.toolCalls)
1390
+ ?chunk.toolCalls
1391
+ :Array.isArray(chunk?.tool_calls)
1392
+ ?chunk.tool_calls
1393
+ :[];
1394
+ for(const call of calls){
1395
+ const name=call?.function?.name||call?.name;
1396
+ if(typeof name==='string'&&name){
1397
+ toolNames.push(name);
1398
+ }
1399
+ }
603
1400
  }
1401
+ return {chunks,toolNames};
1402
+ }
604
1403
 
605
- reportAIRequestToConsole({
606
- id,
607
- service:this.llmService,
608
- request,
609
- ...metadata
610
- });
1404
+ #providerCompletionOutput(completion){
1405
+ if(typeof completion==='string'){
1406
+ return completion;
1407
+ }
1408
+ const toolRecord={};
1409
+ let toolCount=0;
1410
+ for(const choice of Array.isArray(completion?.choices)?completion.choices:[]){
1411
+ for(const call of Array.isArray(choice?.message?.tool_calls)
1412
+ ?choice.message.tool_calls
1413
+ :[]){
1414
+ const name=call?.function?.name;
1415
+ if(typeof name==='string'&&name){
1416
+ toolRecord[name]=call.function.arguments;
1417
+ toolCount+=1;
1418
+ }
1419
+ }
1420
+ }
1421
+ if(toolCount){
1422
+ return toolRecord;
1423
+ }
1424
+ const content=completion?.choices?.[0]?.message?.content;
1425
+ return typeof content==='string'?content:completion;
611
1426
  }
612
1427
 
613
- #reportResponse(response,id,metadata={}){
614
- reportAIResponseToConsole({
615
- id,
616
- service:this.llmService,
617
- response,
618
- ...metadata
619
- });
1428
+ #requestLegacyLLMChat(payload={},signal=null){
1429
+ return this.#fetchLegacy(
1430
+ payload.messages??[],
1431
+ function ignoreLegacyLLMProviderResponse(){},
1432
+ payload.structuredOutput??false,
1433
+ payload.tools??[],
1434
+ payload.toolChoice??'auto',
1435
+ payload.parallelToolCalls??true,
1436
+ payload.id??Date.now(),
1437
+ function ignoreLegacyLLMProviderRequest(){},
1438
+ signal
1439
+ );
1440
+ }
1441
+
1442
+ #requestLegacyLLMStream(payload={},bridge){
1443
+ function emitLegacyLLMStreamText(text,id,thinking){
1444
+ if(typeof text!=='string'||!text){
1445
+ return;
1446
+ }
1447
+ bridge.emit(
1448
+ thinking
1449
+ ?{thinking:text}
1450
+ :{content:text}
1451
+ );
1452
+ }
1453
+
1454
+ function emitLegacyLLMStreamTool(name){
1455
+ if(typeof name==='string'&&name){
1456
+ bridge.emit({toolCalls:[{name}]});
1457
+ }
1458
+ }
1459
+
1460
+ return this.#streamLegacyMessage(
1461
+ payload.messages??[],
1462
+ emitLegacyLLMStreamText,
1463
+ function ignoreLegacyLLMProviderCompletion(){},
1464
+ payload.tools??[],
1465
+ payload.toolChoice??'auto',
1466
+ emitLegacyLLMStreamTool,
1467
+ payload.parallelToolCalls??true,
1468
+ payload.id??Date.now(),
1469
+ payload.seeThinking??false,
1470
+ bridge.signal,
1471
+ function ignoreLegacyLLMProviderRequest(){},
1472
+ payload.structuredOutput??false,
1473
+ false
1474
+ );
620
1475
  }
621
1476
 
622
1477
  #assertRequiredOllamaToolCall(toolCalls=[],toolChoice='auto'){
@@ -701,13 +1556,116 @@ class AI {
701
1556
  if(localOnly!==true&&localOnly!==false){
702
1557
  throw new TypeError('AI localOnly must be a boolean.');
703
1558
  }
704
- if(localOnly&&this.llmService!=='OLLAMA'){
1559
+ if(localOnly&&!this.#hasLocalRoute('llm',this.llmService)){
705
1560
  const error=new Error(
706
1561
  'This AI request requires a configured local model.'
707
1562
  );
708
1563
  error.code='AI_LOCAL_MODEL_REQUIRED';
709
1564
  throw error;
710
1565
  }
1566
+ if(this.#shouldUseProviderRuntime('llm',this.llmService,localOnly)){
1567
+ const request={
1568
+ messages,
1569
+ structuredOutput,
1570
+ tools,
1571
+ toolChoice,
1572
+ parallelToolCalls,
1573
+ id,
1574
+ seeThinking
1575
+ };
1576
+ const displayId=`M-${id}`;
1577
+ const announcedTools=new Set();
1578
+ let handle=null;
1579
+ try{
1580
+ if(signal?.aborted){
1581
+ throw normalizeAIRequestAbort();
1582
+ }
1583
+ await this.#reportRequest(onRequest,request,id);
1584
+ if(signal?.aborted){
1585
+ throw normalizeAIRequestAbort();
1586
+ }
1587
+ handle=await this.#providerRuntime.request(
1588
+ 'llm',
1589
+ {
1590
+ operation:'stream',
1591
+ payload:request,
1592
+ localOnly,
1593
+ signal
1594
+ }
1595
+ );
1596
+ for await(const chunk of handle){
1597
+ if(signal?.aborted){
1598
+ throw normalizeAIRequestAbort();
1599
+ }
1600
+ const emissions=this.#providerStreamEmissions(chunk,seeThinking);
1601
+ for(const emission of emissions.chunks){
1602
+ if(signal?.aborted){
1603
+ throw normalizeAIRequestAbort();
1604
+ }
1605
+ await onChunk(
1606
+ emission.text,
1607
+ displayId,
1608
+ emission.thinking
1609
+ );
1610
+ if(signal?.aborted){
1611
+ throw normalizeAIRequestAbort();
1612
+ }
1613
+ }
1614
+ for(const name of emissions.toolNames){
1615
+ if(signal?.aborted){
1616
+ throw normalizeAIRequestAbort();
1617
+ }
1618
+ if(!announcedTools.has(name)){
1619
+ announcedTools.add(name);
1620
+ await onToolCall(name);
1621
+ if(signal?.aborted){
1622
+ throw normalizeAIRequestAbort();
1623
+ }
1624
+ }
1625
+ }
1626
+ }
1627
+ const completion=await handle.result;
1628
+ if(signal?.aborted){
1629
+ throw normalizeAIRequestAbort();
1630
+ }
1631
+ for(const choice of Array.isArray(completion?.choices)
1632
+ ?completion.choices
1633
+ :[]){
1634
+ for(const call of Array.isArray(choice?.message?.tool_calls)
1635
+ ?choice.message.tool_calls
1636
+ :[]){
1637
+ const name=call?.function?.name;
1638
+ if(typeof name==='string'&&name&&!announcedTools.has(name)){
1639
+ if(signal?.aborted){
1640
+ throw normalizeAIRequestAbort();
1641
+ }
1642
+ announcedTools.add(name);
1643
+ await onToolCall(name);
1644
+ if(signal?.aborted){
1645
+ throw normalizeAIRequestAbort();
1646
+ }
1647
+ }
1648
+ }
1649
+ }
1650
+ const result=this.#providerCompletionOutput(completion);
1651
+ await onComplete(result,displayId,false);
1652
+ if(signal?.aborted){
1653
+ throw normalizeAIRequestAbort();
1654
+ }
1655
+ this.finishTTS();
1656
+ return result;
1657
+ }catch(error){
1658
+ if(handle){
1659
+ await handle.cancel(error).catch(
1660
+ function retainProviderStreamCleanupFailure() {}
1661
+ );
1662
+ }
1663
+ this.stopAudio();
1664
+ throw isAIRequestAbort(error,signal)
1665
+ ?normalizeAIRequestAbort(error)
1666
+ :error;
1667
+ }
1668
+ }
711
1669
 
712
1670
  return this.streamMessage(
713
1671
  messages,
@@ -738,6 +1696,55 @@ class AI {
738
1696
  signal=null,
739
1697
  requestHandler=function ignoreStreamRequest(){},
740
1698
  structuredOutput=false
1699
+ ){
1700
+ if(this.#shouldUseProviderRuntime('llm',this.llmService,false)){
1701
+ return this.streamRequest({
1702
+ messages,
1703
+ structuredOutput,
1704
+ localOnly:false,
1705
+ onChunk:streamHandler,
1706
+ onComplete:streamComplete,
1707
+ tools,
1708
+ toolChoice:tool_choice,
1709
+ onToolCall:earlyFunctionTrigger,
1710
+ onRequest:requestHandler,
1711
+ parallelToolCalls:parallel_tool_calls,
1712
+ id,
1713
+ seeThinking,
1714
+ signal
1715
+ });
1716
+ }
1717
+
1718
+ return this.#streamLegacyMessage(
1719
+ messages,
1720
+ streamHandler,
1721
+ streamComplete,
1722
+ tools,
1723
+ tool_choice,
1724
+ earlyFunctionTrigger,
1725
+ parallel_tool_calls,
1726
+ id,
1727
+ seeThinking,
1728
+ signal,
1729
+ requestHandler,
1730
+ structuredOutput
1731
+ );
1732
+ }
1733
+
1734
+ async #streamLegacyMessage(
1735
+ messages=[],
1736
+ streamHandler=function ignoreStreamChunk(){},
1737
+ streamComplete=function finishIgnoredStream(){},
1738
+ tools=[],
1739
+ tool_choice='auto',
1740
+ earlyFunctionTrigger=function ignoreEarlyFunction(){},
1741
+ parallel_tool_calls=true,
1742
+ id=Date.now(),
1743
+ seeThinking=false,
1744
+ signal=null,
1745
+ requestHandler=function ignoreStreamRequest(){},
1746
+ structuredOutput=false,
1747
+ finishSpeech=true
741
1748
  ){
742
1749
  let speechTurnCompleted=false;
743
1750
 
@@ -785,10 +1792,16 @@ class AI {
785
1792
 
786
1793
  const nativeOllama=this.#nativeOllama();
787
1794
 
1795
+ if(this.llmService==='OLLAMA'&&!nativeOllama){
1796
+ throw legacyAIProviderError(
1797
+ 'Local AI requires the capability-gated Arcane API.',
1798
+ 'AI_NATIVE_LOCAL_REQUIRED'
1799
+ );
1800
+ }
1801
+
788
1802
  if(nativeOllama){
789
1803
  let nativeContent='';
790
1804
  const nativeToolCalls={};
791
- const nativeResponseChunks=[];
792
1805
  const triggeredTools=new Set();
793
1806
  const ollamaTools=this.#ollamaTools(tools,tool_choice);
794
1807
  const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
@@ -802,7 +1815,7 @@ class AI {
802
1815
  };
803
1816
 
804
1817
  function reportEarlyFunctionFailure(error){
805
- console.error('Early tool trigger failed:',error);
1818
+ console.error('Early tool trigger failed.');
806
1819
  }
807
1820
 
808
1821
  function receiveNativeToolCalls(message={}){
@@ -832,7 +1845,7 @@ class AI {
832
1845
  }
833
1846
  }
834
1847
 
835
- this.#reportRequest(requestHandler,ollamaRequest,id,{
1848
+ await this.#reportRequest(requestHandler,ollamaRequest,id,{
836
1849
  operation:'stream',
837
1850
  transport:'native',
838
1851
  destination:'Arcane.ollama.chat'
@@ -844,7 +1857,6 @@ class AI {
844
1857
  if(signal?.aborted){
845
1858
  return;
846
1859
  }
847
- nativeResponseChunks.push(chunk);
848
1860
  const message=chunk?.message||{};
849
1861
  const thinking=seeThinking
850
1862
  ?String(message.thinking||'')
@@ -866,15 +1878,6 @@ class AI {
866
1878
  signal
867
1879
  }
868
1880
  );
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
1881
  if(signal?.aborted){
879
1882
  throw normalizeAIRequestAbort();
880
1883
  }
@@ -892,13 +1895,15 @@ class AI {
892
1895
  if(Object.keys(nativeToolCalls).length&&!nativeContent){
893
1896
  streamHandler('',`M-${id}`,false);
894
1897
  }
895
- this.finishTTS();
1898
+ if(finishSpeech){
1899
+ this.finishTTS();
1900
+ }
896
1901
  await streamComplete(nativeResult,`M-${id}`,isThinking);
897
1902
  speechTurnCompleted=true;
898
1903
  return nativeResult;
899
1904
  }
900
1905
 
901
- this.#reportRequest(requestHandler,request,id,{
1906
+ await this.#reportRequest(requestHandler,request,id,{
902
1907
  operation:'stream',
903
1908
  transport:'http',
904
1909
  destination:this.url
@@ -921,7 +1926,7 @@ class AI {
921
1926
  if(signal?.aborted||err?.name==='AbortError'){
922
1927
  const error=new Error('The AI request was cancelled.',{cause:err});
923
1928
  error.name='AbortError';
924
- error.code='AI_REQUEST_ABORTED';
1929
+ error.code='ARCANE_AI_REQUEST_ABORTED';
925
1930
  throw error;
926
1931
  }
927
1932
  const error=new Error(
@@ -936,7 +1941,6 @@ class AI {
936
1941
 
937
1942
  let chunkString='';
938
1943
  let chunkCache='';
939
- const responseEvents=[];
940
1944
  const streamedToolCalls=new Map();
941
1945
  const triggeredTools=new Set();
942
1946
  const decoder = new TextDecoder('utf-8');
@@ -969,7 +1973,7 @@ class AI {
969
1973
  Promise.resolve(
970
1974
  earlyFunctionTrigger(record.name)
971
1975
  ).catch(
972
- error=>console.error('Early tool trigger failed:',error)
1976
+ ()=>console.error('Early tool trigger failed.')
973
1977
  );
974
1978
  }
975
1979
  }
@@ -1007,14 +2011,12 @@ class AI {
1007
2011
  chunkCache+=delta;
1008
2012
 
1009
2013
  if (chunkCache.trim() === '[DONE]') {
1010
- responseEvents.push('[DONE]');
1011
2014
  chunkCache = '';
1012
2015
  return;
1013
2016
  }
1014
2017
 
1015
2018
  try{
1016
2019
  const resp=JSON.parse(chunkCache)||{};
1017
- responseEvents.push(resp);
1018
2020
  //console.log(JSON.stringify(resp));
1019
2021
  //console.log(resp)
1020
2022
  const choice = resp.choices?.[0] || {};
@@ -1058,7 +2060,7 @@ class AI {
1058
2060
  receiveStreamedToolCalls(tool_calls);
1059
2061
  }
1060
2062
  } catch(err) {
1061
- console.warn(err);
2063
+ console.warn('AI stream callback failed.');
1062
2064
  }
1063
2065
  }
1064
2066
  );
@@ -1072,12 +2074,6 @@ class AI {
1072
2074
  reader.releaseLock();
1073
2075
  }
1074
2076
 
1075
- this.#reportResponse(responseEvents,id,{
1076
- operation:'stream',
1077
- transport:'http',
1078
- destination:this.url
1079
- });
1080
-
1081
2077
  const tool_funcs={};
1082
2078
  const orderedToolCalls=[...streamedToolCalls.values()].sort(
1083
2079
  function sortStreamedToolCalls(a,b){
@@ -1103,7 +2099,9 @@ class AI {
1103
2099
  if(Object.keys(tool_funcs).length&&!chunkString){
1104
2100
  streamHandler('',`M-${id}`,false);
1105
2101
  }
1106
- this.finishTTS();
2102
+ if(finishSpeech){
2103
+ this.finishTTS();
2104
+ }
1107
2105
  await streamComplete(streamResult, `M-${id}`,isThinking);
1108
2106
 
1109
2107
  //sync
@@ -1136,13 +2134,47 @@ class AI {
1136
2134
  if(localOnly!==true&&localOnly!==false){
1137
2135
  throw new TypeError('AI localOnly must be a boolean.');
1138
2136
  }
1139
- if(localOnly&&this.llmService!=='OLLAMA'){
2137
+ if(localOnly&&!this.#hasLocalRoute('llm',this.llmService)){
1140
2138
  const error=new Error(
1141
2139
  'This AI request requires a configured local model.'
1142
2140
  );
1143
2141
  error.code='AI_LOCAL_MODEL_REQUIRED';
1144
2142
  throw error;
1145
2143
  }
2144
+ if(this.#shouldUseProviderRuntime('llm',this.llmService,localOnly)){
2145
+ if(signal?.aborted){
2146
+ throw normalizeAIRequestAbort();
2147
+ }
2148
+ const request={
2149
+ messages,
2150
+ structuredOutput,
2151
+ tools,
2152
+ toolChoice,
2153
+ parallelToolCalls,
2154
+ id
2155
+ };
2156
+ await this.#reportRequest(onRequest,request,id);
2157
+ if(signal?.aborted){
2158
+ throw normalizeAIRequestAbort();
2159
+ }
2160
+ const response=await this.#providerRuntime.request(
2161
+ 'llm',
2162
+ {
2163
+ operation:'chat',
2164
+ payload:request,
2165
+ localOnly,
2166
+ signal
2167
+ }
2168
+ );
2169
+ if(signal?.aborted){
2170
+ throw normalizeAIRequestAbort();
2171
+ }
2172
+ await onResponse(response,id,false);
2173
+ if(signal?.aborted){
2174
+ throw normalizeAIRequestAbort();
2175
+ }
2176
+ return response;
2177
+ }
1146
2178
 
1147
2179
  return this.fetch(
1148
2180
  messages,
@@ -1167,6 +2199,45 @@ class AI {
1167
2199
  id=Date.now(),
1168
2200
  requestHandler=function ignoreFetchRequest(){},
1169
2201
  signal=null,
2202
+ ){
2203
+ if(this.#shouldUseProviderRuntime('llm',this.llmService,false)){
2204
+ return this.fetchRequest({
2205
+ messages,
2206
+ structuredOutput,
2207
+ localOnly:false,
2208
+ tools,
2209
+ toolChoice:tool_choice,
2210
+ parallelToolCalls:parallel_tool_calls,
2211
+ id,
2212
+ signal,
2213
+ onRequest:requestHandler,
2214
+ onResponse:responseHandler
2215
+ });
2216
+ }
2217
+
2218
+ return this.#fetchLegacy(
2219
+ messages,
2220
+ responseHandler,
2221
+ structuredOutput,
2222
+ tools,
2223
+ tool_choice,
2224
+ parallel_tool_calls,
2225
+ id,
2226
+ requestHandler,
2227
+ signal
2228
+ );
2229
+ }
2230
+
2231
+ async #fetchLegacy(
2232
+ messages=[],
2233
+ responseHandler=function ignoreFetchResponse(){},
2234
+ structuredOutput=false,
2235
+ tools=[],
2236
+ tool_choice='auto',
2237
+ parallel_tool_calls=true,
2238
+ id=Date.now(),
2239
+ requestHandler=function ignoreFetchRequest(){},
2240
+ signal=null,
1170
2241
  ){
1171
2242
  this.#assertServiceConfigured(this.llmService);
1172
2243
  if(signal&&(
@@ -1204,6 +2275,13 @@ class AI {
1204
2275
 
1205
2276
  const nativeOllama=this.#nativeOllama();
1206
2277
 
2278
+ if(this.llmService==='OLLAMA'&&!nativeOllama){
2279
+ throw legacyAIProviderError(
2280
+ 'Local AI requires the capability-gated Arcane API.',
2281
+ 'AI_NATIVE_LOCAL_REQUIRED'
2282
+ );
2283
+ }
2284
+
1207
2285
  if(nativeOllama){
1208
2286
  const ollamaTools=this.#ollamaTools(tools,tool_choice);
1209
2287
  const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
@@ -1215,7 +2293,7 @@ class AI {
1215
2293
  ...(structuredOutputFormat?{format:structuredOutputFormat}:{}),
1216
2294
  ...(ollamaTools.length?{tools:ollamaTools}:{})
1217
2295
  };
1218
- this.#reportRequest(requestHandler,nativeRequest,id,{
2296
+ await this.#reportRequest(requestHandler,nativeRequest,id,{
1219
2297
  operation:'fetch',
1220
2298
  transport:'native',
1221
2299
  destination:'Arcane.ollama.chat'
@@ -1232,11 +2310,6 @@ class AI {
1232
2310
  }
1233
2311
  throw error;
1234
2312
  }
1235
- this.#reportResponse(nativeResponse,id,{
1236
- operation:'fetch',
1237
- transport:'native',
1238
- destination:'Arcane.ollama.chat'
1239
- });
1240
2313
  if(signal?.aborted){
1241
2314
  throw normalizeAIRequestAbort();
1242
2315
  }
@@ -1258,7 +2331,7 @@ class AI {
1258
2331
  return responseJSON;
1259
2332
  }
1260
2333
 
1261
- this.#reportRequest(requestHandler,request,id,{
2334
+ await this.#reportRequest(requestHandler,request,id,{
1262
2335
  operation:'fetch',
1263
2336
  transport:'http',
1264
2337
  destination:this.url
@@ -1309,11 +2382,6 @@ class AI {
1309
2382
  }
1310
2383
  throw error;
1311
2384
  }
1312
- this.#reportResponse(responseJSON,id,{
1313
- operation:'fetch',
1314
- transport:'http',
1315
- destination:this.url
1316
- });
1317
2385
  if(signal?.aborted){
1318
2386
  throw normalizeAIRequestAbort();
1319
2387
  }
@@ -1348,9 +2416,9 @@ class AI {
1348
2416
  }
1349
2417
 
1350
2418
  try{
1351
- this.#assertServiceConfigured(this.ttsService);
2419
+ this.#assertServiceConfigured(this.ttsService,'tts');
1352
2420
  }catch(error){
1353
- console.warn('Error preparing speech from AI:',error);
2421
+ console.warn('AI speech provider is unavailable.');
1354
2422
  return Promise.resolve(false);
1355
2423
  }
1356
2424
 
@@ -1498,7 +2566,28 @@ class AI {
1498
2566
  }
1499
2567
 
1500
2568
  async #requestSpeechAudio(job){
1501
- const nativeSpeech=this.#nativeSpeech(this.ttsService);
2569
+ if(this.#usesProviderRuntime('tts',this.ttsService)){
2570
+ job.abortController=new AbortController();
2571
+ const responseFormat=this.#providerSpeechResponseFormat();
2572
+ const response=await this.#providerRuntime.request(
2573
+ 'tts',
2574
+ {
2575
+ operation:'synthesize',
2576
+ payload:{
2577
+ model:this.#providerRuntime.selection('tts')?.modelId,
2578
+ voice:String(window.user?.AI_voice||'af_heart'),
2579
+ input:job.text,
2580
+ responseFormat,
2581
+ speed:this.voiceSpeed
2582
+ },
2583
+ localOnly:false,
2584
+ signal:job.abortController.signal
2585
+ }
2586
+ );
2587
+ return this.#normalizeProviderSpeechAudio(response);
2588
+ }
2589
+
2590
+ const nativeSpeech=this.#nativeSpeech(this.ttsService,'tts');
1502
2591
 
1503
2592
  if(nativeSpeech){
1504
2593
  const response=await nativeSpeech.synthesize({
@@ -1577,6 +2666,109 @@ class AI {
1577
2666
  return {chunks,type:this.audioType};
1578
2667
  }
1579
2668
 
2669
+ #providerSpeechResponseFormat(){
2670
+ const selection=this.#providerRuntime.selection('tts');
2671
+ if(!selection){
2672
+ return this.audioFormat;
2673
+ }
2674
+ const provider=this.#providerRuntime.catalog('tts').find(
2675
+ entry=>entry.providerId===selection.providerId
2676
+ );
2677
+ const model=provider?.models.find(entry=>entry?.id===selection.modelId);
2678
+ const speech=model?.speech;
2679
+ if(speech===undefined){
2680
+ return this.audioFormat;
2681
+ }
2682
+ const prototype=speech&&typeof speech==='object'
2683
+ ?Object.getPrototypeOf(speech)
2684
+ :null;
2685
+ const descriptors=prototype===Object.prototype||prototype===null
2686
+ ?Object.getOwnPropertyDescriptors(speech)
2687
+ :null;
2688
+ const formats=descriptors?.responseFormats?.value;
2689
+ const defaultFormat=descriptors?.defaultResponseFormat?.value;
2690
+ if(!Array.isArray(formats)
2691
+ ||formats.length<1
2692
+ ||!formats.every(format=>typeof format==='string'&&format.trim()===format&&format)
2693
+ ||typeof defaultFormat!=='string'
2694
+ ||!formats.includes(defaultFormat)){
2695
+ throw legacyAIProviderError(
2696
+ 'The selected TTS provider returned an invalid speech format catalog.',
2697
+ 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
2698
+ );
2699
+ }
2700
+ if(formats.includes(this.audioFormat)){
2701
+ return this.audioFormat;
2702
+ }
2703
+ if(this.audioFormat===LEGACY_TTS_RESPONSE_FORMAT){
2704
+ return defaultFormat;
2705
+ }
2706
+ throw legacyAIProviderError(
2707
+ `The selected TTS provider does not support ${this.audioFormat}.`,
2708
+ 'ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT'
2709
+ );
2710
+ }
2711
+
2712
+ async #normalizeProviderSpeechAudio(response){
2713
+ if(response instanceof Blob){
2714
+ return {
2715
+ chunks:[new Uint8Array(await response.arrayBuffer())],
2716
+ type:response.type||this.audioType
2717
+ };
2718
+ }
2719
+
2720
+ if(response instanceof ArrayBuffer||ArrayBuffer.isView(response)){
2721
+ const bytes=response instanceof ArrayBuffer
2722
+ ?new Uint8Array(response)
2723
+ :new Uint8Array(
2724
+ response.buffer,
2725
+ response.byteOffset,
2726
+ response.byteLength
2727
+ );
2728
+ return {
2729
+ chunks:[bytes],
2730
+ type:this.audioType
2731
+ };
2732
+ }
2733
+
2734
+ if(response&&typeof response==='object'){
2735
+ if(typeof response.audioBase64==='string'){
2736
+ return {
2737
+ chunks:[this.#base64ToBytes(response.audioBase64)],
2738
+ type:typeof response.contentType==='string'
2739
+ ?response.contentType
2740
+ :this.audioType
2741
+ };
2742
+ }
2743
+ if(response.audio instanceof Blob){
2744
+ return {
2745
+ chunks:[new Uint8Array(await response.audio.arrayBuffer())],
2746
+ type:response.audio.type
2747
+ ||response.contentType
2748
+ ||this.audioType
2749
+ };
2750
+ }
2751
+ if(response.audio instanceof ArrayBuffer
2752
+ ||ArrayBuffer.isView(response.audio)){
2753
+ const bytes=response.audio instanceof ArrayBuffer
2754
+ ?new Uint8Array(response.audio)
2755
+ :new Uint8Array(
2756
+ response.audio.buffer,
2757
+ response.audio.byteOffset,
2758
+ response.audio.byteLength
2759
+ );
2760
+ return {
2761
+ chunks:[bytes],
2762
+ type:typeof response.contentType==='string'
2763
+ ?response.contentType
2764
+ :this.audioType
2765
+ };
2766
+ }
2767
+ }
2768
+
2769
+ throw new TypeError('Arcane returned an invalid provider speech response.');
2770
+ }
2771
+
1580
2772
  #getSpeechAudioContext(){
1581
2773
  if(this.audioContext&&this.audioContext.state!=='closed'){
1582
2774
  return this.audioContext;
@@ -1594,11 +2786,50 @@ class AI {
1594
2786
 
1595
2787
  async fetchSTT(
1596
2788
  audioFile,
1597
- responseHandler=(text='')=>{}
2789
+ responseHandler=(text='')=>{},
2790
+ signal=null
1598
2791
  ){
1599
- this.#assertServiceConfigured(this.sttService);
2792
+ this.#assertServiceConfigured(this.sttService,'stt');
2793
+ if(signal&&(
2794
+ typeof signal.aborted!=='boolean'
2795
+ ||typeof signal.addEventListener!=='function'
2796
+ )){
2797
+ throw new TypeError('AI request signal must be an AbortSignal.');
2798
+ }
2799
+ if(signal?.aborted){
2800
+ throw normalizeAIRequestAbort();
2801
+ }
2802
+
2803
+ if(this.#usesProviderRuntime('stt',this.sttService)){
2804
+ if(!audioFile||typeof audioFile.arrayBuffer!=='function'){
2805
+ throw new TypeError('Speech transcription requires an audio Blob or File.');
2806
+ }
2807
+ const response=await this.#providerRuntime.request(
2808
+ 'stt',
2809
+ {
2810
+ operation:'transcribe',
2811
+ payload:{
2812
+ audio:audioFile,
2813
+ mimeType:String(audioFile.type||'audio/webm'),
2814
+ model:this.#providerRuntime.selection('stt')?.modelId
2815
+ },
2816
+ localOnly:false,
2817
+ signal
2818
+ }
2819
+ );
2820
+ const text=typeof response==='string'
2821
+ ?response
2822
+ :response?.text;
2823
+ if(typeof text!=='string'){
2824
+ throw new TypeError(
2825
+ 'Arcane returned an invalid provider speech transcription.'
2826
+ );
2827
+ }
2828
+ await responseHandler(text);
2829
+ return text;
2830
+ }
1600
2831
 
1601
- const nativeSpeech=this.#nativeSpeech(this.sttService);
2832
+ const nativeSpeech=this.#nativeSpeech(this.sttService,'stt');
1602
2833
 
1603
2834
  if(nativeSpeech){
1604
2835
  if(!audioFile||typeof audioFile.arrayBuffer!=='function'){
@@ -1632,7 +2863,8 @@ class AI {
1632
2863
  method: 'POST',
1633
2864
  credentials: credentials,
1634
2865
  headers: this.#sttHeaders[this.sttService],
1635
- body: formData
2866
+ body: formData,
2867
+ signal
1636
2868
  }
1637
2869
  );
1638
2870
 
@@ -1672,7 +2904,7 @@ class AI {
1672
2904
  try{
1673
2905
  sourceNode.stop();
1674
2906
  }catch(error){
1675
- console.warn('Error stopping AI audio:',error);
2907
+ console.warn('AI audio could not be stopped cleanly.');
1676
2908
  }
1677
2909
  }
1678
2910
 
@@ -1798,7 +3030,7 @@ class AI {
1798
3030
  #requestSpeechPlayback(){
1799
3031
  this.#pumpSpeechPlayback().catch(
1800
3032
  function reportSpeechPlaybackFailure(error){
1801
- console.warn('Error playing audio data:',error);
3033
+ console.warn('AI audio playback failed.');
1802
3034
  }
1803
3035
  );
1804
3036
  }
@@ -1925,7 +3157,7 @@ class AI {
1925
3157
  }
1926
3158
 
1927
3159
  if(job.generation===this.speechGeneration&&error?.name!=='AbortError'){
1928
- console.warn('Error preparing audio from AI:',error);
3160
+ console.warn('AI speech synthesis failed.');
1929
3161
  }
1930
3162
 
1931
3163
  this.#requestSpeechPlayback();
@@ -1974,7 +3206,7 @@ class AI {
1974
3206
  );
1975
3207
 
1976
3208
  if(error?.name&&error.name!=='NotAllowedError'){
1977
- console.info('AI speech is waiting for audio playback permission:',error);
3209
+ console.info('AI speech is waiting for audio playback permission.');
1978
3210
  }
1979
3211
 
1980
3212
  return true;