arcane-os 0.1.0-dev.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/COMMERCIAL-LICENSE.md +13 -0
- package/LICENSE +661 -0
- package/NOTICE +70 -0
- package/README.md +448 -0
- package/bin/arcane-test.mjs +741 -0
- package/bin/arcane.mjs +5 -0
- package/docs/architecture.md +234 -0
- package/docs/compatibility.md +36 -0
- package/docs/event-manager.md +166 -0
- package/docs/platform-targets.md +108 -0
- package/docs/publishing.md +203 -0
- package/docs/reference/README.md +117 -0
- package/docs/reference/arcane-ollama.md +288 -0
- package/docs/reference/availability-and-normalization.md +123 -0
- package/docs/reference/behavioral-testing.md +86 -0
- package/docs/reference/cli.md +569 -0
- package/docs/reference/core/README.md +62 -0
- package/docs/reference/core/arcane-ai-contracts.md +873 -0
- package/docs/reference/core/arcane-api.md +601 -0
- package/docs/reference/core/arcane-entities.md +65 -0
- package/docs/reference/core/arcane-events.md +134 -0
- package/docs/reference/core/ollama-module.md +181 -0
- package/docs/reference/core/reference/arcane-api/ai-and-ollama.md +1909 -0
- package/docs/reference/core/reference/arcane-api/applications-terminal-capabilities.md +1057 -0
- package/docs/reference/core/reference/arcane-api/core-and-events.md +320 -0
- package/docs/reference/core/reference/arcane-api/filesystem-storage-preferences-appearance.md +610 -0
- package/docs/reference/core/reference/arcane-api/namespaces.md +1157 -0
- package/docs/reference/core/reference/arcane-api/platform-installation-users-system.md +1423 -0
- package/docs/reference/core/reference/arcane-api/session-provisioning-diagnostics-development.md +315 -0
- package/docs/reference/event-manager.md +957 -0
- package/docs/reference/inventory/package-api.json +2632 -0
- package/docs/reference/inventory/runtime-components.json +934 -0
- package/docs/reference/inventory/runtime-entities.json +26 -0
- package/docs/reference/inventory/runtime-modules.json +1249 -0
- package/docs/reference/protocols.md +242 -0
- package/docs/reference/runtime-components.md +1098 -0
- package/docs/reference/runtime-entities.md +303 -0
- package/docs/reference/runtime-modules.md +2010 -0
- package/docs/reference/sdk-api.md +4901 -0
- package/docs/roadmap.md +79 -0
- package/docs/work-amplification.md +124 -0
- package/node_modules/event-pubsub/CHANGELOG.md +55 -0
- package/node_modules/event-pubsub/MIGRATION.md +70 -0
- package/node_modules/event-pubsub/README.md +363 -0
- package/node_modules/event-pubsub/SECURITY.md +37 -0
- package/node_modules/event-pubsub/index.js +141 -0
- package/node_modules/event-pubsub/licence +21 -0
- package/node_modules/event-pubsub/package.json +59 -0
- package/node_modules/strong-type/README.md +408 -0
- package/node_modules/strong-type/assets/strong-type-header.png +0 -0
- package/node_modules/strong-type/index.js +1151 -0
- package/node_modules/strong-type/licence +21 -0
- package/node_modules/strong-type/node.js +125 -0
- package/node_modules/strong-type/package.json +61 -0
- package/package.json +95 -0
- package/runtime/ARCANE_RUNTIME_RELEASE.json +791 -0
- package/runtime/arcane/components/app-bar.html +468 -0
- package/runtime/arcane/components/assistant-panel.html +715 -0
- package/runtime/arcane/components/calculator.html +8 -0
- package/runtime/arcane/components/chart.html +655 -0
- package/runtime/arcane/components/chat.html +1225 -0
- package/runtime/arcane/components/conversation-view.html +13 -0
- package/runtime/arcane/components/dashboard-config.html +341 -0
- package/runtime/arcane/components/data-maintenance.html +112 -0
- package/runtime/arcane/components/data-view.html +92 -0
- package/runtime/arcane/components/directory-picker.html +197 -0
- package/runtime/arcane/components/document-inspector.html +252 -0
- package/runtime/arcane/components/file-drop.html +264 -0
- package/runtime/arcane/components/file-inspector.html +293 -0
- package/runtime/arcane/components/file-manager.html +1715 -0
- package/runtime/arcane/components/header.html +142 -0
- package/runtime/arcane/components/integration-settings.html +14 -0
- package/runtime/arcane/components/local-ai-status.html +360 -0
- package/runtime/arcane/components/markdown-document.html +1048 -0
- package/runtime/arcane/components/markdown-editor.html +360 -0
- package/runtime/arcane/components/media-embed.html +8 -0
- package/runtime/arcane/components/modal.html +402 -0
- package/runtime/arcane/components/output-panel.html +259 -0
- package/runtime/arcane/components/preferences-form.html +135 -0
- package/runtime/arcane/components/record-timeline.html +105 -0
- package/runtime/arcane/components/relationship-board.html +116 -0
- package/runtime/arcane/components/screen-capture.html +8 -0
- package/runtime/arcane/components/source-code-viewer.html +441 -0
- package/runtime/arcane/components/source-explanation.html +124 -0
- package/runtime/arcane/components/speech.html +365 -0
- package/runtime/arcane/components/summary-strip.html +177 -0
- package/runtime/arcane/components/table.html +77 -0
- package/runtime/arcane/components/task-progress.html +282 -0
- package/runtime/arcane/components/terminal-workspace.html +65 -0
- package/runtime/arcane/components/theme-editor.html +41 -0
- package/runtime/arcane/components/theme-switcher.html +46 -0
- package/runtime/arcane/components/unified-inbox.html +20 -0
- package/runtime/arcane/components/voice-transcription.html +476 -0
- package/runtime/arcane/components/weather-widget.html +8 -0
- package/runtime/arcane/components/web-navigator.html +239 -0
- package/runtime/arcane/css/communications.css +1 -0
- package/runtime/arcane/css/dashboard-config.css +45 -0
- package/runtime/arcane/css/document-site.css +981 -0
- package/runtime/arcane/css/layout.css +438 -0
- package/runtime/arcane/css/primitives.css +321 -0
- package/runtime/arcane/css/theme.css +112 -0
- package/runtime/arcane/css/utility-workspace.css +1 -0
- package/runtime/arcane/entities/ApiModelRecord.js +20 -0
- package/runtime/arcane/entities/Calculation.js +13 -0
- package/runtime/arcane/entities/Chat.js +581 -0
- package/runtime/arcane/entities/CommunicationMessage.js +29 -0
- package/runtime/arcane/entities/CommunicationThread.js +21 -0
- package/runtime/arcane/entities/Document.js +10 -0
- package/runtime/arcane/entities/File.js +143 -0
- package/runtime/arcane/entities/Image.js +135 -0
- package/runtime/arcane/entities/IntentEnvelope.js +834 -0
- package/runtime/arcane/entities/Preference.js +83 -0
- package/runtime/arcane/entities/TWiNPolicyDecision.js +1092 -0
- package/runtime/arcane/entities/TerminalSession.js +46 -0
- package/runtime/arcane/entities/Theme.js +107 -0
- package/runtime/arcane/entities/User.js +1046 -0
- package/runtime/arcane/entities/Weather.js +23 -0
- package/runtime/arcane/img/arcane-os-everywhere.png +0 -0
- package/runtime/arcane/img/arrow-left.png +0 -0
- package/runtime/arcane/img/arrow-right.png +0 -0
- package/runtime/arcane/img/doc.svg +5 -0
- package/runtime/arcane/img/folder.svg +4 -0
- package/runtime/arcane/img/image.svg +5 -0
- package/runtime/arcane/img/refresh.png +0 -0
- package/runtime/arcane/img/send.svg +9 -0
- package/runtime/arcane/img/trash.svg +5 -0
- package/runtime/arcane/img/upload.svg +5 -0
- package/runtime/arcane/modules/AI.js +2048 -0
- package/runtime/arcane/modules/AIPreferenceRuntime.js +32 -0
- package/runtime/arcane/modules/AIPreferenceTuple.js +92 -0
- package/runtime/arcane/modules/AIResponseLength.js +42 -0
- package/runtime/arcane/modules/AIResponseURLPolicy.js +626 -0
- package/runtime/arcane/modules/AnsiText.js +53 -0
- package/runtime/arcane/modules/ApiModelDatabase.js +25 -0
- package/runtime/arcane/modules/AppDataScope.js +245 -0
- package/runtime/arcane/modules/AppearancePreferences.js +28 -0
- package/runtime/arcane/modules/ArcaneCommunicationBridge.js +22 -0
- package/runtime/arcane/modules/ArcaneNavigationPolicy.js +135 -0
- package/runtime/arcane/modules/ArcaneNetworkPolicy.js +255 -0
- package/runtime/arcane/modules/AsyncBoundary.js +161 -0
- package/runtime/arcane/modules/BrowserTestSuite.js +326 -0
- package/runtime/arcane/modules/CalculatorEngine.js +20 -0
- package/runtime/arcane/modules/CaseEvidenceIndexer.js +134 -0
- package/runtime/arcane/modules/ChartLibrary.js +35 -0
- package/runtime/arcane/modules/ChatRecords.js +13 -0
- package/runtime/arcane/modules/CommunicationAppController.js +43 -0
- package/runtime/arcane/modules/CommunicationHub.js +16 -0
- package/runtime/arcane/modules/CommunicationPreferences.js +13 -0
- package/runtime/arcane/modules/CommunicationProviderRegistry.js +16 -0
- package/runtime/arcane/modules/ComponentContracts.js +586 -0
- package/runtime/arcane/modules/ConfiguredAIChatSession.js +288 -0
- package/runtime/arcane/modules/ConversationActionItems.js +488 -0
- package/runtime/arcane/modules/ConversationClosingReport.js +274 -0
- package/runtime/arcane/modules/ConversationTimebox.js +527 -0
- package/runtime/arcane/modules/CoreLocalModelCatalog.js +255 -0
- package/runtime/arcane/modules/DBLS.js +171 -0
- package/runtime/arcane/modules/DBOPFS.js +1154 -0
- package/runtime/arcane/modules/DBOPFSWorker.js +116 -0
- package/runtime/arcane/modules/DataMaintenance.js +91 -0
- package/runtime/arcane/modules/DevelopmentWorkspace.js +74 -0
- package/runtime/arcane/modules/DirectoryPicker.js +109 -0
- package/runtime/arcane/modules/DocumentNavigation.js +223 -0
- package/runtime/arcane/modules/Errors.js +1025 -0
- package/runtime/arcane/modules/GifEncoder.js +29 -0
- package/runtime/arcane/modules/HTMLImport.js +114 -0
- package/runtime/arcane/modules/InMemoryCommunicationProvider.js +14 -0
- package/runtime/arcane/modules/IsolatedModelQuestionRunner.js +275 -0
- package/runtime/arcane/modules/LocalAIReadiness.js +870 -0
- package/runtime/arcane/modules/LocalAIReadinessController.js +156 -0
- package/runtime/arcane/modules/MD.js +111 -0
- package/runtime/arcane/modules/Mail.js +352 -0
- package/runtime/arcane/modules/MailTransport.mjs +180 -0
- package/runtime/arcane/modules/Marked.min.js +71 -0
- package/runtime/arcane/modules/MemoryRecords.js +44 -0
- package/runtime/arcane/modules/MessageAdvisory.js +38 -0
- package/runtime/arcane/modules/ModelDefinition.js +189 -0
- package/runtime/arcane/modules/Ollama.js +74 -0
- package/runtime/arcane/modules/OllamaModelIdentifier.js +21 -0
- package/runtime/arcane/modules/OllamaSettings.js +24 -0
- package/runtime/arcane/modules/OpenMeteoWeatherProvider.js +14 -0
- package/runtime/arcane/modules/PreferenceStore.js +109 -0
- package/runtime/arcane/modules/QRCode.min.js +1 -0
- package/runtime/arcane/modules/Questionnaire.js +61 -0
- package/runtime/arcane/modules/RecordLinkIndex.js +24 -0
- package/runtime/arcane/modules/RecordPassageIndex.js +222 -0
- package/runtime/arcane/modules/RecordReviewStore.js +94 -0
- package/runtime/arcane/modules/RevocableProjectionLedger.js +1623 -0
- package/runtime/arcane/modules/RiskSignalAnalyzer.js +37 -0
- package/runtime/arcane/modules/ScamRiskPolicy.js +62 -0
- package/runtime/arcane/modules/ScopedOPFSCache.js +183 -0
- package/runtime/arcane/modules/ScreenCapture.js +20 -0
- package/runtime/arcane/modules/SpeechPlayback.js +581 -0
- package/runtime/arcane/modules/StaticDocumentCatalog.js +1248 -0
- package/runtime/arcane/modules/SystemAppearance.js +20 -0
- package/runtime/arcane/modules/SystemPlatformPresentation.js +51 -0
- package/runtime/arcane/modules/SystemToolRegistry.js +28 -0
- package/runtime/arcane/modules/TerminalClient.js +52 -0
- package/runtime/arcane/modules/TerminalCommandRegistry.js +50 -0
- package/runtime/arcane/modules/ThemeBootstrap.js +26 -0
- package/runtime/arcane/modules/ThemeManager.js +131 -0
- package/runtime/arcane/modules/TimeGuard.js +149 -0
- package/runtime/arcane/modules/ToolCallRouter.js +83 -0
- package/runtime/arcane/modules/WaitForComponent.js +102 -0
- package/runtime/arcane/modules/YouTubeMedia.js +16 -0
- package/runtime/arcane/modules/uPlot.LICENSE.txt +21 -0
- package/runtime/arcane/modules/uPlot.iife.min.js +2 -0
- package/runtime/arcane/modules/uPlot.min.css +1 -0
- package/runtime/arcane/security/arcane-network-policy.json +6 -0
- package/runtime/strong-type/index.js +352 -0
- package/runtime/strong-type/licence +21 -0
- package/runtime/strong-type/package.json +45 -0
- package/schemas/arcane-app-bundle.schema.json +125 -0
- package/schemas/arcane-app.schema.json +329 -0
- package/schemas/arcane-lock.schema.json +86 -0
- package/schemas/arcane-package.schema.json +224 -0
- package/schemas/cli-event.schema.json +122 -0
- package/schemas/event-stack.schema.json +152 -0
- package/schemas/native-build-plan.schema.json +176 -0
- package/schemas/target-adapter.schema.json +117 -0
- package/src/app-descriptor.mjs +500 -0
- package/src/cli/main.mjs +561 -0
- package/src/constants.mjs +31 -0
- package/src/dev-server.mjs +718 -0
- package/src/doctor.mjs +315 -0
- package/src/dom-event-instrumentation.mjs +594 -0
- package/src/errors.mjs +75 -0
- package/src/event-manager.mjs +1342 -0
- package/src/event-queue.mjs +138 -0
- package/src/events.mjs +219 -0
- package/src/index.mjs +177 -0
- package/src/integrated-provider-loader.mjs +432 -0
- package/src/native-plan.mjs +698 -0
- package/src/native-provider-loader.mjs +1126 -0
- package/src/packager/core.mjs +2691 -0
- package/src/process.mjs +353 -0
- package/src/release-bundle.mjs +2523 -0
- package/src/repository.mjs +90 -0
- package/src/runtime.mjs +452 -0
- package/src/scaffold.mjs +380 -0
- package/src/targets/index.mjs +436 -0
- package/src/templates/assets/app-icon.png +0 -0
- package/src/templates/workspace-template.mjs +388 -0
- package/src/testing-loader.mjs +9 -0
- package/src/testing.mjs +427 -0
- package/src/toolchain.mjs +1335 -0
- package/src/update-check.mjs +307 -0
- package/src/workspace.mjs +449 -0
|
@@ -0,0 +1,2048 @@
|
|
|
1
|
+
import './DBOPFS.js';
|
|
2
|
+
import UserEntity from '../entities/User.js';
|
|
3
|
+
import {getAIPreferencesForRuntime} from './AIPreferenceRuntime.js';
|
|
4
|
+
import {normalizeOllamaModelIdentifier} from './OllamaModelIdentifier.js';
|
|
5
|
+
|
|
6
|
+
let credentials='include';
|
|
7
|
+
credentials='omit';
|
|
8
|
+
|
|
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
|
+
}
|
|
59
|
+
|
|
60
|
+
function isAIRequestAbort(error,signal){
|
|
61
|
+
return signal?.aborted
|
|
62
|
+
||error?.name==='AbortError'
|
|
63
|
+
||error?.code==='ARCANE_REQUEST_ABORTED'
|
|
64
|
+
||error?.code==='AI_REQUEST_ABORTED';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeAIRequestAbort(error){
|
|
68
|
+
if(error?.code==='AI_REQUEST_ABORTED'){
|
|
69
|
+
return error;
|
|
70
|
+
}
|
|
71
|
+
const normalized=new Error('The AI request was cancelled.',{cause:error});
|
|
72
|
+
normalized.name='AbortError';
|
|
73
|
+
normalized.code='AI_REQUEST_ABORTED';
|
|
74
|
+
return normalized;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class AI {
|
|
78
|
+
// This is the enum section for inference configuration
|
|
79
|
+
#service = {
|
|
80
|
+
baseURL: {
|
|
81
|
+
OPENAI: 'https://api.openai.com/v1'
|
|
82
|
+
},
|
|
83
|
+
sttURL: {
|
|
84
|
+
LOCAL_SPEACH: 'http://127.0.0.1:8011/v1',
|
|
85
|
+
OPENAI: 'https://api.openai.com/v1'
|
|
86
|
+
},
|
|
87
|
+
ttsURL: {
|
|
88
|
+
LOCAL_SPEACH: 'http://127.0.0.1:8011/v1',
|
|
89
|
+
OPENAI: 'https://api.openai.com/v1'
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#paths = {
|
|
94
|
+
chat: {
|
|
95
|
+
OPENAI: '/chat/completions'
|
|
96
|
+
},
|
|
97
|
+
stt: {
|
|
98
|
+
LOCAL_SPEACH: '/audio/transcriptions',
|
|
99
|
+
OPENAI: '/audio/transcriptions'
|
|
100
|
+
},
|
|
101
|
+
tts: {
|
|
102
|
+
LOCAL_SPEACH: '/audio/speech',
|
|
103
|
+
OPENAI: '/audio/speech'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
#models = {
|
|
108
|
+
OPENAI:'gpt-4o'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#sttModels = {
|
|
112
|
+
OPENAI: 'whisper-1',
|
|
113
|
+
LOCAL_SPEACH: 'whisper-small'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#ttsModels = {
|
|
117
|
+
OPENAI: 'gpt-4o-mini-tts',
|
|
118
|
+
LOCAL_SPEACH: 'kokoro'
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#speechAbbreviations=new Set([
|
|
122
|
+
'co','dept','dr','e.g','etc','fig','i.e','inc','jr','ltd','mr',
|
|
123
|
+
'mrs','ms','no','prof','sr','st','vs'
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
// Note: if we expand cloud providers, simply add their expected JSON metadata here
|
|
127
|
+
get #serviceHeaders(){
|
|
128
|
+
return {
|
|
129
|
+
OPENAI: {
|
|
130
|
+
'Content-Type': 'application/json',
|
|
131
|
+
'Authorization': `Bearer ${this.license}`
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get #ttsHeaders(){
|
|
137
|
+
return {
|
|
138
|
+
OPENAI: {
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
'Authorization': `Bearer ${this.license}`
|
|
141
|
+
},
|
|
142
|
+
LOCAL_SPEACH: {
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
get #sttHeaders(){
|
|
149
|
+
return {
|
|
150
|
+
OPENAI: {
|
|
151
|
+
'Authorization': `Bearer ${this.license}`,
|
|
152
|
+
},
|
|
153
|
+
LOCAL_SPEACH: {}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ready=false;
|
|
158
|
+
muted=false;
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
llmService = '';
|
|
162
|
+
sttService = '';
|
|
163
|
+
ttsService = '';
|
|
164
|
+
|
|
165
|
+
model = '';
|
|
166
|
+
modelTTS = '';
|
|
167
|
+
modelSTT = '';
|
|
168
|
+
reasoningEffort = '';
|
|
169
|
+
|
|
170
|
+
audioFormat = 'opus';
|
|
171
|
+
audioType = 'audio/ogg; codecs=opus';
|
|
172
|
+
voiceSpeed = 1.0;
|
|
173
|
+
|
|
174
|
+
//audioFormat = 'wav';
|
|
175
|
+
//audioType = 'audio/wav; codecs=1';
|
|
176
|
+
|
|
177
|
+
constructor(
|
|
178
|
+
llmService='',
|
|
179
|
+
sttService='',
|
|
180
|
+
ttsService='',
|
|
181
|
+
model='',
|
|
182
|
+
modelTTS='',
|
|
183
|
+
modelSTT=''
|
|
184
|
+
) {
|
|
185
|
+
if(window.ai){
|
|
186
|
+
return window.ai;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.setAI(
|
|
190
|
+
llmService || 'OPENAI',
|
|
191
|
+
sttService || 'OPENAI',
|
|
192
|
+
ttsService || 'OPENAI',
|
|
193
|
+
model || 'OPENAI',
|
|
194
|
+
modelTTS || 'OPENAI',
|
|
195
|
+
modelSTT || 'OPENAI'
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
get url() {
|
|
200
|
+
return `${this.#service.baseURL[this.llmService]}${this.#paths.chat[this.llmService]}`
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
set url(value) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get urlTTS() {
|
|
208
|
+
return `${this.#service.ttsURL[this.ttsService]}${this.#paths.tts[this.ttsService]}`
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
set urlTTS(value) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
get urlSTT() {
|
|
216
|
+
return `${this.#service.sttURL[this.sttService]}${this.#paths.stt[this.sttService]}`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
set urlSTT(value) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#license='';
|
|
224
|
+
|
|
225
|
+
// Browser-delivered framework code must not contain provider credentials.
|
|
226
|
+
// The selected host, application, or user profile supplies one at runtime.
|
|
227
|
+
get license(){
|
|
228
|
+
return this.#license || globalThis.arcane?.config?.openAI?.apiKey || '';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
set license(value){
|
|
232
|
+
this.#license=typeof value==='string' ? value.trim():'';
|
|
233
|
+
return this.#license;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
get configured(){
|
|
237
|
+
if(this.llmService==='OLLAMA'){
|
|
238
|
+
return Boolean(this.model)&&Boolean(this.#nativeOllama());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return this.llmService==='OPENAI'
|
|
242
|
+
&&Boolean(this.model)
|
|
243
|
+
&&Boolean(this.license);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#assertServiceConfigured(service=this.llmService){
|
|
247
|
+
if(service==='OLLAMA'){
|
|
248
|
+
if(this.#nativeOllama()){
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const error=new Error(
|
|
253
|
+
'Local AI requires the capability-gated Arcane API.'
|
|
254
|
+
);
|
|
255
|
+
error.code='AI_NATIVE_LOCAL_REQUIRED';
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
if(service&&service!=='OPENAI'){
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if(service==='OPENAI'&&this.license){
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const error=new Error('AI provider is not configured.');
|
|
267
|
+
error.code='AI_PROVIDER_NOT_CONFIGURED';
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
audioMessageChunks='';
|
|
272
|
+
sourceNodes=[];
|
|
273
|
+
isSpeaking=false;
|
|
274
|
+
audioContext=null;
|
|
275
|
+
currentSpeechJob=null;
|
|
276
|
+
speechGeneration=0;
|
|
277
|
+
speechJobs=[];
|
|
278
|
+
speechAwaitingGesture=false;
|
|
279
|
+
speechPlaybackStarting=false;
|
|
280
|
+
speechResumeAttempt=0;
|
|
281
|
+
speechResumePending=false;
|
|
282
|
+
speechSynthesisTail=Promise.resolve();
|
|
283
|
+
speechUnlockHandler=null;
|
|
284
|
+
|
|
285
|
+
// Set models to be used by the AI.
|
|
286
|
+
// Note: Only those that are defined are set.
|
|
287
|
+
setAI(
|
|
288
|
+
llmService,
|
|
289
|
+
sttService,
|
|
290
|
+
ttsService,
|
|
291
|
+
model,
|
|
292
|
+
modelTTS,
|
|
293
|
+
modelSTT
|
|
294
|
+
) {
|
|
295
|
+
if (
|
|
296
|
+
!(
|
|
297
|
+
llmService ||
|
|
298
|
+
sttService ||
|
|
299
|
+
ttsService ||
|
|
300
|
+
model ||
|
|
301
|
+
modelTTS ||
|
|
302
|
+
modelSTT
|
|
303
|
+
)
|
|
304
|
+
) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
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)||'';
|
|
314
|
+
|
|
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='';
|
|
324
|
+
}
|
|
325
|
+
this.modelTTS=this.#ttsModels[modelTTS];
|
|
326
|
+
this.modelTTS=this.#ttsModels[modelTTS];
|
|
327
|
+
this.modelSTT=this.#sttModels[modelSTT];
|
|
328
|
+
this.reasoningEffort='';
|
|
329
|
+
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async #assertResponseOK(response){
|
|
334
|
+
if(response.ok){
|
|
335
|
+
return response;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
let detail='';
|
|
339
|
+
|
|
340
|
+
try{
|
|
341
|
+
const contentType=response.headers.get('content-type')||'';
|
|
342
|
+
|
|
343
|
+
if(contentType.includes('application/json')){
|
|
344
|
+
const errorResponse=await response.json();
|
|
345
|
+
detail=errorResponse?.error?.message
|
|
346
|
+
|| errorResponse?.message
|
|
347
|
+
|| '';
|
|
348
|
+
}else{
|
|
349
|
+
const errorText=await response.text();
|
|
350
|
+
|
|
351
|
+
if(errorText&&!errorText.trim().startsWith('<')){
|
|
352
|
+
detail=errorText.trim().slice(0,500);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}catch{
|
|
356
|
+
// The response status is enough when its body cannot be read.
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const status=[response.status,response.statusText]
|
|
360
|
+
.filter(Boolean)
|
|
361
|
+
.join(' ');
|
|
362
|
+
const message=`AI request failed${status ? ` (${status})`:''}`;
|
|
363
|
+
const error=new Error(message);
|
|
364
|
+
error.code='AI_REQUEST_FAILED';
|
|
365
|
+
error.status=response.status;
|
|
366
|
+
error.providerMessage=detail;
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#nativeOllama(){
|
|
371
|
+
const client=globalThis.Arcane?.ollama;
|
|
372
|
+
|
|
373
|
+
return this.llmService==='OLLAMA'
|
|
374
|
+
&&typeof client?.chat==='function'
|
|
375
|
+
?client
|
|
376
|
+
:null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#nativeSpeech(service){
|
|
380
|
+
const client=globalThis.Arcane?.speech;
|
|
381
|
+
|
|
382
|
+
return service==='LOCAL_SPEACH'
|
|
383
|
+
&&typeof client?.synthesize==='function'
|
|
384
|
+
&&typeof client?.transcribe==='function'
|
|
385
|
+
?client
|
|
386
|
+
:null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async #androidNativeHost(){
|
|
390
|
+
if(typeof globalThis.arcaneAndroid?.postMessage==='function'){
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
try{
|
|
395
|
+
const runtime=await globalThis.Arcane?.runtime?.current?.();
|
|
396
|
+
return runtime?.native===true
|
|
397
|
+
&&runtime?.transport==='android-webview';
|
|
398
|
+
}catch{
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async #assertAndroidSpeechBridge(service){
|
|
404
|
+
if(service!=='LOCAL_SPEACH'||!await this.#androidNativeHost()){
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const error=new Error(
|
|
409
|
+
'Android local speech requires the capability-gated Arcane speech bridge.'
|
|
410
|
+
);
|
|
411
|
+
error.code='AI_ANDROID_NATIVE_SPEECH_UNAVAILABLE';
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#arrayBufferToBase64(arrayBuffer){
|
|
416
|
+
const bytes=new Uint8Array(arrayBuffer);
|
|
417
|
+
|
|
418
|
+
if(!bytes.length||bytes.length>6*1024*1024){
|
|
419
|
+
throw new RangeError('Microphone audio must be between 1 byte and 6 MiB.');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const chunks=[];
|
|
423
|
+
|
|
424
|
+
for(let offset=0;offset<bytes.length;offset+=0x8000){
|
|
425
|
+
chunks.push(String.fromCharCode(...bytes.subarray(offset,offset+0x8000)));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return btoa(chunks.join(''));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
#base64ToBytes(value){
|
|
432
|
+
if(typeof value!=='string'||!value||value.length>8*1024*1024){
|
|
433
|
+
throw new TypeError('Arcane returned invalid local speech audio.');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const binary=atob(value);
|
|
437
|
+
const bytes=new Uint8Array(binary.length);
|
|
438
|
+
|
|
439
|
+
for(let index=0;index<binary.length;index+=1){
|
|
440
|
+
bytes[index]=binary.charCodeAt(index);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return bytes;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
#ollamaTools(tools=[],toolChoice='auto'){
|
|
447
|
+
if(!Array.isArray(tools)){
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const requiredName=toolChoice?.function?.name;
|
|
452
|
+
const requiredToolCount=requiredName
|
|
453
|
+
?tools.filter(tool=>tool?.function?.name===requiredName).length
|
|
454
|
+
:0;
|
|
455
|
+
|
|
456
|
+
if(requiredName&&requiredToolCount!==1){
|
|
457
|
+
const error=new Error(`Required AI tool "${requiredName}" is not available.`);
|
|
458
|
+
error.code='AI_REQUIRED_TOOL_UNAVAILABLE';
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return tools;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#ollamaMessages(messages=[],toolChoice='auto'){
|
|
466
|
+
const toolNamesByCallId=new Map();
|
|
467
|
+
const sanitizedMessages=messages.map(function sanitizeOllamaMessage(message){
|
|
468
|
+
const role=String(message?.role??'');
|
|
469
|
+
const content=String(message?.content??'');
|
|
470
|
+
|
|
471
|
+
if(role==='assistant'&&Array.isArray(message?.tool_calls)){
|
|
472
|
+
const toolCalls=message.tool_calls.map(function sanitizeOllamaToolCall(call){
|
|
473
|
+
const name=String(call?.function?.name??'').trim();
|
|
474
|
+
const callId=String(call?.id??'').trim();
|
|
475
|
+
let argumentValue=call?.function?.arguments;
|
|
476
|
+
if(typeof argumentValue==='string'){
|
|
477
|
+
try{
|
|
478
|
+
argumentValue=JSON.parse(argumentValue);
|
|
479
|
+
}catch{
|
|
480
|
+
argumentValue={};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if(
|
|
484
|
+
!argumentValue
|
|
485
|
+
||typeof argumentValue!=='object'
|
|
486
|
+
||Array.isArray(argumentValue)
|
|
487
|
+
){
|
|
488
|
+
argumentValue={};
|
|
489
|
+
}
|
|
490
|
+
if(callId&&name){
|
|
491
|
+
toolNamesByCallId.set(callId,name);
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
type:'function',
|
|
495
|
+
function:{name,arguments:argumentValue}
|
|
496
|
+
};
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
role,
|
|
501
|
+
content,
|
|
502
|
+
tool_calls:toolCalls
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if(role==='tool'){
|
|
507
|
+
const callId=String(message?.tool_call_id??'').trim();
|
|
508
|
+
const toolName=String(
|
|
509
|
+
message?.tool_name
|
|
510
|
+
??message?.name
|
|
511
|
+
??toolNamesByCallId.get(callId)
|
|
512
|
+
??''
|
|
513
|
+
).trim();
|
|
514
|
+
if(!toolName){
|
|
515
|
+
throw new TypeError('Ollama tool results require a matching tool name.');
|
|
516
|
+
}
|
|
517
|
+
return {role,tool_name:toolName,content};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {role,content};
|
|
521
|
+
});
|
|
522
|
+
const requiredName=toolChoice?.function?.name;
|
|
523
|
+
const instruction=requiredName
|
|
524
|
+
?`Call the ${requiredName} function now with concise values for every required field. Do not answer in prose.`
|
|
525
|
+
:toolChoice==='none'
|
|
526
|
+
?'Do not call a function in this request. Follow the response instructions and answer in the requested format.'
|
|
527
|
+
:'';
|
|
528
|
+
|
|
529
|
+
if(!instruction){
|
|
530
|
+
return sanitizedMessages;
|
|
531
|
+
}
|
|
532
|
+
const firstMessage=sanitizedMessages[0];
|
|
533
|
+
|
|
534
|
+
if(firstMessage?.role==='system'){
|
|
535
|
+
return [
|
|
536
|
+
{
|
|
537
|
+
...firstMessage,
|
|
538
|
+
content:`${firstMessage.content||''}\n\n${instruction}`
|
|
539
|
+
},
|
|
540
|
+
...sanitizedMessages.slice(1)
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return [
|
|
545
|
+
{role:'system',content:instruction},
|
|
546
|
+
...sanitizedMessages
|
|
547
|
+
];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
#structuredOutputFormat(value=false){
|
|
551
|
+
if(value===false||value===null||value===undefined){
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
if(value===true||value==='json'){
|
|
555
|
+
return 'json';
|
|
556
|
+
}
|
|
557
|
+
if(
|
|
558
|
+
typeof value==='object'
|
|
559
|
+
&&!Array.isArray(value)
|
|
560
|
+
&&(
|
|
561
|
+
Object.getPrototypeOf(value)===Object.prototype
|
|
562
|
+
||Object.getPrototypeOf(value)===null
|
|
563
|
+
)
|
|
564
|
+
){
|
|
565
|
+
return value;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const error=new TypeError(
|
|
569
|
+
'AI structured output must be enabled with true, json, or a JSON Schema object.'
|
|
570
|
+
);
|
|
571
|
+
error.code='AI_STRUCTURED_OUTPUT_INVALID';
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#openAIResponseFormat(structuredOutputFormat){
|
|
576
|
+
if(structuredOutputFormat==='json'){
|
|
577
|
+
return {type:'json_object'};
|
|
578
|
+
}
|
|
579
|
+
if(structuredOutputFormat){
|
|
580
|
+
return {
|
|
581
|
+
type:'json_schema',
|
|
582
|
+
json_schema:{
|
|
583
|
+
name:'structured_response',
|
|
584
|
+
strict:true,
|
|
585
|
+
schema:structuredOutputFormat
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#reportRequest(requestHandler,request,id,metadata={}){
|
|
593
|
+
if(typeof requestHandler!=='function'){
|
|
594
|
+
throw new TypeError('AI request diagnostics require a function.');
|
|
595
|
+
}
|
|
596
|
+
|
|
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);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
reportAIRequestToConsole({
|
|
606
|
+
id,
|
|
607
|
+
service:this.llmService,
|
|
608
|
+
request,
|
|
609
|
+
...metadata
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
#reportResponse(response,id,metadata={}){
|
|
614
|
+
reportAIResponseToConsole({
|
|
615
|
+
id,
|
|
616
|
+
service:this.llmService,
|
|
617
|
+
response,
|
|
618
|
+
...metadata
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
#assertRequiredOllamaToolCall(toolCalls=[],toolChoice='auto'){
|
|
623
|
+
const requiredName=toolChoice?.function?.name;
|
|
624
|
+
|
|
625
|
+
if(!requiredName){
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const called=toolCalls.some(function isRequiredOllamaToolCall(call){
|
|
630
|
+
return call?.function?.name===requiredName;
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
if(!called){
|
|
634
|
+
const error=new Error(`Local AI did not call the required "${requiredName}" tool.`);
|
|
635
|
+
error.code='AI_REQUIRED_TOOL_CALL_MISSING';
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
#openAICompatibleOllamaResponse(response={},id=Date.now()){
|
|
641
|
+
const message=response?.message||{};
|
|
642
|
+
const toolCalls=Array.isArray(message.tool_calls)
|
|
643
|
+
?message.tool_calls.map(
|
|
644
|
+
function normalizeOllamaToolCall(call,index){
|
|
645
|
+
return {
|
|
646
|
+
id:call?.id||`call-${id}-${index}`,
|
|
647
|
+
type:'function',
|
|
648
|
+
function:{
|
|
649
|
+
name:call?.function?.name||'',
|
|
650
|
+
arguments:typeof call?.function?.arguments==='string'
|
|
651
|
+
?call.function.arguments
|
|
652
|
+
:JSON.stringify(call?.function?.arguments||{})
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
)
|
|
657
|
+
:[];
|
|
658
|
+
|
|
659
|
+
return {
|
|
660
|
+
id:response?.id||`ollama-${id}`,
|
|
661
|
+
object:'chat.completion',
|
|
662
|
+
created:Math.floor(Date.now()/1000),
|
|
663
|
+
model:response?.model||this.model,
|
|
664
|
+
choices:[
|
|
665
|
+
{
|
|
666
|
+
index:0,
|
|
667
|
+
message:{
|
|
668
|
+
role:message.role||'assistant',
|
|
669
|
+
content:message.content||'',
|
|
670
|
+
...(toolCalls.length?{tool_calls:toolCalls}:{})
|
|
671
|
+
},
|
|
672
|
+
finish_reason:response?.done_reason
|
|
673
|
+
||(toolCalls.length?'tool_calls':'stop')
|
|
674
|
+
}
|
|
675
|
+
],
|
|
676
|
+
usage:{
|
|
677
|
+
prompt_tokens:Number(response?.prompt_eval_count)||0,
|
|
678
|
+
completion_tokens:Number(response?.eval_count)||0,
|
|
679
|
+
total_tokens:(Number(response?.prompt_eval_count)||0)
|
|
680
|
+
+(Number(response?.eval_count)||0)
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
async streamRequest({
|
|
687
|
+
messages=[],
|
|
688
|
+
structuredOutput=false,
|
|
689
|
+
localOnly=false,
|
|
690
|
+
onChunk=function ignoreStreamChunk(){},
|
|
691
|
+
onComplete=function finishIgnoredStream(){},
|
|
692
|
+
tools=[],
|
|
693
|
+
toolChoice='auto',
|
|
694
|
+
onToolCall=function ignoreEarlyFunction(){},
|
|
695
|
+
onRequest=function ignoreStreamRequest(){},
|
|
696
|
+
parallelToolCalls=true,
|
|
697
|
+
id=Date.now(),
|
|
698
|
+
seeThinking=false,
|
|
699
|
+
signal=null
|
|
700
|
+
}={}){
|
|
701
|
+
if(localOnly!==true&&localOnly!==false){
|
|
702
|
+
throw new TypeError('AI localOnly must be a boolean.');
|
|
703
|
+
}
|
|
704
|
+
if(localOnly&&this.llmService!=='OLLAMA'){
|
|
705
|
+
const error=new Error(
|
|
706
|
+
'This AI request requires a configured local model.'
|
|
707
|
+
);
|
|
708
|
+
error.code='AI_LOCAL_MODEL_REQUIRED';
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return this.streamMessage(
|
|
713
|
+
messages,
|
|
714
|
+
onChunk,
|
|
715
|
+
onComplete,
|
|
716
|
+
tools,
|
|
717
|
+
toolChoice,
|
|
718
|
+
onToolCall,
|
|
719
|
+
parallelToolCalls,
|
|
720
|
+
id,
|
|
721
|
+
seeThinking,
|
|
722
|
+
signal,
|
|
723
|
+
onRequest,
|
|
724
|
+
structuredOutput
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async streamMessage(
|
|
729
|
+
messages=[],
|
|
730
|
+
streamHandler=function ignoreStreamChunk(){},
|
|
731
|
+
streamComplete=function finishIgnoredStream(){},
|
|
732
|
+
tools=[],
|
|
733
|
+
tool_choice='auto',
|
|
734
|
+
earlyFunctionTrigger=function ignoreEarlyFunction(){},
|
|
735
|
+
parallel_tool_calls=true,
|
|
736
|
+
id=Date.now(),
|
|
737
|
+
seeThinking=false,
|
|
738
|
+
signal=null,
|
|
739
|
+
requestHandler=function ignoreStreamRequest(){},
|
|
740
|
+
structuredOutput=false
|
|
741
|
+
){
|
|
742
|
+
let speechTurnCompleted=false;
|
|
743
|
+
|
|
744
|
+
try{
|
|
745
|
+
this.#assertServiceConfigured(this.llmService);
|
|
746
|
+
if(signal&&(
|
|
747
|
+
typeof signal.aborted!=='boolean'
|
|
748
|
+
||typeof signal.addEventListener!=='function'
|
|
749
|
+
)){
|
|
750
|
+
throw new TypeError('AI request signal must be an AbortSignal.');
|
|
751
|
+
}
|
|
752
|
+
if(signal?.aborted){
|
|
753
|
+
throw normalizeAIRequestAbort();
|
|
754
|
+
}
|
|
755
|
+
const structuredOutputFormat=this.#structuredOutputFormat(
|
|
756
|
+
structuredOutput
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
const request={
|
|
760
|
+
model:this.model,
|
|
761
|
+
messages:messages,
|
|
762
|
+
stream:true
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if(structuredOutputFormat){
|
|
766
|
+
request.response_format=this.#openAIResponseFormat(
|
|
767
|
+
structuredOutputFormat
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if(tools.length){
|
|
772
|
+
request.tools=tools;
|
|
773
|
+
request.tool_choice=tool_choice;
|
|
774
|
+
request.parallel_tool_calls=parallel_tool_calls;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if(this.llmService==='OLLAMA'&&this.reasoningEffort){
|
|
778
|
+
request.reasoning_effort=this.reasoningEffort;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
let isThinking=true;
|
|
782
|
+
let isWaiting=true;
|
|
783
|
+
|
|
784
|
+
streamHandler('Thinking...',`M-${id}`,isThinking);
|
|
785
|
+
|
|
786
|
+
const nativeOllama=this.#nativeOllama();
|
|
787
|
+
|
|
788
|
+
if(nativeOllama){
|
|
789
|
+
let nativeContent='';
|
|
790
|
+
const nativeToolCalls={};
|
|
791
|
+
const nativeResponseChunks=[];
|
|
792
|
+
const triggeredTools=new Set();
|
|
793
|
+
const ollamaTools=this.#ollamaTools(tools,tool_choice);
|
|
794
|
+
const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
|
|
795
|
+
const ollamaRequest={
|
|
796
|
+
model:this.model,
|
|
797
|
+
messages:ollamaMessages,
|
|
798
|
+
stream:true,
|
|
799
|
+
...(this.reasoningEffort?{think:this.reasoningEffort}:{}),
|
|
800
|
+
...(structuredOutputFormat?{format:structuredOutputFormat}:{}),
|
|
801
|
+
...(ollamaTools.length?{tools:ollamaTools}:{})
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
function reportEarlyFunctionFailure(error){
|
|
805
|
+
console.error('Early tool trigger failed:',error);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function receiveNativeToolCalls(message={}){
|
|
809
|
+
const calls=Array.isArray(message.tool_calls)?message.tool_calls:[];
|
|
810
|
+
|
|
811
|
+
if(calls.length){
|
|
812
|
+
isThinking=false;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
for(const call of calls){
|
|
816
|
+
const name=call?.function?.name;
|
|
817
|
+
|
|
818
|
+
if(!name){
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
nativeToolCalls[name]=typeof call.function.arguments==='string'
|
|
823
|
+
?call.function.arguments
|
|
824
|
+
:JSON.stringify(call.function.arguments||{});
|
|
825
|
+
|
|
826
|
+
if(!triggeredTools.has(name)){
|
|
827
|
+
triggeredTools.add(name);
|
|
828
|
+
Promise.resolve(earlyFunctionTrigger(name)).catch(
|
|
829
|
+
reportEarlyFunctionFailure
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
this.#reportRequest(requestHandler,ollamaRequest,id,{
|
|
836
|
+
operation:'stream',
|
|
837
|
+
transport:'native',
|
|
838
|
+
destination:'Arcane.ollama.chat'
|
|
839
|
+
});
|
|
840
|
+
const nativeResponse=await nativeOllama.chat(
|
|
841
|
+
ollamaRequest,
|
|
842
|
+
{
|
|
843
|
+
onChunk:function receiveNativeOllamaChunk(chunk){
|
|
844
|
+
if(signal?.aborted){
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
nativeResponseChunks.push(chunk);
|
|
848
|
+
const message=chunk?.message||{};
|
|
849
|
+
const thinking=seeThinking
|
|
850
|
+
?String(message.thinking||'')
|
|
851
|
+
:'';
|
|
852
|
+
const content=String(message.content||'');
|
|
853
|
+
|
|
854
|
+
if(thinking){
|
|
855
|
+
streamHandler(thinking,`M-${id}`,true);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if(content){
|
|
859
|
+
isThinking=false;
|
|
860
|
+
nativeContent+=content;
|
|
861
|
+
streamHandler(content,`M-${id}`,false);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
receiveNativeToolCalls(message);
|
|
865
|
+
},
|
|
866
|
+
signal
|
|
867
|
+
}
|
|
868
|
+
);
|
|
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
|
+
if(signal?.aborted){
|
|
879
|
+
throw normalizeAIRequestAbort();
|
|
880
|
+
}
|
|
881
|
+
receiveNativeToolCalls(nativeResponse?.message);
|
|
882
|
+
this.#assertRequiredOllamaToolCall(
|
|
883
|
+
Object.keys(nativeToolCalls).map(function createToolCallName(name){
|
|
884
|
+
return {function:{name}};
|
|
885
|
+
}),
|
|
886
|
+
tool_choice
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
const nativeResult=Object.keys(nativeToolCalls).length
|
|
890
|
+
?nativeToolCalls
|
|
891
|
+
:nativeContent;
|
|
892
|
+
if(Object.keys(nativeToolCalls).length&&!nativeContent){
|
|
893
|
+
streamHandler('',`M-${id}`,false);
|
|
894
|
+
}
|
|
895
|
+
this.finishTTS();
|
|
896
|
+
await streamComplete(nativeResult,`M-${id}`,isThinking);
|
|
897
|
+
speechTurnCompleted=true;
|
|
898
|
+
return nativeResult;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
this.#reportRequest(requestHandler,request,id,{
|
|
902
|
+
operation:'stream',
|
|
903
|
+
transport:'http',
|
|
904
|
+
destination:this.url
|
|
905
|
+
});
|
|
906
|
+
const body = JSON.stringify(request);
|
|
907
|
+
let response;
|
|
908
|
+
|
|
909
|
+
try{
|
|
910
|
+
response=await fetch(
|
|
911
|
+
this.url,
|
|
912
|
+
{
|
|
913
|
+
method:'POST',
|
|
914
|
+
credentials,
|
|
915
|
+
headers:this.#serviceHeaders[this.llmService],
|
|
916
|
+
body,
|
|
917
|
+
...(signal?{signal}:{})
|
|
918
|
+
}
|
|
919
|
+
);
|
|
920
|
+
}catch(err){
|
|
921
|
+
if(signal?.aborted||err?.name==='AbortError'){
|
|
922
|
+
const error=new Error('The AI request was cancelled.',{cause:err});
|
|
923
|
+
error.name='AbortError';
|
|
924
|
+
error.code='AI_REQUEST_ABORTED';
|
|
925
|
+
throw error;
|
|
926
|
+
}
|
|
927
|
+
const error=new Error(
|
|
928
|
+
'Unable to reach the AI service.',
|
|
929
|
+
{cause:err}
|
|
930
|
+
);
|
|
931
|
+
error.code='AI_SERVICE_UNREACHABLE';
|
|
932
|
+
throw error;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
await this.#assertResponseOK(response);
|
|
936
|
+
|
|
937
|
+
let chunkString='';
|
|
938
|
+
let chunkCache='';
|
|
939
|
+
const responseEvents=[];
|
|
940
|
+
const streamedToolCalls=new Map();
|
|
941
|
+
const triggeredTools=new Set();
|
|
942
|
+
const decoder = new TextDecoder('utf-8');
|
|
943
|
+
//alert(1)
|
|
944
|
+
const reader=response.body?.getReader?.();
|
|
945
|
+
|
|
946
|
+
if(!reader){
|
|
947
|
+
throw new TypeError('Streaming response body is not readable');
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function receiveStreamedToolCalls(toolCalls=[]){
|
|
951
|
+
for(let position=0;position<toolCalls.length;position++){
|
|
952
|
+
const toolCall=toolCalls[position]||{};
|
|
953
|
+
const toolFunction=toolCall.function||{};
|
|
954
|
+
const key=Number.isInteger(toolCall.index)
|
|
955
|
+
?`index:${toolCall.index}`
|
|
956
|
+
:toolCall.id
|
|
957
|
+
?`id:${toolCall.id}`
|
|
958
|
+
:`position:${position}`;
|
|
959
|
+
const record=streamedToolCalls.get(key)||{
|
|
960
|
+
arguments:'',
|
|
961
|
+
name:'',
|
|
962
|
+
order:streamedToolCalls.size
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
if(toolFunction.name){
|
|
966
|
+
record.name=toolFunction.name;
|
|
967
|
+
if(!triggeredTools.has(record.name)){
|
|
968
|
+
triggeredTools.add(record.name);
|
|
969
|
+
Promise.resolve(
|
|
970
|
+
earlyFunctionTrigger(record.name)
|
|
971
|
+
).catch(
|
|
972
|
+
error=>console.error('Early tool trigger failed:',error)
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if(typeof toolFunction.arguments==='string'){
|
|
978
|
+
record.arguments+=toolFunction.arguments;
|
|
979
|
+
}else if(toolFunction.arguments&&typeof toolFunction.arguments==='object'){
|
|
980
|
+
record.arguments+=JSON.stringify(toolFunction.arguments);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
streamedToolCalls.set(key,record);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
try{
|
|
988
|
+
while(true){
|
|
989
|
+
const {done,value:chunk}=await reader.read();
|
|
990
|
+
|
|
991
|
+
if(signal?.aborted){
|
|
992
|
+
throw normalizeAIRequestAbort();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if(done){
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
//alert(2) //const data=String.fromCharCode.apply(null, chunk).trim().replaceAll('data: ','');
|
|
1000
|
+
const data = decoder.decode(chunk, { stream: true})?.trim()?.replaceAll('data: ','');
|
|
1001
|
+
const lines=data.split('\n\n');
|
|
1002
|
+
//alert(3)
|
|
1003
|
+
//console.log(lines);
|
|
1004
|
+
|
|
1005
|
+
lines.forEach(
|
|
1006
|
+
function parsingAIGeneratedStream(delta,i){
|
|
1007
|
+
chunkCache+=delta;
|
|
1008
|
+
|
|
1009
|
+
if (chunkCache.trim() === '[DONE]') {
|
|
1010
|
+
responseEvents.push('[DONE]');
|
|
1011
|
+
chunkCache = '';
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
try{
|
|
1016
|
+
const resp=JSON.parse(chunkCache)||{};
|
|
1017
|
+
responseEvents.push(resp);
|
|
1018
|
+
//console.log(JSON.stringify(resp));
|
|
1019
|
+
//console.log(resp)
|
|
1020
|
+
const choice = resp.choices?.[0] || {};
|
|
1021
|
+
const delta = choice.delta || {};
|
|
1022
|
+
const content = delta.content || '';
|
|
1023
|
+
const tool_calls=delta.tool_calls || [];
|
|
1024
|
+
let value = content;
|
|
1025
|
+
|
|
1026
|
+
let reasoning = '';
|
|
1027
|
+
|
|
1028
|
+
if(seeThinking){
|
|
1029
|
+
reasoning=delta.reasoning || '';
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (reasoning) {
|
|
1033
|
+
isThinking = true;
|
|
1034
|
+
value = reasoning;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (!reasoning && isThinking) {
|
|
1038
|
+
//remove thinking chunks
|
|
1039
|
+
chunkString='';
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (!reasoning) {
|
|
1043
|
+
isThinking = false;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
chunkCache='';
|
|
1047
|
+
|
|
1048
|
+
if(value==='' && !tool_calls.length){
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if(value){
|
|
1053
|
+
streamHandler(value,`M-${id}`, isThinking);
|
|
1054
|
+
chunkString+=value;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if(tool_calls.length){
|
|
1058
|
+
receiveStreamedToolCalls(tool_calls);
|
|
1059
|
+
}
|
|
1060
|
+
} catch(err) {
|
|
1061
|
+
console.warn(err);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
}catch(error){
|
|
1067
|
+
if(isAIRequestAbort(error,signal)){
|
|
1068
|
+
throw normalizeAIRequestAbort(error);
|
|
1069
|
+
}
|
|
1070
|
+
throw error;
|
|
1071
|
+
}finally{
|
|
1072
|
+
reader.releaseLock();
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
this.#reportResponse(responseEvents,id,{
|
|
1076
|
+
operation:'stream',
|
|
1077
|
+
transport:'http',
|
|
1078
|
+
destination:this.url
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
const tool_funcs={};
|
|
1082
|
+
const orderedToolCalls=[...streamedToolCalls.values()].sort(
|
|
1083
|
+
function sortStreamedToolCalls(a,b){
|
|
1084
|
+
return a.order-b.order;
|
|
1085
|
+
}
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
for(const toolCall of orderedToolCalls){
|
|
1089
|
+
if(!toolCall.name){
|
|
1090
|
+
throw new Error('AI stream returned a tool call without a name.');
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if(Object.hasOwn(tool_funcs,toolCall.name)){
|
|
1094
|
+
throw new Error(`AI stream returned duplicate tool ${toolCall.name}.`);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
tool_funcs[toolCall.name]=toolCall.arguments;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const streamResult=Object.keys(tool_funcs).length
|
|
1101
|
+
?tool_funcs
|
|
1102
|
+
:chunkString;
|
|
1103
|
+
if(Object.keys(tool_funcs).length&&!chunkString){
|
|
1104
|
+
streamHandler('',`M-${id}`,false);
|
|
1105
|
+
}
|
|
1106
|
+
this.finishTTS();
|
|
1107
|
+
await streamComplete(streamResult, `M-${id}`,isThinking);
|
|
1108
|
+
|
|
1109
|
+
//sync
|
|
1110
|
+
speechTurnCompleted=true;
|
|
1111
|
+
return streamResult;
|
|
1112
|
+
}catch(error){
|
|
1113
|
+
if(isAIRequestAbort(error,signal)){
|
|
1114
|
+
throw normalizeAIRequestAbort(error);
|
|
1115
|
+
}
|
|
1116
|
+
throw error;
|
|
1117
|
+
}finally{
|
|
1118
|
+
if(!speechTurnCompleted){
|
|
1119
|
+
this.stopAudio();
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async fetchRequest({
|
|
1125
|
+
messages=[],
|
|
1126
|
+
structuredOutput=false,
|
|
1127
|
+
localOnly=false,
|
|
1128
|
+
tools=[],
|
|
1129
|
+
toolChoice='auto',
|
|
1130
|
+
parallelToolCalls=true,
|
|
1131
|
+
id=Date.now(),
|
|
1132
|
+
signal=null,
|
|
1133
|
+
onRequest=function ignoreFetchRequest(){},
|
|
1134
|
+
onResponse=function ignoreFetchResponse(){}
|
|
1135
|
+
}={}){
|
|
1136
|
+
if(localOnly!==true&&localOnly!==false){
|
|
1137
|
+
throw new TypeError('AI localOnly must be a boolean.');
|
|
1138
|
+
}
|
|
1139
|
+
if(localOnly&&this.llmService!=='OLLAMA'){
|
|
1140
|
+
const error=new Error(
|
|
1141
|
+
'This AI request requires a configured local model.'
|
|
1142
|
+
);
|
|
1143
|
+
error.code='AI_LOCAL_MODEL_REQUIRED';
|
|
1144
|
+
throw error;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
return this.fetch(
|
|
1148
|
+
messages,
|
|
1149
|
+
onResponse,
|
|
1150
|
+
structuredOutput,
|
|
1151
|
+
tools,
|
|
1152
|
+
toolChoice,
|
|
1153
|
+
parallelToolCalls,
|
|
1154
|
+
id,
|
|
1155
|
+
onRequest,
|
|
1156
|
+
signal
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
async fetch(
|
|
1161
|
+
messages=[],
|
|
1162
|
+
responseHandler=function ignoreFetchResponse(){},
|
|
1163
|
+
structuredOutput=false,
|
|
1164
|
+
tools=[],
|
|
1165
|
+
tool_choice='auto',
|
|
1166
|
+
parallel_tool_calls=true,
|
|
1167
|
+
id=Date.now(),
|
|
1168
|
+
requestHandler=function ignoreFetchRequest(){},
|
|
1169
|
+
signal=null,
|
|
1170
|
+
){
|
|
1171
|
+
this.#assertServiceConfigured(this.llmService);
|
|
1172
|
+
if(signal&&(
|
|
1173
|
+
typeof signal.aborted!=='boolean'
|
|
1174
|
+
||typeof signal.addEventListener!=='function'
|
|
1175
|
+
)){
|
|
1176
|
+
throw new TypeError('AI request signal must be an AbortSignal.');
|
|
1177
|
+
}
|
|
1178
|
+
if(signal?.aborted){
|
|
1179
|
+
throw normalizeAIRequestAbort();
|
|
1180
|
+
}
|
|
1181
|
+
const structuredOutputFormat=this.#structuredOutputFormat(structuredOutput);
|
|
1182
|
+
|
|
1183
|
+
const request={
|
|
1184
|
+
model:this.model,
|
|
1185
|
+
messages:messages,
|
|
1186
|
+
stream:false
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if(structuredOutputFormat){
|
|
1190
|
+
request.response_format=this.#openAIResponseFormat(
|
|
1191
|
+
structuredOutputFormat
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
if(tools.length){
|
|
1196
|
+
request.tools=tools;
|
|
1197
|
+
request.tool_choice=tool_choice;
|
|
1198
|
+
request.parallel_tool_calls=parallel_tool_calls;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if(this.llmService==='OLLAMA'&&this.reasoningEffort){
|
|
1202
|
+
request.reasoning_effort=this.reasoningEffort;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const nativeOllama=this.#nativeOllama();
|
|
1206
|
+
|
|
1207
|
+
if(nativeOllama){
|
|
1208
|
+
const ollamaTools=this.#ollamaTools(tools,tool_choice);
|
|
1209
|
+
const ollamaMessages=this.#ollamaMessages(messages,tool_choice);
|
|
1210
|
+
const nativeRequest={
|
|
1211
|
+
model:this.model,
|
|
1212
|
+
messages:ollamaMessages,
|
|
1213
|
+
stream:false,
|
|
1214
|
+
...(this.reasoningEffort?{think:this.reasoningEffort}:{}),
|
|
1215
|
+
...(structuredOutputFormat?{format:structuredOutputFormat}:{}),
|
|
1216
|
+
...(ollamaTools.length?{tools:ollamaTools}:{})
|
|
1217
|
+
};
|
|
1218
|
+
this.#reportRequest(requestHandler,nativeRequest,id,{
|
|
1219
|
+
operation:'fetch',
|
|
1220
|
+
transport:'native',
|
|
1221
|
+
destination:'Arcane.ollama.chat'
|
|
1222
|
+
});
|
|
1223
|
+
let nativeResponse;
|
|
1224
|
+
try{
|
|
1225
|
+
nativeResponse=await nativeOllama.chat(
|
|
1226
|
+
nativeRequest,
|
|
1227
|
+
{signal}
|
|
1228
|
+
);
|
|
1229
|
+
}catch(error){
|
|
1230
|
+
if(isAIRequestAbort(error,signal)){
|
|
1231
|
+
throw normalizeAIRequestAbort(error);
|
|
1232
|
+
}
|
|
1233
|
+
throw error;
|
|
1234
|
+
}
|
|
1235
|
+
this.#reportResponse(nativeResponse,id,{
|
|
1236
|
+
operation:'fetch',
|
|
1237
|
+
transport:'native',
|
|
1238
|
+
destination:'Arcane.ollama.chat'
|
|
1239
|
+
});
|
|
1240
|
+
if(signal?.aborted){
|
|
1241
|
+
throw normalizeAIRequestAbort();
|
|
1242
|
+
}
|
|
1243
|
+
this.#assertRequiredOllamaToolCall(
|
|
1244
|
+
Array.isArray(nativeResponse?.message?.tool_calls)
|
|
1245
|
+
?nativeResponse.message.tool_calls
|
|
1246
|
+
:[],
|
|
1247
|
+
tool_choice
|
|
1248
|
+
);
|
|
1249
|
+
const responseJSON=this.#openAICompatibleOllamaResponse(
|
|
1250
|
+
nativeResponse,
|
|
1251
|
+
id
|
|
1252
|
+
);
|
|
1253
|
+
|
|
1254
|
+
if(signal?.aborted){
|
|
1255
|
+
throw normalizeAIRequestAbort();
|
|
1256
|
+
}
|
|
1257
|
+
await responseHandler(responseJSON,id,false);
|
|
1258
|
+
return responseJSON;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
this.#reportRequest(requestHandler,request,id,{
|
|
1262
|
+
operation:'fetch',
|
|
1263
|
+
transport:'http',
|
|
1264
|
+
destination:this.url
|
|
1265
|
+
});
|
|
1266
|
+
const body = JSON.stringify(request);
|
|
1267
|
+
|
|
1268
|
+
let response;
|
|
1269
|
+
|
|
1270
|
+
try{
|
|
1271
|
+
response = await fetch(
|
|
1272
|
+
this.url,
|
|
1273
|
+
{
|
|
1274
|
+
method: 'POST',
|
|
1275
|
+
credentials: credentials,
|
|
1276
|
+
headers: this.#serviceHeaders[this.llmService],
|
|
1277
|
+
body: body,
|
|
1278
|
+
...(signal?{signal}:{})
|
|
1279
|
+
}
|
|
1280
|
+
);
|
|
1281
|
+
}catch(err){
|
|
1282
|
+
if(isAIRequestAbort(err,signal)){
|
|
1283
|
+
throw normalizeAIRequestAbort(err);
|
|
1284
|
+
}
|
|
1285
|
+
const error=new Error(
|
|
1286
|
+
'Unable to reach the AI service.',
|
|
1287
|
+
{cause:err}
|
|
1288
|
+
);
|
|
1289
|
+
error.code='AI_SERVICE_UNREACHABLE';
|
|
1290
|
+
throw error;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
await this.#assertResponseOK(response);
|
|
1294
|
+
|
|
1295
|
+
const contentType=response.headers.get('content-type')||'';
|
|
1296
|
+
|
|
1297
|
+
if(!contentType.includes('application/json')){
|
|
1298
|
+
throw new TypeError(
|
|
1299
|
+
`AI request returned ${contentType||'an unknown content type'} instead of JSON.`
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
let responseJSON;
|
|
1304
|
+
try{
|
|
1305
|
+
responseJSON=await response.json();
|
|
1306
|
+
}catch(error){
|
|
1307
|
+
if(isAIRequestAbort(error,signal)){
|
|
1308
|
+
throw normalizeAIRequestAbort(error);
|
|
1309
|
+
}
|
|
1310
|
+
throw error;
|
|
1311
|
+
}
|
|
1312
|
+
this.#reportResponse(responseJSON,id,{
|
|
1313
|
+
operation:'fetch',
|
|
1314
|
+
transport:'http',
|
|
1315
|
+
destination:this.url
|
|
1316
|
+
});
|
|
1317
|
+
if(signal?.aborted){
|
|
1318
|
+
throw normalizeAIRequestAbort();
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
if(!response.id){
|
|
1322
|
+
response.id=id;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
//console.log(responseJSON);
|
|
1326
|
+
//async
|
|
1327
|
+
await responseHandler(responseJSON,id,false);
|
|
1328
|
+
//sync
|
|
1329
|
+
return responseJSON;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
streamTTS(
|
|
1333
|
+
text='',
|
|
1334
|
+
end=false
|
|
1335
|
+
){
|
|
1336
|
+
if(this.muted){
|
|
1337
|
+
if(end){
|
|
1338
|
+
this.audioMessageChunks='';
|
|
1339
|
+
}
|
|
1340
|
+
return Promise.resolve(false);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
this.audioMessageChunks+=String(text||'');
|
|
1344
|
+
const outputs=this.#extractSpeechSegments(end);
|
|
1345
|
+
|
|
1346
|
+
if(!outputs.length){
|
|
1347
|
+
return Promise.resolve(true);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
try{
|
|
1351
|
+
this.#assertServiceConfigured(this.ttsService);
|
|
1352
|
+
}catch(error){
|
|
1353
|
+
console.warn('Error preparing speech from AI:',error);
|
|
1354
|
+
return Promise.resolve(false);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const generation=this.speechGeneration;
|
|
1358
|
+
const jobs=[];
|
|
1359
|
+
|
|
1360
|
+
for(const output of outputs){
|
|
1361
|
+
jobs.push(this.#queueSpeechJob(output,generation));
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return Promise.all(jobs).then(
|
|
1365
|
+
function reportQueuedSpeechResult(results){
|
|
1366
|
+
return results.every(Boolean);
|
|
1367
|
+
}
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
finishTTS(){
|
|
1372
|
+
return this.streamTTS('',true);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
#extractSpeechSegments(end=false){
|
|
1376
|
+
const segments=[];
|
|
1377
|
+
const maximumLength=220;
|
|
1378
|
+
let remainder=this.audioMessageChunks;
|
|
1379
|
+
|
|
1380
|
+
while(remainder.trim()){
|
|
1381
|
+
const terminator=this.#findSpeechTerminator(remainder,end);
|
|
1382
|
+
let boundary=terminator
|
|
1383
|
+
?terminator.index+terminator[0].length
|
|
1384
|
+
:-1;
|
|
1385
|
+
|
|
1386
|
+
if(boundary<0&&remainder.length>=maximumLength){
|
|
1387
|
+
const candidate=remainder.slice(0,maximumLength+1);
|
|
1388
|
+
const whitespace=candidate.lastIndexOf(' ');
|
|
1389
|
+
boundary=whitespace>=80?whitespace+1:maximumLength;
|
|
1390
|
+
}else if(boundary<0&&end){
|
|
1391
|
+
boundary=remainder.length;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
if(boundary<0){
|
|
1395
|
+
break;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const segment=remainder.slice(0,boundary).trim();
|
|
1399
|
+
remainder=remainder.slice(boundary).trimStart();
|
|
1400
|
+
|
|
1401
|
+
if(segment){
|
|
1402
|
+
segments.push(segment);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
this.audioMessageChunks=remainder;
|
|
1407
|
+
return segments;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
#findSpeechTerminator(text,end=false){
|
|
1411
|
+
const pattern=end
|
|
1412
|
+
?/(?:[\u3002\uFF01\uFF1F]|[.!?](?=\s|$))/g
|
|
1413
|
+
:/(?:[\u3002\uFF01\uFF1F]|[.!?](?=\s+\S))/g;
|
|
1414
|
+
let terminator;
|
|
1415
|
+
|
|
1416
|
+
while((terminator=pattern.exec(text))){
|
|
1417
|
+
if(
|
|
1418
|
+
terminator[0]==='.'
|
|
1419
|
+
&&this.#isSpeechAbbreviation(text,terminator.index)
|
|
1420
|
+
){
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
return terminator;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
#isSpeechAbbreviation(text,periodIndex){
|
|
1431
|
+
const beforePeriod=text.slice(0,periodIndex);
|
|
1432
|
+
const token=beforePeriod.match(/([A-Za-z][A-Za-z.]*)$/)?.[1]?.toLowerCase();
|
|
1433
|
+
|
|
1434
|
+
if(!token){
|
|
1435
|
+
const currentLine=beforePeriod.slice(beforePeriod.lastIndexOf('\n')+1);
|
|
1436
|
+
return /^\s*\d+$/.test(currentLine);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
if(token.length===1||/^(?:[a-z]\.)+[a-z]$/.test(token)){
|
|
1440
|
+
return true;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
return this.#speechAbbreviations.has(token);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
#queueSpeechJob(text,generation){
|
|
1447
|
+
const job={
|
|
1448
|
+
abortController:null,
|
|
1449
|
+
generation,
|
|
1450
|
+
sourceNode:null,
|
|
1451
|
+
state:'queued',
|
|
1452
|
+
text
|
|
1453
|
+
};
|
|
1454
|
+
const runtime=this;
|
|
1455
|
+
const previous=this.speechSynthesisTail;
|
|
1456
|
+
|
|
1457
|
+
this.speechJobs.push(job);
|
|
1458
|
+
|
|
1459
|
+
const synthesis=previous.then(
|
|
1460
|
+
function synthesizeQueuedSpeech(){
|
|
1461
|
+
return runtime.#prepareSpeechJob(job);
|
|
1462
|
+
}
|
|
1463
|
+
).catch(
|
|
1464
|
+
function discardFailedSpeechJob(error){
|
|
1465
|
+
return runtime.#failSpeechJob(job,error);
|
|
1466
|
+
}
|
|
1467
|
+
);
|
|
1468
|
+
|
|
1469
|
+
this.speechSynthesisTail=synthesis.then(
|
|
1470
|
+
function releaseSpeechSynthesisSlot(){
|
|
1471
|
+
return undefined;
|
|
1472
|
+
}
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
return synthesis;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async #prepareSpeechJob(job){
|
|
1479
|
+
if(job.generation!==this.speechGeneration||this.muted){
|
|
1480
|
+
return this.#cancelSpeechJob(job);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
job.state='synthesizing';
|
|
1484
|
+
const audio=await this.#requestSpeechAudio(job);
|
|
1485
|
+
|
|
1486
|
+
if(job.generation!==this.speechGeneration||this.muted){
|
|
1487
|
+
return this.#cancelSpeechJob(job);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const audioContext=this.#getSpeechAudioContext();
|
|
1491
|
+
return this.playAudio(
|
|
1492
|
+
audio.chunks,
|
|
1493
|
+
audioContext,
|
|
1494
|
+
null,
|
|
1495
|
+
audio.type,
|
|
1496
|
+
job
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
async #requestSpeechAudio(job){
|
|
1501
|
+
const nativeSpeech=this.#nativeSpeech(this.ttsService);
|
|
1502
|
+
|
|
1503
|
+
if(nativeSpeech){
|
|
1504
|
+
const response=await nativeSpeech.synthesize({
|
|
1505
|
+
model:this.modelTTS,
|
|
1506
|
+
voice:String(window.user?.AI_voice||'af_heart'),
|
|
1507
|
+
input:job.text,
|
|
1508
|
+
responseFormat:this.audioFormat,
|
|
1509
|
+
speed:this.voiceSpeed
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
if(!response||typeof response.audioBase64!=='string'){
|
|
1513
|
+
throw new TypeError('Arcane returned an invalid local speech response.');
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
return {
|
|
1517
|
+
chunks:[this.#base64ToBytes(response.audioBase64)],
|
|
1518
|
+
type:typeof response.contentType==='string'
|
|
1519
|
+
?response.contentType
|
|
1520
|
+
:this.audioType
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
await this.#assertAndroidSpeechBridge(this.ttsService);
|
|
1525
|
+
|
|
1526
|
+
job.abortController=new AbortController();
|
|
1527
|
+
const personality=await window.user?.personality
|
|
1528
|
+
||'A behavioral health technician with a slight veteran feel on occasion.';
|
|
1529
|
+
const religion=await window.user?.religion||'caring';
|
|
1530
|
+
const request={
|
|
1531
|
+
model:this.modelTTS,
|
|
1532
|
+
voice:window.user?.AI_voice,
|
|
1533
|
+
input:job.text,
|
|
1534
|
+
speed:this.voiceSpeed,
|
|
1535
|
+
instructions:`${personality} and sounding a bit ${religion}`,
|
|
1536
|
+
response_format:this.audioFormat
|
|
1537
|
+
};
|
|
1538
|
+
const response=await fetch(
|
|
1539
|
+
this.urlTTS,
|
|
1540
|
+
{
|
|
1541
|
+
method:'POST',
|
|
1542
|
+
credentials,
|
|
1543
|
+
headers:this.#ttsHeaders[this.ttsService],
|
|
1544
|
+
body:JSON.stringify(request),
|
|
1545
|
+
signal:job.abortController.signal
|
|
1546
|
+
}
|
|
1547
|
+
);
|
|
1548
|
+
|
|
1549
|
+
if(!response.ok){
|
|
1550
|
+
throw new Error(`Speech synthesis failed with status ${response.status}.`);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const reader=response.body?.getReader?.();
|
|
1554
|
+
|
|
1555
|
+
if(!reader){
|
|
1556
|
+
throw new TypeError('Speech synthesis response body is not readable.');
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const chunks=[];
|
|
1560
|
+
|
|
1561
|
+
try{
|
|
1562
|
+
while(true){
|
|
1563
|
+
const {done,value}=await reader.read();
|
|
1564
|
+
|
|
1565
|
+
if(done){
|
|
1566
|
+
break;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
if(value){
|
|
1570
|
+
chunks.push(value);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
}finally{
|
|
1574
|
+
reader.releaseLock?.();
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
return {chunks,type:this.audioType};
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
#getSpeechAudioContext(){
|
|
1581
|
+
if(this.audioContext&&this.audioContext.state!=='closed'){
|
|
1582
|
+
return this.audioContext;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
const AudioContext=window.AudioContext||window.webkitAudioContext;
|
|
1586
|
+
|
|
1587
|
+
if(typeof AudioContext!=='function'){
|
|
1588
|
+
throw new TypeError('Audio playback is unavailable in this browser.');
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
this.audioContext=new AudioContext();
|
|
1592
|
+
return this.audioContext;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
async fetchSTT(
|
|
1596
|
+
audioFile,
|
|
1597
|
+
responseHandler=(text='')=>{}
|
|
1598
|
+
){
|
|
1599
|
+
this.#assertServiceConfigured(this.sttService);
|
|
1600
|
+
|
|
1601
|
+
const nativeSpeech=this.#nativeSpeech(this.sttService);
|
|
1602
|
+
|
|
1603
|
+
if(nativeSpeech){
|
|
1604
|
+
if(!audioFile||typeof audioFile.arrayBuffer!=='function'){
|
|
1605
|
+
throw new TypeError('Speech transcription requires an audio Blob or File.');
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const response=await nativeSpeech.transcribe({
|
|
1609
|
+
audioBase64:this.#arrayBufferToBase64(await audioFile.arrayBuffer()),
|
|
1610
|
+
mimeType:String(audioFile.type||'audio/webm'),
|
|
1611
|
+
model:this.modelSTT
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
if(!response||typeof response.text!=='string'){
|
|
1615
|
+
throw new TypeError('Arcane returned an invalid local speech transcription.');
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
await responseHandler(response.text);
|
|
1619
|
+
return response.text;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
await this.#assertAndroidSpeechBridge(this.sttService);
|
|
1623
|
+
|
|
1624
|
+
const formData = new FormData();
|
|
1625
|
+
formData.append('file', audioFile);
|
|
1626
|
+
formData.append('model', this.modelSTT);
|
|
1627
|
+
formData.append('response_format', 'text');
|
|
1628
|
+
|
|
1629
|
+
const response = await fetch(
|
|
1630
|
+
this.urlSTT,
|
|
1631
|
+
{
|
|
1632
|
+
method: 'POST',
|
|
1633
|
+
credentials: credentials,
|
|
1634
|
+
headers: this.#sttHeaders[this.sttService],
|
|
1635
|
+
body: formData
|
|
1636
|
+
}
|
|
1637
|
+
);
|
|
1638
|
+
|
|
1639
|
+
if(!response.ok){
|
|
1640
|
+
throw new Error(`Speech transcription failed with status ${response.status}.`);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const text = await response.text();
|
|
1644
|
+
|
|
1645
|
+
//async
|
|
1646
|
+
await responseHandler(text);
|
|
1647
|
+
|
|
1648
|
+
//sync
|
|
1649
|
+
return text;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
stopAudio(){
|
|
1653
|
+
this.speechGeneration+=1;
|
|
1654
|
+
this.speechResumeAttempt+=1;
|
|
1655
|
+
this.speechResumePending=false;
|
|
1656
|
+
this.audioMessageChunks='';
|
|
1657
|
+
this.#clearSpeechUnlock();
|
|
1658
|
+
|
|
1659
|
+
for(const job of this.speechJobs){
|
|
1660
|
+
job.abortController?.abort();
|
|
1661
|
+
job.state='cancelled';
|
|
1662
|
+
|
|
1663
|
+
if(job.sourceNode){
|
|
1664
|
+
job.sourceNode.onended=null;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
for(const sourceNode of this.sourceNodes){
|
|
1669
|
+
sourceNode.onended=null;
|
|
1670
|
+
|
|
1671
|
+
if(sourceNode.__arcaneStarted){
|
|
1672
|
+
try{
|
|
1673
|
+
sourceNode.stop();
|
|
1674
|
+
}catch(error){
|
|
1675
|
+
console.warn('Error stopping AI audio:',error);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
sourceNode.disconnect?.();
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
this.speechJobs.splice(0);
|
|
1683
|
+
this.sourceNodes.splice(0);
|
|
1684
|
+
this.currentSpeechJob=null;
|
|
1685
|
+
this.isSpeaking=false;
|
|
1686
|
+
return true;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async resumeAudio(audioContext=null,fromUserGesture=true){
|
|
1690
|
+
if(this.muted){
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
if(fromUserGesture){
|
|
1695
|
+
this.#clearSpeechUnlock();
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
let attempt=0;
|
|
1699
|
+
let context;
|
|
1700
|
+
|
|
1701
|
+
try{
|
|
1702
|
+
context=audioContext||this.#getSpeechAudioContext();
|
|
1703
|
+
|
|
1704
|
+
if(context.state==='running'){
|
|
1705
|
+
this.#clearSpeechUnlock();
|
|
1706
|
+
this.#requestSpeechPlayback();
|
|
1707
|
+
return true;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if(typeof context.resume!=='function'){
|
|
1711
|
+
this.#waitForSpeechGesture();
|
|
1712
|
+
return false;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
attempt=++this.speechResumeAttempt;
|
|
1716
|
+
this.speechResumePending=true;
|
|
1717
|
+
await context.resume();
|
|
1718
|
+
|
|
1719
|
+
if(attempt!==this.speechResumeAttempt){
|
|
1720
|
+
return context.state==='running';
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
this.speechResumePending=false;
|
|
1724
|
+
|
|
1725
|
+
if(context.state==='running'){
|
|
1726
|
+
this.#clearSpeechUnlock();
|
|
1727
|
+
this.#requestSpeechPlayback();
|
|
1728
|
+
return true;
|
|
1729
|
+
}
|
|
1730
|
+
}catch(error){
|
|
1731
|
+
if(attempt&&attempt!==this.speechResumeAttempt){
|
|
1732
|
+
return context?.state==='running';
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if(attempt===this.speechResumeAttempt){
|
|
1736
|
+
this.speechResumePending=false;
|
|
1737
|
+
}
|
|
1738
|
+
this.#waitForSpeechGesture(error);
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
this.#waitForSpeechGesture();
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
async playAudio(
|
|
1747
|
+
audioChunks=[],
|
|
1748
|
+
audioContext=this.#getSpeechAudioContext(),
|
|
1749
|
+
sourceNode=null,
|
|
1750
|
+
audioType=this.audioType,
|
|
1751
|
+
speechJob=null
|
|
1752
|
+
){
|
|
1753
|
+
const job=speechJob||{
|
|
1754
|
+
abortController:null,
|
|
1755
|
+
generation:this.speechGeneration,
|
|
1756
|
+
sourceNode:null,
|
|
1757
|
+
state:'decoding',
|
|
1758
|
+
text:''
|
|
1759
|
+
};
|
|
1760
|
+
|
|
1761
|
+
if(!speechJob){
|
|
1762
|
+
this.speechJobs.push(job);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
if(this.muted||job.generation!==this.speechGeneration){
|
|
1766
|
+
return this.#cancelSpeechJob(job);
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
try{
|
|
1770
|
+
job.state='decoding';
|
|
1771
|
+
const audioBlob=new Blob(audioChunks,{type:audioType});
|
|
1772
|
+
const arrayBuffer=await audioBlob.arrayBuffer();
|
|
1773
|
+
const audioBuffer=await audioContext.decodeAudioData(arrayBuffer);
|
|
1774
|
+
|
|
1775
|
+
if(this.muted||job.generation!==this.speechGeneration){
|
|
1776
|
+
return this.#cancelSpeechJob(job);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
const preparedSource=sourceNode||audioContext.createBufferSource();
|
|
1780
|
+
const runtime=this;
|
|
1781
|
+
|
|
1782
|
+
preparedSource.buffer=audioBuffer;
|
|
1783
|
+
preparedSource.connect(audioContext.destination);
|
|
1784
|
+
preparedSource.__arcaneStarted=false;
|
|
1785
|
+
preparedSource.onended=function finishQueuedSpeechSource(){
|
|
1786
|
+
runtime.nextSentance(job);
|
|
1787
|
+
};
|
|
1788
|
+
job.sourceNode=preparedSource;
|
|
1789
|
+
job.state='ready';
|
|
1790
|
+
this.sourceNodes.push(preparedSource);
|
|
1791
|
+
this.#requestSpeechPlayback();
|
|
1792
|
+
return true;
|
|
1793
|
+
}catch(error){
|
|
1794
|
+
return this.#failSpeechJob(job,error);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
#requestSpeechPlayback(){
|
|
1799
|
+
this.#pumpSpeechPlayback().catch(
|
|
1800
|
+
function reportSpeechPlaybackFailure(error){
|
|
1801
|
+
console.warn('Error playing audio data:',error);
|
|
1802
|
+
}
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
async #pumpSpeechPlayback(){
|
|
1807
|
+
if(this.speechPlaybackStarting||this.isSpeaking||this.muted){
|
|
1808
|
+
return false;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
this.speechPlaybackStarting=true;
|
|
1812
|
+
|
|
1813
|
+
try{
|
|
1814
|
+
while(!this.isSpeaking&&!this.muted){
|
|
1815
|
+
const job=this.speechJobs[0];
|
|
1816
|
+
|
|
1817
|
+
if(!job){
|
|
1818
|
+
return false;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
if(job.generation!==this.speechGeneration||['cancelled','failed'].includes(job.state)){
|
|
1822
|
+
this.#removeSpeechJob(job);
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
if(job.state!=='ready'||!job.sourceNode?.buffer){
|
|
1827
|
+
return false;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
const audioContext=this.#getSpeechAudioContext();
|
|
1831
|
+
|
|
1832
|
+
if(audioContext.state!=='running'){
|
|
1833
|
+
this.#waitForSpeechGesture();
|
|
1834
|
+
|
|
1835
|
+
if(!this.speechResumePending){
|
|
1836
|
+
this.resumeAudio(audioContext,false);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
if(
|
|
1843
|
+
this.muted
|
|
1844
|
+
||job!==this.speechJobs[0]
|
|
1845
|
+
||job.generation!==this.speechGeneration
|
|
1846
|
+
||job.state!=='ready'
|
|
1847
|
+
){
|
|
1848
|
+
continue;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
try{
|
|
1852
|
+
job.state='playing';
|
|
1853
|
+
job.sourceNode.__arcaneStarted=true;
|
|
1854
|
+
this.currentSpeechJob=job;
|
|
1855
|
+
this.isSpeaking=true;
|
|
1856
|
+
job.sourceNode.start(0);
|
|
1857
|
+
return true;
|
|
1858
|
+
}catch(error){
|
|
1859
|
+
this.currentSpeechJob=null;
|
|
1860
|
+
this.isSpeaking=false;
|
|
1861
|
+
this.#failSpeechJob(job,error);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
}finally{
|
|
1865
|
+
this.speechPlaybackStarting=false;
|
|
1866
|
+
|
|
1867
|
+
if(
|
|
1868
|
+
!this.isSpeaking
|
|
1869
|
+
&&!this.muted
|
|
1870
|
+
&&!this.speechAwaitingGesture
|
|
1871
|
+
&&!this.speechResumePending
|
|
1872
|
+
&&this.speechJobs[0]?.state==='ready'
|
|
1873
|
+
){
|
|
1874
|
+
this.#requestSpeechPlayback();
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
return false;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
nextSentance(job=this.currentSpeechJob){
|
|
1882
|
+
if(!job||job.generation!==this.speechGeneration){
|
|
1883
|
+
return false;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
if(job.sourceNode){
|
|
1887
|
+
job.sourceNode.onended=null;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
job.state='complete';
|
|
1891
|
+
this.#removeSpeechJob(job);
|
|
1892
|
+
|
|
1893
|
+
if(this.currentSpeechJob===job){
|
|
1894
|
+
this.currentSpeechJob=null;
|
|
1895
|
+
this.isSpeaking=false;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
this.#requestSpeechPlayback();
|
|
1899
|
+
return true;
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
#cancelSpeechJob(job){
|
|
1903
|
+
job.abortController?.abort();
|
|
1904
|
+
job.state='cancelled';
|
|
1905
|
+
this.#removeSpeechJob(job);
|
|
1906
|
+
return false;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
#failSpeechJob(job,error){
|
|
1910
|
+
if(job.state==='failed'||job.state==='cancelled'){
|
|
1911
|
+
return false;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
job.state='failed';
|
|
1915
|
+
|
|
1916
|
+
if(job.sourceNode){
|
|
1917
|
+
job.sourceNode.onended=null;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
this.#removeSpeechJob(job);
|
|
1921
|
+
|
|
1922
|
+
if(this.currentSpeechJob===job){
|
|
1923
|
+
this.currentSpeechJob=null;
|
|
1924
|
+
this.isSpeaking=false;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
if(job.generation===this.speechGeneration&&error?.name!=='AbortError'){
|
|
1928
|
+
console.warn('Error preparing audio from AI:',error);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
this.#requestSpeechPlayback();
|
|
1932
|
+
return false;
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
#removeSpeechJob(job){
|
|
1936
|
+
const jobIndex=this.speechJobs.indexOf(job);
|
|
1937
|
+
|
|
1938
|
+
if(jobIndex>=0){
|
|
1939
|
+
this.speechJobs.splice(jobIndex,1);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
const sourceIndex=this.sourceNodes.indexOf(job.sourceNode);
|
|
1943
|
+
|
|
1944
|
+
if(sourceIndex>=0){
|
|
1945
|
+
this.sourceNodes.splice(sourceIndex,1);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
job.sourceNode?.disconnect?.();
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
#waitForSpeechGesture(error=null){
|
|
1952
|
+
if(this.speechUnlockHandler){
|
|
1953
|
+
return false;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
const runtime=this;
|
|
1957
|
+
const target=window;
|
|
1958
|
+
|
|
1959
|
+
this.speechAwaitingGesture=true;
|
|
1960
|
+
this.speechUnlockHandler=function unlockSpeechFromUserGesture(){
|
|
1961
|
+
runtime.#clearSpeechUnlock();
|
|
1962
|
+
runtime.resumeAudio();
|
|
1963
|
+
};
|
|
1964
|
+
|
|
1965
|
+
target.addEventListener?.(
|
|
1966
|
+
'pointerdown',
|
|
1967
|
+
this.speechUnlockHandler,
|
|
1968
|
+
{capture:true,once:true}
|
|
1969
|
+
);
|
|
1970
|
+
target.addEventListener?.(
|
|
1971
|
+
'keydown',
|
|
1972
|
+
this.speechUnlockHandler,
|
|
1973
|
+
{capture:true,once:true}
|
|
1974
|
+
);
|
|
1975
|
+
|
|
1976
|
+
if(error?.name&&error.name!=='NotAllowedError'){
|
|
1977
|
+
console.info('AI speech is waiting for audio playback permission:',error);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
return true;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
#clearSpeechUnlock(){
|
|
1984
|
+
if(this.speechUnlockHandler){
|
|
1985
|
+
window.removeEventListener?.(
|
|
1986
|
+
'pointerdown',
|
|
1987
|
+
this.speechUnlockHandler,
|
|
1988
|
+
true
|
|
1989
|
+
);
|
|
1990
|
+
window.removeEventListener?.(
|
|
1991
|
+
'keydown',
|
|
1992
|
+
this.speechUnlockHandler,
|
|
1993
|
+
true
|
|
1994
|
+
);
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
this.speechUnlockHandler=null;
|
|
1998
|
+
this.speechAwaitingGesture=false;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
window.addEventListener(
|
|
2003
|
+
'user-entity-loaded',
|
|
2004
|
+
instantiateAI
|
|
2005
|
+
);
|
|
2006
|
+
|
|
2007
|
+
if(window.user?.ready){
|
|
2008
|
+
instantiateAI();
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
function instantiateAI(event) {
|
|
2012
|
+
if(
|
|
2013
|
+
event?.detail?.user
|
|
2014
|
+
&&event.detail.user!==window.user
|
|
2015
|
+
){
|
|
2016
|
+
return;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
if(!window.user?.ready){
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
if(!window.ai){
|
|
2024
|
+
const preferences=getAIPreferencesForRuntime(window.user);
|
|
2025
|
+
|
|
2026
|
+
window.ai=new AI(
|
|
2027
|
+
preferences[0],
|
|
2028
|
+
preferences[1],
|
|
2029
|
+
preferences[2],
|
|
2030
|
+
preferences[3],
|
|
2031
|
+
preferences[4],
|
|
2032
|
+
preferences[5]
|
|
2033
|
+
);
|
|
2034
|
+
|
|
2035
|
+
window.ai.ready=true;
|
|
2036
|
+
|
|
2037
|
+
const aiReady=new CustomEvent(
|
|
2038
|
+
'ai-ready', {
|
|
2039
|
+
detail: { db: window.ai }
|
|
2040
|
+
}
|
|
2041
|
+
);
|
|
2042
|
+
|
|
2043
|
+
window.dispatchEvent(aiReady);
|
|
2044
|
+
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
export default AI;
|