arcane-os 0.2.0 → 0.2.2

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 (38) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +8 -8
  3. package/browser-runtime/ARCANE_SDK_BROWSER_RELEASE.json +5 -5
  4. package/browser-runtime/ai/browser-speech-providers.mjs +331 -25
  5. package/docs/architecture.md +2 -2
  6. package/docs/reference/README.md +79 -13
  7. package/docs/reference/ai/browser-speech.md +336 -0
  8. package/docs/reference/ai/browser-wasm.md +207 -82
  9. package/docs/reference/availability-and-normalization.md +30 -4
  10. package/docs/reference/behavioral-testing.md +4 -1
  11. package/docs/reference/cli.md +29 -10
  12. package/docs/reference/core/arcane-ai-contracts.md +43 -9
  13. package/docs/reference/inventory/package-api.json +110 -14
  14. package/docs/reference/inventory/runtime-components.json +19 -6
  15. package/docs/reference/inventory/runtime-modules.json +113 -9
  16. package/docs/reference/protocols.md +260 -38
  17. package/docs/reference/runtime-components.md +108 -15
  18. package/docs/reference/runtime-modules.md +422 -8
  19. package/docs/reference/sdk-api.md +626 -85
  20. package/package.json +1 -1
  21. package/runtime/ARCANE_RUNTIME_RELEASE.json +19 -19
  22. package/runtime/arcane/components/chat.html +288 -52
  23. package/runtime/arcane/components/speech.html +339 -15
  24. package/runtime/arcane/modules/AI.js +1245 -136
  25. package/runtime/arcane/modules/AIProviderRuntime.js +299 -30
  26. package/runtime/arcane/modules/AIRuntimeState.js +23 -4
  27. package/runtime/arcane/modules/ConfiguredAIChatSession.js +93 -8
  28. package/runtime/arcane/modules/DBOPFSDocumentLibrary.js +448 -24
  29. package/runtime/arcane/modules/LocalAIReadinessController.js +1 -1
  30. package/schemas/arcane-lock.schema.json +6 -4
  31. package/src/dev-server.mjs +29 -13
  32. package/src/doctor.mjs +1 -3
  33. package/src/import-map.mjs +134 -83
  34. package/src/packager/core.mjs +311 -39
  35. package/src/scaffold.mjs +45 -17
  36. package/src/templates/workspace-template.mjs +23 -4
  37. package/src/toolchain.mjs +10 -2
  38. package/src/workspace.mjs +177 -24
@@ -1,10 +1,15 @@
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
+ import {
5
+ AI_MODEL_AUTHORITY_PROTOCOL,
6
+ AI_PROVIDER_PROTOCOL,
7
+ getAIProviderRuntime
8
+ } from './AIProviderRuntime.js';
5
9
  import {normalizeOllamaModelIdentifier} from './OllamaModelIdentifier.js';
6
10
 
7
11
  let credentials='include';
12
+ const LEGACY_TTS_RESPONSE_FORMAT='opus';
8
13
  credentials='omit';
9
14
 
10
15
  const LEGACY_AI_SERVICES=new Set(['OPENAI','OLLAMA','LOCAL_SPEACH']);
@@ -27,9 +32,155 @@ function normalizeAIRequestAbort(error){
27
32
  return normalized;
28
33
  }
29
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
+
30
177
  function normalizeAIStartupOptions(options){
31
178
  if(options===undefined){
32
- return Object.freeze({startMuted:true,signal:null});
179
+ return Object.freeze({
180
+ startMuted:true,
181
+ startTranscription:false,
182
+ signal:null
183
+ });
33
184
  }
34
185
  if(!options||typeof options!=='object'||Array.isArray(options)){
35
186
  throw new TypeError('AI startup options must be a plain object.');
@@ -40,7 +191,11 @@ function normalizeAIStartupOptions(options){
40
191
  }
41
192
  const descriptors=Object.getOwnPropertyDescriptors(options);
42
193
  for(const key of Reflect.ownKeys(descriptors)){
43
- if(typeof key==='symbol'||(key!=='startMuted'&&key!=='signal')){
194
+ if(typeof key==='symbol'||(
195
+ key!=='startMuted'
196
+ &&key!=='startTranscription'
197
+ &&key!=='signal'
198
+ )){
44
199
  throw new TypeError('AI startup options contain an unknown option.');
45
200
  }
46
201
  if(!Object.hasOwn(descriptors[key],'value')){
@@ -50,12 +205,18 @@ function normalizeAIStartupOptions(options){
50
205
  const startMuted=Object.hasOwn(descriptors,'startMuted')
51
206
  ?descriptors.startMuted.value
52
207
  :true;
208
+ const startTranscription=Object.hasOwn(descriptors,'startTranscription')
209
+ ?descriptors.startTranscription.value
210
+ :false;
53
211
  const signal=Object.hasOwn(descriptors,'signal')
54
212
  ?descriptors.signal.value
55
213
  :null;
56
214
  if(typeof startMuted!=='boolean'){
57
215
  throw new TypeError('AI startup startMuted must be a boolean.');
58
216
  }
217
+ if(typeof startTranscription!=='boolean'){
218
+ throw new TypeError('AI startup startTranscription must be a boolean.');
219
+ }
59
220
  if(signal!==null&&signal!==undefined&&(
60
221
  typeof signal!=='object'
61
222
  ||typeof signal.aborted!=='boolean'
@@ -64,7 +225,7 @@ function normalizeAIStartupOptions(options){
64
225
  )){
65
226
  throw new TypeError('AI startup signal must be an AbortSignal.');
66
227
  }
67
- return Object.freeze({startMuted,signal});
228
+ return Object.freeze({startMuted,startTranscription,signal});
68
229
  }
69
230
 
70
231
  class AI {
@@ -190,9 +351,23 @@ class AI {
190
351
  this.setAI(
191
352
  ...preferences
192
353
  );
354
+
355
+ const runtime=this;
356
+ globalThis.addEventListener?.(
357
+ 'arcane-ollama-ready',
358
+ function reconcileLegacyOllamaReadiness(){
359
+ runtime.#retainLegacyLLMReadiness(
360
+ runtime.#reconcileLegacyLLMReadiness()
361
+ );
362
+ }
363
+ );
193
364
  }
194
365
 
195
366
  #providerRuntime=getAIProviderRuntime();
367
+ #legacyLLMProviders=new Map();
368
+ #legacyLLMReadiness=Promise.resolve(null);
369
+ #legacySpeechProviders=new Map();
370
+ #legacySpeechReadiness=Promise.resolve(null);
196
371
  #preferenceTuple=Object.freeze([
197
372
  'OPENAI',
198
373
  'OPENAI',
@@ -240,11 +415,642 @@ class AI {
240
415
 
241
416
  set license(value){
242
417
  this.#license=typeof value==='string' ? value.trim():'';
418
+ this.#retainLegacyLLMReadiness(
419
+ this.#reconcileLegacyLLMReadiness()
420
+ );
421
+ this.#retainLegacySpeechReadiness(
422
+ this.#reconcileLegacySpeechReadiness()
423
+ );
243
424
  return this.#license;
244
425
  }
245
426
 
427
+ #legacyLLMCapability(providerId){
428
+ if(providerId==='OPENAI'){
429
+ return this.llmService==='OPENAI'
430
+ &&Boolean(this.model)
431
+ &&Boolean(this.license)
432
+ &&typeof globalThis.fetch==='function';
433
+ }
434
+ if(providerId==='OLLAMA'){
435
+ return this.llmService==='OLLAMA'
436
+ &&Boolean(this.model)
437
+ &&Boolean(this.#nativeOllama());
438
+ }
439
+ return false;
440
+ }
441
+
442
+ #legacyLLMInspection(providerId,selection){
443
+ const localOnly=providerId==='OLLAMA';
444
+ if(!selection
445
+ ||selection.providerId!==providerId
446
+ ||selection.modelId!==this.model
447
+ ||selection.localOnly!==localOnly
448
+ ||this.llmService!==providerId){
449
+ return Object.freeze({
450
+ available:false,
451
+ code:'ARCANE_AI_MODEL_AUTHORITY_REQUIRED',
452
+ message:'The selected legacy LLM route does not match the active AI configuration.'
453
+ });
454
+ }
455
+ if(!this.#legacyLLMCapability(providerId)){
456
+ return Object.freeze({
457
+ available:false,
458
+ code:providerId==='OLLAMA'
459
+ ?'AI_NATIVE_LOCAL_REQUIRED'
460
+ :'AI_PROVIDER_NOT_CONFIGURED',
461
+ message:providerId==='OLLAMA'
462
+ ?'Local AI requires the capability-gated Arcane API.'
463
+ :'AI provider is not configured.'
464
+ });
465
+ }
466
+ return Object.freeze({
467
+ available:true,
468
+ authority:Object.freeze({
469
+ protocol:AI_MODEL_AUTHORITY_PROTOCOL,
470
+ providerId,
471
+ modelId:selection.modelId,
472
+ admitted:true
473
+ })
474
+ });
475
+ }
476
+
477
+ #createLegacyLLMProvider(providerId){
478
+ const runtime=this;
479
+ const localOnly=providerId==='OLLAMA';
480
+ let state='unloaded';
481
+ let busy=false;
482
+
483
+ function statusLegacyLLMProvider(){
484
+ if(state==='ready'
485
+ &&!busy
486
+ &&!runtime.#legacyLLMCapability(providerId)){
487
+ state='unloaded';
488
+ }
489
+ return Object.freeze({
490
+ state,
491
+ loaded:state==='ready',
492
+ busy
493
+ });
494
+ }
495
+
496
+ function assertLegacyLLMSelection(selection){
497
+ const inspection=runtime.#legacyLLMInspection(
498
+ providerId,
499
+ selection
500
+ );
501
+ if(!inspection.available){
502
+ throw legacyAIProviderError(
503
+ inspection.message,
504
+ inspection.code
505
+ );
506
+ }
507
+ return inspection;
508
+ }
509
+
510
+ function releaseLegacyLLMRequest(){
511
+ busy=false;
512
+ }
513
+
514
+ return Object.freeze({
515
+ protocol:AI_PROVIDER_PROTOCOL,
516
+ role:'llm',
517
+ id:providerId,
518
+ localOnly,
519
+ catalog:function catalogLegacyLLMProvider(){
520
+ if(runtime.llmService!==providerId||!runtime.model){
521
+ return Object.freeze([]);
522
+ }
523
+ return Object.freeze([
524
+ Object.freeze({id:runtime.model})
525
+ ]);
526
+ },
527
+ inspect:function inspectLegacyLLMProvider(selection,{signal}={}){
528
+ if(signal?.aborted){
529
+ throw normalizeAIRequestAbort(signal.reason);
530
+ }
531
+ return runtime.#legacyLLMInspection(providerId,selection);
532
+ },
533
+ status:statusLegacyLLMProvider,
534
+ load:function loadLegacyLLMProvider(context={}){
535
+ if(context.signal?.aborted){
536
+ throw normalizeAIRequestAbort(context.signal.reason);
537
+ }
538
+ if(state==='disposed'){
539
+ throw legacyAIProviderError(
540
+ 'The legacy LLM provider is disposed.',
541
+ 'ARCANE_AI_PROVIDER_DISPOSED'
542
+ );
543
+ }
544
+ if(busy){
545
+ throw legacyAIProviderError(
546
+ 'The legacy LLM provider owns an active request.',
547
+ 'ARCANE_AI_ROLE_BUSY'
548
+ );
549
+ }
550
+ if(typeof context.progress!=='function'){
551
+ throw new TypeError(
552
+ 'Legacy LLM provider load progress must be a function.'
553
+ );
554
+ }
555
+ const inspection=assertLegacyLLMSelection(context.selection);
556
+ state='loading';
557
+ context.progress({
558
+ phase:'capability',
559
+ completed:0,
560
+ total:1,
561
+ unit:'items',
562
+ heartbeat:false
563
+ });
564
+ if(context.signal?.aborted){
565
+ state='unloaded';
566
+ throw normalizeAIRequestAbort(context.signal.reason);
567
+ }
568
+ state='ready';
569
+ context.progress({
570
+ phase:'capability',
571
+ completed:1,
572
+ total:1,
573
+ unit:'items',
574
+ heartbeat:false
575
+ });
576
+ return Object.freeze({
577
+ authority:inspection.authority,
578
+ status:statusLegacyLLMProvider()
579
+ });
580
+ },
581
+ request:function requestLegacyLLMProvider(context={}){
582
+ if(context.signal?.aborted){
583
+ throw normalizeAIRequestAbort(context.signal.reason);
584
+ }
585
+ assertLegacyLLMSelection(context.selection);
586
+ const current=statusLegacyLLMProvider();
587
+ if(current.state!=='ready'||!current.loaded){
588
+ throw legacyAIProviderError(
589
+ 'The legacy LLM provider is not ready.',
590
+ 'ARCANE_AI_ROLE_NOT_READY'
591
+ );
592
+ }
593
+ if(busy){
594
+ throw legacyAIProviderError(
595
+ 'The legacy LLM provider owns an active request.',
596
+ 'ARCANE_AI_ROLE_BUSY'
597
+ );
598
+ }
599
+ busy=true;
600
+ if(context.operation==='chat'){
601
+ return Promise.resolve(
602
+ runtime.#requestLegacyLLMChat(
603
+ context.payload,
604
+ context.signal
605
+ )
606
+ ).finally(releaseLegacyLLMRequest);
607
+ }
608
+ if(context.operation==='stream'){
609
+ const handle=createLegacyAIStreamBridge(
610
+ function executeLegacyLLMProviderStream(bridge){
611
+ return runtime.#requestLegacyLLMStream(
612
+ context.payload,
613
+ bridge
614
+ );
615
+ },
616
+ context.signal
617
+ );
618
+ handle.result.then(
619
+ releaseLegacyLLMRequest,
620
+ releaseLegacyLLMRequest
621
+ );
622
+ return handle;
623
+ }
624
+ busy=false;
625
+ throw legacyAIProviderError(
626
+ 'The legacy LLM provider operation is unsupported.',
627
+ 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
628
+ );
629
+ },
630
+ unload:function unloadLegacyLLMProvider(context={}){
631
+ if(context.signal?.aborted){
632
+ throw normalizeAIRequestAbort(context.signal.reason);
633
+ }
634
+ state='unloaded';
635
+ busy=false;
636
+ return statusLegacyLLMProvider();
637
+ },
638
+ dispose:function disposeLegacyLLMProvider(context={}){
639
+ if(context.signal?.aborted){
640
+ throw normalizeAIRequestAbort(context.signal.reason);
641
+ }
642
+ state='disposed';
643
+ busy=false;
644
+ return statusLegacyLLMProvider();
645
+ }
646
+ });
647
+ }
648
+
649
+ #legacySpeechService(role){
650
+ return role==='stt'?this.sttService:this.ttsService;
651
+ }
652
+
653
+ #legacySpeechModel(role){
654
+ return role==='stt'?this.modelSTT:this.modelTTS;
655
+ }
656
+
657
+ #legacySpeechProviderKey(role,providerId){
658
+ return `${role}:${providerId}`;
659
+ }
660
+
661
+ #legacySpeechDefaultVoice(role,providerId){
662
+ if(role!=='tts'){
663
+ return null;
664
+ }
665
+ if(providerId==='OPENAI'){
666
+ const selected=typeof globalThis.window?.user?.AI_voice==='string'
667
+ ?globalThis.window.user.AI_voice.trim()
668
+ :'';
669
+ return selected||'alloy';
670
+ }
671
+ if(providerId==='LOCAL_SPEACH'){
672
+ return 'af_heart';
673
+ }
674
+ return null;
675
+ }
676
+
677
+ #legacySpeechCapability(role,providerId){
678
+ const service=this.#legacySpeechService(role);
679
+ const model=this.#legacySpeechModel(role);
680
+ if(service!==providerId||!model){
681
+ return false;
682
+ }
683
+ if(providerId==='OPENAI'){
684
+ return Boolean(this.license)&&typeof globalThis.fetch==='function';
685
+ }
686
+ if(providerId==='LOCAL_SPEACH'){
687
+ return Boolean(this.#nativeSpeech(service,role));
688
+ }
689
+ return false;
690
+ }
691
+
692
+ #legacySpeechInspection(role,providerId,selection){
693
+ const localOnly=providerId==='LOCAL_SPEACH';
694
+ if(!selection
695
+ ||selection.providerId!==providerId
696
+ ||selection.modelId!==this.#legacySpeechModel(role)
697
+ ||selection.localOnly!==localOnly
698
+ ||this.#legacySpeechService(role)!==providerId){
699
+ return Object.freeze({
700
+ available:false,
701
+ code:'ARCANE_AI_MODEL_AUTHORITY_REQUIRED',
702
+ message:`The selected legacy ${role.toUpperCase()} route does not match the active AI configuration.`
703
+ });
704
+ }
705
+ if(!this.#legacySpeechCapability(role,providerId)){
706
+ return Object.freeze({
707
+ available:false,
708
+ code:providerId==='LOCAL_SPEACH'
709
+ ?'AI_NATIVE_LOCAL_REQUIRED'
710
+ :'AI_PROVIDER_NOT_CONFIGURED',
711
+ message:providerId==='LOCAL_SPEACH'
712
+ ?`Local ${role.toUpperCase()} requires the capability-gated Arcane API.`
713
+ :'AI provider is not configured.'
714
+ });
715
+ }
716
+ return Object.freeze({
717
+ available:true,
718
+ authority:Object.freeze({
719
+ protocol:AI_MODEL_AUTHORITY_PROTOCOL,
720
+ providerId,
721
+ modelId:selection.modelId,
722
+ admitted:true
723
+ })
724
+ });
725
+ }
726
+
727
+ #createLegacySpeechProvider(role,providerId){
728
+ const runtime=this;
729
+ const localOnly=providerId==='LOCAL_SPEACH';
730
+ const expectedOperation=role==='stt'?'transcribe':'synthesize';
731
+ let state='unloaded';
732
+ let busy=false;
733
+
734
+ function statusLegacySpeechProvider(){
735
+ if(state==='ready'
736
+ &&!busy
737
+ &&!runtime.#legacySpeechCapability(role,providerId)){
738
+ state='unloaded';
739
+ }
740
+ return Object.freeze({
741
+ state,
742
+ loaded:state==='ready',
743
+ busy
744
+ });
745
+ }
746
+
747
+ function assertLegacySpeechSelection(selection){
748
+ const inspection=runtime.#legacySpeechInspection(
749
+ role,
750
+ providerId,
751
+ selection
752
+ );
753
+ if(!inspection.available){
754
+ throw legacyAIProviderError(
755
+ inspection.message,
756
+ inspection.code
757
+ );
758
+ }
759
+ return inspection;
760
+ }
761
+
762
+ function releaseLegacySpeechRequest(){
763
+ busy=false;
764
+ }
765
+
766
+ return Object.freeze({
767
+ protocol:AI_PROVIDER_PROTOCOL,
768
+ role,
769
+ id:providerId,
770
+ localOnly,
771
+ catalog:function catalogLegacySpeechProvider(){
772
+ const model=runtime.#legacySpeechModel(role);
773
+ if(runtime.#legacySpeechService(role)!==providerId||!model){
774
+ return Object.freeze([]);
775
+ }
776
+ const defaultVoice=runtime.#legacySpeechDefaultVoice(
777
+ role,
778
+ providerId
779
+ );
780
+ return Object.freeze([
781
+ Object.freeze({
782
+ id:model,
783
+ ...(defaultVoice?{defaultVoice}:{})
784
+ })
785
+ ]);
786
+ },
787
+ inspect:function inspectLegacySpeechProvider(selection,{signal}={}){
788
+ if(signal?.aborted){
789
+ throw normalizeAIRequestAbort(signal.reason);
790
+ }
791
+ return runtime.#legacySpeechInspection(role,providerId,selection);
792
+ },
793
+ status:statusLegacySpeechProvider,
794
+ load:function loadLegacySpeechProvider(context={}){
795
+ if(context.signal?.aborted){
796
+ throw normalizeAIRequestAbort(context.signal.reason);
797
+ }
798
+ if(state==='disposed'){
799
+ throw legacyAIProviderError(
800
+ `The legacy ${role.toUpperCase()} provider is disposed.`,
801
+ 'ARCANE_AI_PROVIDER_DISPOSED'
802
+ );
803
+ }
804
+ if(busy){
805
+ throw legacyAIProviderError(
806
+ `The legacy ${role.toUpperCase()} provider owns an active request.`,
807
+ 'ARCANE_AI_ROLE_BUSY'
808
+ );
809
+ }
810
+ if(typeof context.progress!=='function'){
811
+ throw new TypeError(
812
+ `Legacy ${role.toUpperCase()} provider load progress must be a function.`
813
+ );
814
+ }
815
+ const inspection=assertLegacySpeechSelection(context.selection);
816
+ state='loading';
817
+ context.progress({
818
+ phase:'capability',
819
+ completed:0,
820
+ total:1,
821
+ unit:'items',
822
+ heartbeat:false
823
+ });
824
+ if(context.signal?.aborted){
825
+ state='unloaded';
826
+ throw normalizeAIRequestAbort(context.signal.reason);
827
+ }
828
+ state='ready';
829
+ context.progress({
830
+ phase:'capability',
831
+ completed:1,
832
+ total:1,
833
+ unit:'items',
834
+ heartbeat:false
835
+ });
836
+ return Object.freeze({
837
+ authority:inspection.authority,
838
+ status:statusLegacySpeechProvider()
839
+ });
840
+ },
841
+ request:function requestLegacySpeechProvider(context={}){
842
+ if(context.signal?.aborted){
843
+ throw normalizeAIRequestAbort(context.signal.reason);
844
+ }
845
+ assertLegacySpeechSelection(context.selection);
846
+ const current=statusLegacySpeechProvider();
847
+ if(current.state!=='ready'||!current.loaded){
848
+ throw legacyAIProviderError(
849
+ `The legacy ${role.toUpperCase()} provider is not ready.`,
850
+ 'ARCANE_AI_ROLE_NOT_READY'
851
+ );
852
+ }
853
+ if(busy){
854
+ throw legacyAIProviderError(
855
+ `The legacy ${role.toUpperCase()} provider owns an active request.`,
856
+ 'ARCANE_AI_ROLE_BUSY'
857
+ );
858
+ }
859
+ if(context.operation!==expectedOperation){
860
+ throw legacyAIProviderError(
861
+ `The legacy ${role.toUpperCase()} provider operation is unsupported.`,
862
+ 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
863
+ );
864
+ }
865
+ busy=true;
866
+ const request=role==='stt'
867
+ ?runtime.#requestLegacySpeechTranscription(
868
+ context.payload,
869
+ context.signal
870
+ )
871
+ :runtime.#requestLegacySpeechSynthesis(
872
+ context.payload,
873
+ context.signal
874
+ );
875
+ return Promise.resolve(request).finally(releaseLegacySpeechRequest);
876
+ },
877
+ unload:function unloadLegacySpeechProvider(context={}){
878
+ if(context.signal?.aborted){
879
+ throw normalizeAIRequestAbort(context.signal.reason);
880
+ }
881
+ if(busy){
882
+ throw legacyAIProviderError(
883
+ `The legacy ${role.toUpperCase()} provider still owns an active request.`,
884
+ 'ARCANE_AI_ROLE_BUSY'
885
+ );
886
+ }
887
+ state='unloaded';
888
+ return statusLegacySpeechProvider();
889
+ },
890
+ dispose:function disposeLegacySpeechProvider(context={}){
891
+ if(context.signal?.aborted){
892
+ throw normalizeAIRequestAbort(context.signal.reason);
893
+ }
894
+ if(busy){
895
+ throw legacyAIProviderError(
896
+ `The legacy ${role.toUpperCase()} provider still owns an active request.`,
897
+ 'ARCANE_AI_ROLE_BUSY'
898
+ );
899
+ }
900
+ state='disposed';
901
+ return statusLegacySpeechProvider();
902
+ }
903
+ });
904
+ }
905
+
906
+ #ensureLegacyLLMProvider(providerId){
907
+ if(providerId!=='OPENAI'&&providerId!=='OLLAMA'){
908
+ return false;
909
+ }
910
+ if(this.#providerRuntime.hasProvider('llm',providerId)){
911
+ return false;
912
+ }
913
+ const provider=this.#createLegacyLLMProvider(providerId);
914
+ const unregister=this.#providerRuntime.register(provider);
915
+ this.#legacyLLMProviders.set(
916
+ providerId,
917
+ Object.freeze({provider,unregister})
918
+ );
919
+ return true;
920
+ }
921
+
922
+ #ensureLegacySpeechProvider(role,providerId){
923
+ if(!['stt','tts'].includes(role)
924
+ ||!['OPENAI','LOCAL_SPEACH'].includes(providerId)){
925
+ return false;
926
+ }
927
+ if(this.#providerRuntime.hasProvider(role,providerId)){
928
+ return false;
929
+ }
930
+ const provider=this.#createLegacySpeechProvider(role,providerId);
931
+ const unregister=this.#providerRuntime.register(provider);
932
+ this.#legacySpeechProviders.set(
933
+ this.#legacySpeechProviderKey(role,providerId),
934
+ Object.freeze({role,providerId,provider,unregister})
935
+ );
936
+ return true;
937
+ }
938
+
939
+ #releaseInactiveLegacyLLMProviders(activeProviderId){
940
+ for(const [providerId,record] of this.#legacyLLMProviders){
941
+ if(providerId===activeProviderId){
942
+ continue;
943
+ }
944
+ if(record.unregister()){
945
+ this.#legacyLLMProviders.delete(providerId);
946
+ }
947
+ }
948
+ }
949
+
950
+ #releaseInactiveLegacySpeechProviders(activeProviders){
951
+ for(const [key,record] of this.#legacySpeechProviders){
952
+ if(activeProviders[record.role]===record.providerId){
953
+ continue;
954
+ }
955
+ if(record.unregister()){
956
+ this.#legacySpeechProviders.delete(key);
957
+ }
958
+ }
959
+ }
960
+
961
+ #internalLegacyLLMSelection(localOnly=false){
962
+ const selection=this.#providerRuntime.selection(
963
+ 'llm',
964
+ {localOnly}
965
+ );
966
+ if(!selection
967
+ ||!this.#legacyLLMProviders.has(selection.providerId)
968
+ ||selection.providerId!==this.llmService
969
+ ||selection.modelId!==this.model){
970
+ return null;
971
+ }
972
+ return selection;
973
+ }
974
+
975
+ #internalLegacySpeechSelection(role,localOnly=false){
976
+ const selection=this.#providerRuntime.selection(role,{localOnly});
977
+ if(!selection
978
+ ||!this.#legacySpeechProviders.has(
979
+ this.#legacySpeechProviderKey(role,selection.providerId)
980
+ )
981
+ ||selection.providerId!==this.#legacySpeechService(role)
982
+ ||selection.modelId!==this.#legacySpeechModel(role)){
983
+ return null;
984
+ }
985
+ return selection;
986
+ }
987
+
988
+ #retainLegacyLLMReadiness(operation){
989
+ this.#legacyLLMReadiness=Promise.resolve(operation).catch(
990
+ function retainLegacyLLMReadinessFailure(){
991
+ return null;
992
+ }
993
+ );
994
+ return this.#legacyLLMReadiness;
995
+ }
996
+
997
+ #retainLegacySpeechReadiness(operation){
998
+ this.#legacySpeechReadiness=Promise.resolve(operation).catch(
999
+ function retainLegacySpeechReadinessFailure(){
1000
+ return null;
1001
+ }
1002
+ );
1003
+ return this.#legacySpeechReadiness;
1004
+ }
1005
+
1006
+ #reconcileLegacyLLMReadiness(){
1007
+ const selection=this.#internalLegacyLLMSelection(false);
1008
+ if(!selection){
1009
+ return Promise.resolve(this.#providerRuntime.status('llm'));
1010
+ }
1011
+ const status=this.#providerRuntime.status('llm');
1012
+ if(this.#legacyLLMCapability(selection.providerId)){
1013
+ if(status.state==='ready'&&status.loaded===true){
1014
+ return Promise.resolve(status);
1015
+ }
1016
+ return this.#providerRuntime.load('llm');
1017
+ }
1018
+ if(status.loaded===true
1019
+ ||status.busy===true
1020
+ ||status.state==='loading'
1021
+ ||status.state==='unloading'){
1022
+ return this.#providerRuntime.unload('llm');
1023
+ }
1024
+ return Promise.resolve(status);
1025
+ }
1026
+
1027
+ #reconcileLegacySpeechReadiness(){
1028
+ const runtime=this;
1029
+ return Promise.all(['stt','tts'].map(function reconcileLegacySpeechRole(role){
1030
+ const selection=runtime.#internalLegacySpeechSelection(role,false);
1031
+ if(!selection){
1032
+ return runtime.#providerRuntime.status(role);
1033
+ }
1034
+ const status=runtime.#providerRuntime.status(role);
1035
+ if(runtime.#legacySpeechCapability(role,selection.providerId)){
1036
+ return status;
1037
+ }
1038
+ if(status.loaded===true
1039
+ ||status.busy===true
1040
+ ||status.state==='loading'
1041
+ ||status.state==='unloading'){
1042
+ return runtime.#providerRuntime.unload(role);
1043
+ }
1044
+ return status;
1045
+ }));
1046
+ }
1047
+
246
1048
  get configured(){
247
1049
  if(this.#usesProviderRuntime('llm',this.llmService)){
1050
+ if(this.#internalLegacyLLMSelection(false)
1051
+ &&!this.#legacyLLMCapability(this.llmService)){
1052
+ return false;
1053
+ }
248
1054
  const state=this.#providerRuntime.status('llm');
249
1055
  return state.state==='ready'&&state.loaded===true;
250
1056
  }
@@ -259,6 +1065,26 @@ class AI {
259
1065
 
260
1066
  #assertServiceConfigured(service=this.llmService,role='llm'){
261
1067
  if(this.#usesProviderRuntime(role,service)){
1068
+ const internal=role==='llm'
1069
+ ?this.#internalLegacyLLMSelection(false)
1070
+ :this.#internalLegacySpeechSelection(role,false);
1071
+ const internalAvailable=!internal
1072
+ ||(role==='llm'
1073
+ ?this.#legacyLLMCapability(internal.providerId)
1074
+ :this.#legacySpeechCapability(role,internal.providerId));
1075
+ if(!internalAvailable){
1076
+ const inspection=role==='llm'
1077
+ ?this.#legacyLLMInspection(internal.providerId,internal)
1078
+ :this.#legacySpeechInspection(
1079
+ role,
1080
+ internal.providerId,
1081
+ internal
1082
+ );
1083
+ throw legacyAIProviderError(
1084
+ inspection.message,
1085
+ inspection.code
1086
+ );
1087
+ }
262
1088
  return true;
263
1089
  }
264
1090
  if(service==='OLLAMA'){
@@ -298,6 +1124,11 @@ class AI {
298
1124
  }
299
1125
 
300
1126
  #shouldUseProviderRuntime(role,service,localOnly=false){
1127
+ // Legacy adapters publish lifecycle without replacing established
1128
+ // public transport callbacks or their cancellation behavior.
1129
+ if(role==='llm'&&this.#internalLegacyLLMSelection(localOnly)){
1130
+ return false;
1131
+ }
301
1132
  if(!localOnly){
302
1133
  return this.#usesProviderRuntime(role,service);
303
1134
  }
@@ -361,6 +1192,19 @@ class AI {
361
1192
  }
362
1193
  }
363
1194
 
1195
+ #normalizedLLMModel(service,model){
1196
+ if(service==='OLLAMA'){
1197
+ const mappedModel=model==='OPENAI'?null:this.#models[model];
1198
+ return mappedModel
1199
+ ||normalizeOllamaModelIdentifier(model)
1200
+ ||model;
1201
+ }
1202
+ if(service==='OPENAI'){
1203
+ return this.#models.OPENAI;
1204
+ }
1205
+ return model;
1206
+ }
1207
+
364
1208
  #applyPreferenceTuple(tuple){
365
1209
  const [
366
1210
  llmService,
@@ -369,16 +1213,11 @@ class AI {
369
1213
  model,
370
1214
  modelTTS,
371
1215
  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
- }
1216
+ ]=tuple;
1217
+ const normalizedLLMModel=this.#normalizedLLMModel(
1218
+ llmService,
1219
+ model
1220
+ );
382
1221
  this.llmService=llmService;
383
1222
  this.sttService=sttService;
384
1223
  this.ttsService=ttsService;
@@ -405,9 +1244,12 @@ class AI {
405
1244
 
406
1245
  #routesFromPreferenceTuple(tuple){
407
1246
  const roles={
408
- llm:[tuple[0],tuple[3]],
409
- stt:[tuple[1],tuple[5]],
410
- tts:[tuple[2],tuple[4]]
1247
+ llm:[
1248
+ tuple[0],
1249
+ this.#normalizedLLMModel(tuple[0],tuple[3])
1250
+ ],
1251
+ stt:[tuple[1],this.#sttModels[tuple[5]]||tuple[5]],
1252
+ tts:[tuple[2],this.#ttsModels[tuple[4]]||tuple[4]]
411
1253
  };
412
1254
  const selections={};
413
1255
  for(const role of ['llm','stt','tts']){
@@ -501,15 +1343,54 @@ class AI {
501
1343
  modelSTT
502
1344
  ]);
503
1345
  this.#assertValidProviderTuple(tuple);
1346
+ this.#ensureLegacyLLMProvider(tuple[0]);
1347
+ this.#ensureLegacySpeechProvider('stt',tuple[1]);
1348
+ this.#ensureLegacySpeechProvider('tts',tuple[2]);
504
1349
  this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
505
1350
  this.#applyPreferenceTuple(tuple);
1351
+ this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1352
+ this.#releaseInactiveLegacySpeechProviders({
1353
+ stt:tuple[1],
1354
+ tts:tuple[2]
1355
+ });
1356
+ this.#retainLegacyLLMReadiness(
1357
+ this.#reconcileLegacyLLMReadiness()
1358
+ );
1359
+ this.#retainLegacySpeechReadiness(
1360
+ this.#reconcileLegacySpeechReadiness()
1361
+ );
506
1362
  return true;
507
1363
  }
508
1364
 
509
1365
  configureProviders(selections){
510
- this.#assertRegisteredLegacyRoutes(selections);
511
- const configured=this.#providerRuntime.configure(selections);
1366
+ const prepared=this.#providerRuntime.validateConfiguration(selections);
1367
+ this.#ensureLegacyLLMProvider(
1368
+ prepared.llm.default?.providerId
1369
+ );
1370
+ this.#ensureLegacySpeechProvider(
1371
+ 'stt',
1372
+ prepared.stt.default?.providerId
1373
+ );
1374
+ this.#ensureLegacySpeechProvider(
1375
+ 'tts',
1376
+ prepared.tts.default?.providerId
1377
+ );
1378
+ this.#assertRegisteredLegacyRoutes(prepared);
1379
+ const configured=this.#providerRuntime.configure(prepared);
512
1380
  this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1381
+ this.#releaseInactiveLegacyLLMProviders(
1382
+ configured.llm.default?.providerId
1383
+ );
1384
+ this.#releaseInactiveLegacySpeechProviders({
1385
+ stt:configured.stt.default?.providerId,
1386
+ tts:configured.tts.default?.providerId
1387
+ });
1388
+ this.#retainLegacyLLMReadiness(
1389
+ this.#reconcileLegacyLLMReadiness()
1390
+ );
1391
+ this.#retainLegacySpeechReadiness(
1392
+ this.#reconcileLegacySpeechReadiness()
1393
+ );
513
1394
  return configured;
514
1395
  }
515
1396
 
@@ -532,18 +1413,48 @@ class AI {
532
1413
  this.#assertValidProviderTuple(tuple);
533
1414
  this.stopAudio();
534
1415
  await this.#unloadProviderRolesForTransition();
1416
+ this.#ensureLegacyLLMProvider(tuple[0]);
1417
+ this.#ensureLegacySpeechProvider('stt',tuple[1]);
1418
+ this.#ensureLegacySpeechProvider('tts',tuple[2]);
535
1419
  this.#providerRuntime.configure(this.#routesFromPreferenceTuple(tuple));
536
1420
  this.#applyPreferenceTuple(tuple);
1421
+ this.#releaseInactiveLegacyLLMProviders(tuple[0]);
1422
+ this.#releaseInactiveLegacySpeechProviders({
1423
+ stt:tuple[1],
1424
+ tts:tuple[2]
1425
+ });
1426
+ await this.#reconcileLegacyLLMReadiness();
1427
+ await this.#reconcileLegacySpeechReadiness();
537
1428
  return this.#providerRuntime.status();
538
1429
  }
539
1430
 
540
1431
  async transitionProviders(selections){
541
- this.#assertRegisteredLegacyRoutes(selections);
542
1432
  const prepared=this.#providerRuntime.validateConfiguration(selections);
1433
+ this.#ensureLegacyLLMProvider(
1434
+ prepared.llm.default?.providerId
1435
+ );
1436
+ this.#ensureLegacySpeechProvider(
1437
+ 'stt',
1438
+ prepared.stt.default?.providerId
1439
+ );
1440
+ this.#ensureLegacySpeechProvider(
1441
+ 'tts',
1442
+ prepared.tts.default?.providerId
1443
+ );
1444
+ this.#assertRegisteredLegacyRoutes(prepared);
543
1445
  this.stopAudio();
544
1446
  await this.#unloadProviderRolesForTransition();
545
1447
  const configured=this.#providerRuntime.configure(prepared);
546
1448
  this.#applyPreferenceTuple(this.#tupleFromProviderRoutes(configured));
1449
+ this.#releaseInactiveLegacyLLMProviders(
1450
+ configured.llm.default?.providerId
1451
+ );
1452
+ this.#releaseInactiveLegacySpeechProviders({
1453
+ stt:configured.stt.default?.providerId,
1454
+ tts:configured.tts.default?.providerId
1455
+ });
1456
+ await this.#reconcileLegacyLLMReadiness();
1457
+ await this.#reconcileLegacySpeechReadiness();
547
1458
  return configured;
548
1459
  }
549
1460
 
@@ -632,6 +1543,127 @@ class AI {
632
1543
  return null;
633
1544
  }
634
1545
 
1546
+ async #requestLegacySpeechTranscription(payload={},signal=null){
1547
+ const audio=payload?.audio;
1548
+ if(!audio||typeof audio.arrayBuffer!=='function'){
1549
+ throw new TypeError('Speech transcription requires an audio Blob or File.');
1550
+ }
1551
+ if(signal?.aborted){
1552
+ throw normalizeAIRequestAbort(signal.reason);
1553
+ }
1554
+ const mimeType=String(payload.mimeType||audio.type||'audio/webm');
1555
+ const model=String(payload.model||this.modelSTT);
1556
+ const nativeSpeech=this.#nativeSpeech(this.sttService,'stt');
1557
+ if(nativeSpeech){
1558
+ const audioBytes=await audio.arrayBuffer();
1559
+ if(signal?.aborted){
1560
+ throw normalizeAIRequestAbort(signal.reason);
1561
+ }
1562
+ const response=await nativeSpeech.transcribe({
1563
+ audioBase64:this.#arrayBufferToBase64(audioBytes),
1564
+ mimeType,
1565
+ model
1566
+ });
1567
+ if(signal?.aborted){
1568
+ throw normalizeAIRequestAbort(signal.reason);
1569
+ }
1570
+ if(!response||typeof response.text!=='string'){
1571
+ throw new TypeError('Arcane returned an invalid local speech transcription.');
1572
+ }
1573
+ return response.text;
1574
+ }
1575
+
1576
+ await this.#assertAndroidSpeechBridge(this.sttService);
1577
+ const formData=new FormData();
1578
+ formData.append('file',audio);
1579
+ formData.append('model',model);
1580
+ formData.append('response_format','text');
1581
+ const response=await fetch(
1582
+ this.urlSTT,
1583
+ {
1584
+ method:'POST',
1585
+ credentials,
1586
+ headers:this.#sttHeaders[this.sttService],
1587
+ body:formData,
1588
+ signal
1589
+ }
1590
+ );
1591
+ if(!response.ok){
1592
+ throw new Error(`Speech transcription failed with status ${response.status}.`);
1593
+ }
1594
+ return response.text();
1595
+ }
1596
+
1597
+ async #requestLegacySpeechSynthesis(payload={},signal=null){
1598
+ const input=typeof payload?.input==='string'?payload.input:'';
1599
+ if(!input){
1600
+ throw new TypeError('Speech synthesis requires nonempty input.');
1601
+ }
1602
+ if(signal?.aborted){
1603
+ throw normalizeAIRequestAbort(signal.reason);
1604
+ }
1605
+ const model=String(payload.model||this.modelTTS);
1606
+ const voice=typeof payload.voice==='string'&&payload.voice.trim()
1607
+ ?payload.voice.trim()
1608
+ :this.#legacySpeechDefaultVoice('tts',this.ttsService);
1609
+ if(!voice){
1610
+ throw new TypeError('The selected speech provider requires a voice.');
1611
+ }
1612
+ const responseFormat=String(payload.responseFormat||this.audioFormat);
1613
+ const speed=Number.isFinite(payload.speed)?payload.speed:this.voiceSpeed;
1614
+ const nativeSpeech=this.#nativeSpeech(this.ttsService,'tts');
1615
+ if(nativeSpeech){
1616
+ const response=await nativeSpeech.synthesize({
1617
+ model,
1618
+ voice,
1619
+ input,
1620
+ responseFormat,
1621
+ speed
1622
+ });
1623
+ if(signal?.aborted){
1624
+ throw normalizeAIRequestAbort(signal.reason);
1625
+ }
1626
+ if(!response||typeof response.audioBase64!=='string'){
1627
+ throw new TypeError('Arcane returned an invalid local speech response.');
1628
+ }
1629
+ return new Blob(
1630
+ [this.#base64ToBytes(response.audioBase64)],
1631
+ {
1632
+ type:typeof response.contentType==='string'
1633
+ ?response.contentType
1634
+ :this.audioType
1635
+ }
1636
+ );
1637
+ }
1638
+
1639
+ await this.#assertAndroidSpeechBridge(this.ttsService);
1640
+ const personality=await window.user?.personality
1641
+ ||'A behavioral health technician with a slight veteran feel on occasion.';
1642
+ const religion=await window.user?.religion||'caring';
1643
+ const response=await fetch(
1644
+ this.urlTTS,
1645
+ {
1646
+ method:'POST',
1647
+ credentials,
1648
+ headers:this.#ttsHeaders[this.ttsService],
1649
+ body:JSON.stringify({
1650
+ model,
1651
+ voice,
1652
+ input,
1653
+ speed,
1654
+ instructions:`${personality} and sounding a bit ${religion}`,
1655
+ response_format:responseFormat
1656
+ }),
1657
+ signal
1658
+ }
1659
+ );
1660
+ if(!response.ok){
1661
+ throw new Error(`Speech synthesis failed with status ${response.status}.`);
1662
+ }
1663
+ const contentType=response.headers.get('content-type')||this.audioType;
1664
+ return new Blob([await response.arrayBuffer()],{type:contentType});
1665
+ }
1666
+
635
1667
  async #androidNativeHost(){
636
1668
  if(typeof globalThis.arcaneAndroid?.postMessage==='function'){
637
1669
  return true;
@@ -912,6 +1944,55 @@ class AI {
912
1944
  return typeof content==='string'?content:completion;
913
1945
  }
914
1946
 
1947
+ #requestLegacyLLMChat(payload={},signal=null){
1948
+ return this.#fetchLegacy(
1949
+ payload.messages??[],
1950
+ function ignoreLegacyLLMProviderResponse(){},
1951
+ payload.structuredOutput??false,
1952
+ payload.tools??[],
1953
+ payload.toolChoice??'auto',
1954
+ payload.parallelToolCalls??true,
1955
+ payload.id??Date.now(),
1956
+ function ignoreLegacyLLMProviderRequest(){},
1957
+ signal
1958
+ );
1959
+ }
1960
+
1961
+ #requestLegacyLLMStream(payload={},bridge){
1962
+ function emitLegacyLLMStreamText(text,id,thinking){
1963
+ if(typeof text!=='string'||!text){
1964
+ return;
1965
+ }
1966
+ bridge.emit(
1967
+ thinking
1968
+ ?{thinking:text}
1969
+ :{content:text}
1970
+ );
1971
+ }
1972
+
1973
+ function emitLegacyLLMStreamTool(name){
1974
+ if(typeof name==='string'&&name){
1975
+ bridge.emit({toolCalls:[{name}]});
1976
+ }
1977
+ }
1978
+
1979
+ return this.#streamLegacyMessage(
1980
+ payload.messages??[],
1981
+ emitLegacyLLMStreamText,
1982
+ function ignoreLegacyLLMProviderCompletion(){},
1983
+ payload.tools??[],
1984
+ payload.toolChoice??'auto',
1985
+ emitLegacyLLMStreamTool,
1986
+ payload.parallelToolCalls??true,
1987
+ payload.id??Date.now(),
1988
+ payload.seeThinking??false,
1989
+ bridge.signal,
1990
+ function ignoreLegacyLLMProviderRequest(){},
1991
+ payload.structuredOutput??false,
1992
+ false
1993
+ );
1994
+ }
1995
+
915
1996
  #assertRequiredOllamaToolCall(toolCalls=[],toolChoice='auto'){
916
1997
  const requiredName=toolChoice?.function?.name;
917
1998
 
@@ -1152,6 +2233,38 @@ class AI {
1152
2233
  signal
1153
2234
  });
1154
2235
  }
2236
+
2237
+ return this.#streamLegacyMessage(
2238
+ messages,
2239
+ streamHandler,
2240
+ streamComplete,
2241
+ tools,
2242
+ tool_choice,
2243
+ earlyFunctionTrigger,
2244
+ parallel_tool_calls,
2245
+ id,
2246
+ seeThinking,
2247
+ signal,
2248
+ requestHandler,
2249
+ structuredOutput
2250
+ );
2251
+ }
2252
+
2253
+ async #streamLegacyMessage(
2254
+ messages=[],
2255
+ streamHandler=function ignoreStreamChunk(){},
2256
+ streamComplete=function finishIgnoredStream(){},
2257
+ tools=[],
2258
+ tool_choice='auto',
2259
+ earlyFunctionTrigger=function ignoreEarlyFunction(){},
2260
+ parallel_tool_calls=true,
2261
+ id=Date.now(),
2262
+ seeThinking=false,
2263
+ signal=null,
2264
+ requestHandler=function ignoreStreamRequest(){},
2265
+ structuredOutput=false,
2266
+ finishSpeech=true
2267
+ ){
1155
2268
  let speechTurnCompleted=false;
1156
2269
 
1157
2270
  try{
@@ -1198,6 +2311,13 @@ class AI {
1198
2311
 
1199
2312
  const nativeOllama=this.#nativeOllama();
1200
2313
 
2314
+ if(this.llmService==='OLLAMA'&&!nativeOllama){
2315
+ throw legacyAIProviderError(
2316
+ 'Local AI requires the capability-gated Arcane API.',
2317
+ 'AI_NATIVE_LOCAL_REQUIRED'
2318
+ );
2319
+ }
2320
+
1201
2321
  if(nativeOllama){
1202
2322
  let nativeContent='';
1203
2323
  const nativeToolCalls={};
@@ -1294,7 +2414,9 @@ class AI {
1294
2414
  if(Object.keys(nativeToolCalls).length&&!nativeContent){
1295
2415
  streamHandler('',`M-${id}`,false);
1296
2416
  }
1297
- this.finishTTS();
2417
+ if(finishSpeech){
2418
+ this.finishTTS();
2419
+ }
1298
2420
  await streamComplete(nativeResult,`M-${id}`,isThinking);
1299
2421
  speechTurnCompleted=true;
1300
2422
  return nativeResult;
@@ -1496,7 +2618,9 @@ class AI {
1496
2618
  if(Object.keys(tool_funcs).length&&!chunkString){
1497
2619
  streamHandler('',`M-${id}`,false);
1498
2620
  }
1499
- this.finishTTS();
2621
+ if(finishSpeech){
2622
+ this.finishTTS();
2623
+ }
1500
2624
  await streamComplete(streamResult, `M-${id}`,isThinking);
1501
2625
 
1502
2626
  //sync
@@ -1609,6 +2733,31 @@ class AI {
1609
2733
  onResponse:responseHandler
1610
2734
  });
1611
2735
  }
2736
+
2737
+ return this.#fetchLegacy(
2738
+ messages,
2739
+ responseHandler,
2740
+ structuredOutput,
2741
+ tools,
2742
+ tool_choice,
2743
+ parallel_tool_calls,
2744
+ id,
2745
+ requestHandler,
2746
+ signal
2747
+ );
2748
+ }
2749
+
2750
+ async #fetchLegacy(
2751
+ messages=[],
2752
+ responseHandler=function ignoreFetchResponse(){},
2753
+ structuredOutput=false,
2754
+ tools=[],
2755
+ tool_choice='auto',
2756
+ parallel_tool_calls=true,
2757
+ id=Date.now(),
2758
+ requestHandler=function ignoreFetchRequest(){},
2759
+ signal=null,
2760
+ ){
1612
2761
  this.#assertServiceConfigured(this.llmService);
1613
2762
  if(signal&&(
1614
2763
  typeof signal.aborted!=='boolean'
@@ -1645,6 +2794,13 @@ class AI {
1645
2794
 
1646
2795
  const nativeOllama=this.#nativeOllama();
1647
2796
 
2797
+ if(this.llmService==='OLLAMA'&&!nativeOllama){
2798
+ throw legacyAIProviderError(
2799
+ 'Local AI requires the capability-gated Arcane API.',
2800
+ 'AI_NATIVE_LOCAL_REQUIRED'
2801
+ );
2802
+ }
2803
+
1648
2804
  if(nativeOllama){
1649
2805
  const ollamaTools=this.#ollamaTools(tools,tool_choice);
1650
2806
  const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
@@ -1931,15 +3087,17 @@ class AI {
1931
3087
  async #requestSpeechAudio(job){
1932
3088
  if(this.#usesProviderRuntime('tts',this.ttsService)){
1933
3089
  job.abortController=new AbortController();
3090
+ const responseFormat=this.#providerSpeechResponseFormat();
3091
+ const voice=this.#providerSpeechVoice();
1934
3092
  const response=await this.#providerRuntime.request(
1935
3093
  'tts',
1936
3094
  {
1937
3095
  operation:'synthesize',
1938
3096
  payload:{
1939
3097
  model:this.#providerRuntime.selection('tts')?.modelId,
1940
- voice:String(window.user?.AI_voice||'af_heart'),
1941
3098
  input:job.text,
1942
- responseFormat:this.audioFormat,
3099
+ responseFormat,
3100
+ ...(voice?{voice}:{}),
1943
3101
  speed:this.voiceSpeed
1944
3102
  },
1945
3103
  localOnly:false,
@@ -1949,83 +3107,74 @@ class AI {
1949
3107
  return this.#normalizeProviderSpeechAudio(response);
1950
3108
  }
1951
3109
 
1952
- const nativeSpeech=this.#nativeSpeech(this.ttsService,'tts');
1953
-
1954
- if(nativeSpeech){
1955
- const response=await nativeSpeech.synthesize({
3110
+ job.abortController=new AbortController();
3111
+ const response=await this.#requestLegacySpeechSynthesis(
3112
+ {
1956
3113
  model:this.modelTTS,
1957
- voice:String(window.user?.AI_voice||'af_heart'),
1958
3114
  input:job.text,
1959
3115
  responseFormat:this.audioFormat,
1960
3116
  speed:this.voiceSpeed
1961
- });
1962
-
1963
- if(!response||typeof response.audioBase64!=='string'){
1964
- throw new TypeError('Arcane returned an invalid local speech response.');
1965
- }
3117
+ },
3118
+ job.abortController.signal
3119
+ );
3120
+ return this.#normalizeProviderSpeechAudio(response);
3121
+ }
1966
3122
 
1967
- return {
1968
- chunks:[this.#base64ToBytes(response.audioBase64)],
1969
- type:typeof response.contentType==='string'
1970
- ?response.contentType
1971
- :this.audioType
1972
- };
3123
+ #providerSpeechVoice(){
3124
+ const selection=this.#providerRuntime.selection('tts');
3125
+ if(!selection){
3126
+ return null;
1973
3127
  }
1974
-
1975
- await this.#assertAndroidSpeechBridge(this.ttsService);
1976
-
1977
- job.abortController=new AbortController();
1978
- const personality=await window.user?.personality
1979
- ||'A behavioral health technician with a slight veteran feel on occasion.';
1980
- const religion=await window.user?.religion||'caring';
1981
- const request={
1982
- model:this.modelTTS,
1983
- voice:window.user?.AI_voice,
1984
- input:job.text,
1985
- speed:this.voiceSpeed,
1986
- instructions:`${personality} and sounding a bit ${religion}`,
1987
- response_format:this.audioFormat
1988
- };
1989
- const response=await fetch(
1990
- this.urlTTS,
1991
- {
1992
- method:'POST',
1993
- credentials,
1994
- headers:this.#ttsHeaders[this.ttsService],
1995
- body:JSON.stringify(request),
1996
- signal:job.abortController.signal
1997
- }
3128
+ const provider=this.#providerRuntime.catalog('tts').find(
3129
+ entry=>entry.providerId===selection.providerId
1998
3130
  );
3131
+ const model=provider?.models.find(entry=>entry?.id===selection.modelId);
3132
+ return typeof model?.defaultVoice==='string'&&model.defaultVoice.trim()
3133
+ ?model.defaultVoice.trim()
3134
+ :null;
3135
+ }
1999
3136
 
2000
- if(!response.ok){
2001
- throw new Error(`Speech synthesis failed with status ${response.status}.`);
3137
+ #providerSpeechResponseFormat(){
3138
+ const selection=this.#providerRuntime.selection('tts');
3139
+ if(!selection){
3140
+ return this.audioFormat;
2002
3141
  }
2003
-
2004
- const reader=response.body?.getReader?.();
2005
-
2006
- if(!reader){
2007
- throw new TypeError('Speech synthesis response body is not readable.');
3142
+ const provider=this.#providerRuntime.catalog('tts').find(
3143
+ entry=>entry.providerId===selection.providerId
3144
+ );
3145
+ const model=provider?.models.find(entry=>entry?.id===selection.modelId);
3146
+ const speech=model?.speech;
3147
+ if(speech===undefined){
3148
+ return this.audioFormat;
2008
3149
  }
2009
-
2010
- const chunks=[];
2011
-
2012
- try{
2013
- while(true){
2014
- const {done,value}=await reader.read();
2015
-
2016
- if(done){
2017
- break;
2018
- }
2019
-
2020
- if(value){
2021
- chunks.push(value);
2022
- }
2023
- }
2024
- }finally{
2025
- reader.releaseLock?.();
3150
+ const prototype=speech&&typeof speech==='object'
3151
+ ?Object.getPrototypeOf(speech)
3152
+ :null;
3153
+ const descriptors=prototype===Object.prototype||prototype===null
3154
+ ?Object.getOwnPropertyDescriptors(speech)
3155
+ :null;
3156
+ const formats=descriptors?.responseFormats?.value;
3157
+ const defaultFormat=descriptors?.defaultResponseFormat?.value;
3158
+ if(!Array.isArray(formats)
3159
+ ||formats.length<1
3160
+ ||!formats.every(format=>typeof format==='string'&&format.trim()===format&&format)
3161
+ ||typeof defaultFormat!=='string'
3162
+ ||!formats.includes(defaultFormat)){
3163
+ throw legacyAIProviderError(
3164
+ 'The selected TTS provider returned an invalid speech format catalog.',
3165
+ 'ARCANE_AI_PROVIDER_RUNTIME_INVALID'
3166
+ );
2026
3167
  }
2027
-
2028
- return {chunks,type:this.audioType};
3168
+ if(formats.includes(this.audioFormat)){
3169
+ return this.audioFormat;
3170
+ }
3171
+ if(this.audioFormat===LEGACY_TTS_RESPONSE_FORMAT){
3172
+ return defaultFormat;
3173
+ }
3174
+ throw legacyAIProviderError(
3175
+ `The selected TTS provider does not support ${this.audioFormat}.`,
3176
+ 'ARCANE_AI_UNSUPPORTED_RESPONSE_FORMAT'
3177
+ );
2029
3178
  }
2030
3179
 
2031
3180
  async #normalizeProviderSpeechAudio(response){
@@ -2148,55 +3297,15 @@ class AI {
2148
3297
  return text;
2149
3298
  }
2150
3299
 
2151
- const nativeSpeech=this.#nativeSpeech(this.sttService,'stt');
2152
-
2153
- if(nativeSpeech){
2154
- if(!audioFile||typeof audioFile.arrayBuffer!=='function'){
2155
- throw new TypeError('Speech transcription requires an audio Blob or File.');
2156
- }
2157
-
2158
- const response=await nativeSpeech.transcribe({
2159
- audioBase64:this.#arrayBufferToBase64(await audioFile.arrayBuffer()),
2160
- mimeType:String(audioFile.type||'audio/webm'),
2161
- model:this.modelSTT
2162
- });
2163
-
2164
- if(!response||typeof response.text!=='string'){
2165
- throw new TypeError('Arcane returned an invalid local speech transcription.');
2166
- }
2167
-
2168
- await responseHandler(response.text);
2169
- return response.text;
2170
- }
2171
-
2172
- await this.#assertAndroidSpeechBridge(this.sttService);
2173
-
2174
- const formData = new FormData();
2175
- formData.append('file', audioFile);
2176
- formData.append('model', this.modelSTT);
2177
- formData.append('response_format', 'text');
2178
-
2179
- const response = await fetch(
2180
- this.urlSTT,
3300
+ const text=await this.#requestLegacySpeechTranscription(
2181
3301
  {
2182
- method: 'POST',
2183
- credentials: credentials,
2184
- headers: this.#sttHeaders[this.sttService],
2185
- body: formData,
2186
- signal
2187
- }
3302
+ audio:audioFile,
3303
+ mimeType:String(audioFile?.type||'audio/webm'),
3304
+ model:this.modelSTT
3305
+ },
3306
+ signal
2188
3307
  );
2189
-
2190
- if(!response.ok){
2191
- throw new Error(`Speech transcription failed with status ${response.status}.`);
2192
- }
2193
-
2194
- const text = await response.text();
2195
-
2196
- //async
2197
3308
  await responseHandler(text);
2198
-
2199
- //sync
2200
3309
  return text;
2201
3310
  }
2202
3311