@fiodos/web-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +77 -0
- package/dist/cjs/adapters/webNavigationAdapter.d.ts +31 -0
- package/dist/cjs/adapters/webNavigationAdapter.js +45 -0
- package/dist/cjs/adapters/webStorageAdapter.d.ts +13 -0
- package/dist/cjs/adapters/webStorageAdapter.js +57 -0
- package/dist/cjs/adapters/webVoiceAdapter.d.ts +16 -0
- package/dist/cjs/adapters/webVoiceAdapter.js +477 -0
- package/dist/cjs/api/backendClient.d.ts +31 -0
- package/dist/cjs/api/backendClient.js +182 -0
- package/dist/cjs/api/backendTelemetry.d.ts +23 -0
- package/dist/cjs/api/backendTelemetry.js +80 -0
- package/dist/cjs/api/clientBootstrap.d.ts +44 -0
- package/dist/cjs/api/clientBootstrap.js +68 -0
- package/dist/cjs/api/errors.d.ts +11 -0
- package/dist/cjs/api/errors.js +16 -0
- package/dist/cjs/bridge/createBridge.d.ts +13 -0
- package/dist/cjs/bridge/createBridge.js +15 -0
- package/dist/cjs/config/types.d.ts +63 -0
- package/dist/cjs/config/types.js +9 -0
- package/dist/cjs/context/screenContextStore.d.ts +26 -0
- package/dist/cjs/context/screenContextStore.js +25 -0
- package/dist/cjs/controller/AgentController.d.ts +115 -0
- package/dist/cjs/controller/AgentController.js +594 -0
- package/dist/cjs/core/turnEngine.d.ts +70 -0
- package/dist/cjs/core/turnEngine.js +96 -0
- package/dist/cjs/dropin/createFiodosAgent.d.ts +60 -0
- package/dist/cjs/dropin/createFiodosAgent.js +143 -0
- package/dist/cjs/dropin/createFyodosAgent.d.ts +60 -0
- package/dist/cjs/dropin/createFyodosAgent.js +143 -0
- package/dist/cjs/index.d.ts +45 -0
- package/dist/cjs/index.js +67 -0
- package/dist/cjs/orb/mountOrb.d.ts +24 -0
- package/dist/cjs/orb/mountOrb.js +694 -0
- package/dist/cjs/orb/orbView.d.ts +56 -0
- package/dist/cjs/orb/orbView.js +426 -0
- package/dist/cjs/orb/publishedConfig.d.ts +32 -0
- package/dist/cjs/orb/publishedConfig.js +70 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/speech/speechSession.d.ts +35 -0
- package/dist/cjs/speech/speechSession.js +60 -0
- package/dist/cjs/ui/messages.d.ts +36 -0
- package/dist/cjs/ui/messages.js +41 -0
- package/dist/esm/adapters/webNavigationAdapter.d.ts +31 -0
- package/dist/esm/adapters/webNavigationAdapter.js +41 -0
- package/dist/esm/adapters/webStorageAdapter.d.ts +13 -0
- package/dist/esm/adapters/webStorageAdapter.js +54 -0
- package/dist/esm/adapters/webVoiceAdapter.d.ts +16 -0
- package/dist/esm/adapters/webVoiceAdapter.js +474 -0
- package/dist/esm/api/backendClient.d.ts +31 -0
- package/dist/esm/api/backendClient.js +178 -0
- package/dist/esm/api/backendTelemetry.d.ts +23 -0
- package/dist/esm/api/backendTelemetry.js +77 -0
- package/dist/esm/api/clientBootstrap.d.ts +44 -0
- package/dist/esm/api/clientBootstrap.js +63 -0
- package/dist/esm/api/errors.d.ts +11 -0
- package/dist/esm/api/errors.js +11 -0
- package/dist/esm/bridge/createBridge.d.ts +13 -0
- package/dist/esm/bridge/createBridge.js +12 -0
- package/dist/esm/config/types.d.ts +63 -0
- package/dist/esm/config/types.js +6 -0
- package/dist/esm/context/screenContextStore.d.ts +26 -0
- package/dist/esm/context/screenContextStore.js +22 -0
- package/dist/esm/controller/AgentController.d.ts +115 -0
- package/dist/esm/controller/AgentController.js +590 -0
- package/dist/esm/core/turnEngine.d.ts +70 -0
- package/dist/esm/core/turnEngine.js +92 -0
- package/dist/esm/dropin/createFiodosAgent.d.ts +60 -0
- package/dist/esm/dropin/createFiodosAgent.js +140 -0
- package/dist/esm/dropin/createFyodosAgent.d.ts +60 -0
- package/dist/esm/dropin/createFyodosAgent.js +140 -0
- package/dist/esm/index.d.ts +45 -0
- package/dist/esm/index.js +38 -0
- package/dist/esm/orb/mountOrb.d.ts +24 -0
- package/dist/esm/orb/mountOrb.js +691 -0
- package/dist/esm/orb/orbView.d.ts +56 -0
- package/dist/esm/orb/orbView.js +421 -0
- package/dist/esm/orb/publishedConfig.d.ts +32 -0
- package/dist/esm/orb/publishedConfig.js +67 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/speech/speechSession.d.ts +35 -0
- package/dist/esm/speech/speechSession.js +57 -0
- package/dist/esm/ui/messages.d.ts +36 -0
- package/dist/esm/ui/messages.js +38 -0
- package/package.json +38 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { detectWakePhrase } from '@fiodos/core';
|
|
2
|
+
function getRecognitionCtor() {
|
|
3
|
+
if (typeof window === 'undefined')
|
|
4
|
+
return null;
|
|
5
|
+
const w = window;
|
|
6
|
+
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* iOS Safari/WebKit: continuous passive recognition is not viable (the OS does
|
|
10
|
+
* not allow background-style mic streaming from a web page, and Web Speech is
|
|
11
|
+
* effectively unavailable). We refuse wake-word there rather than fake passive
|
|
12
|
+
* listening the browser actually blocks — push-to-talk / text stay available.
|
|
13
|
+
*/
|
|
14
|
+
function isIosWeb() {
|
|
15
|
+
if (typeof navigator === 'undefined')
|
|
16
|
+
return false;
|
|
17
|
+
const ua = navigator.userAgent || '';
|
|
18
|
+
const iOSDevice = /iPad|iPhone|iPod/.test(ua);
|
|
19
|
+
// iPadOS 13+ reports as Mac; detect touch + Mac to be safe.
|
|
20
|
+
const iPadOS = /Macintosh/.test(ua) &&
|
|
21
|
+
typeof document !== 'undefined' &&
|
|
22
|
+
'ontouchend' in document;
|
|
23
|
+
return iOSDevice || iPadOS;
|
|
24
|
+
}
|
|
25
|
+
export function createWebVoiceAdapter(options = {}) {
|
|
26
|
+
const Ctor = getRecognitionCtor();
|
|
27
|
+
let recognition = null;
|
|
28
|
+
let finalTranscript = '';
|
|
29
|
+
let stopTimer = null;
|
|
30
|
+
let aborted = false;
|
|
31
|
+
let settled = false;
|
|
32
|
+
let audioEl = null;
|
|
33
|
+
// ── Wake-word state (separate recognition instance; the browser allows only
|
|
34
|
+
// one active session, so push-to-talk stops wake first). Desktop-only:
|
|
35
|
+
// foreground tab while the page is visible — there is no passive background
|
|
36
|
+
// listening on the web by browser design. ───────────────────────────────────
|
|
37
|
+
let wakeRec = null;
|
|
38
|
+
let wakeListening = false;
|
|
39
|
+
let wakeDetected = false;
|
|
40
|
+
let wakePhrases = [];
|
|
41
|
+
let wakeLocale = '';
|
|
42
|
+
let wakeEvents = null;
|
|
43
|
+
let wakeRestartTimer = null;
|
|
44
|
+
function clearWakeRestart() {
|
|
45
|
+
if (wakeRestartTimer) {
|
|
46
|
+
clearTimeout(wakeRestartTimer);
|
|
47
|
+
wakeRestartTimer = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function teardownWake() {
|
|
51
|
+
clearWakeRestart();
|
|
52
|
+
if (wakeRec) {
|
|
53
|
+
wakeRec.onresult = null;
|
|
54
|
+
wakeRec.onerror = null;
|
|
55
|
+
wakeRec.onend = null;
|
|
56
|
+
wakeRec.onstart = null;
|
|
57
|
+
try {
|
|
58
|
+
wakeRec.abort();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
wakeRec = null;
|
|
65
|
+
}
|
|
66
|
+
function scheduleWakeRestart() {
|
|
67
|
+
clearWakeRestart();
|
|
68
|
+
if (!wakeListening || wakeDetected)
|
|
69
|
+
return;
|
|
70
|
+
wakeRestartTimer = setTimeout(() => {
|
|
71
|
+
wakeRestartTimer = null;
|
|
72
|
+
if (wakeListening && !wakeDetected)
|
|
73
|
+
startWakeSession();
|
|
74
|
+
}, 600);
|
|
75
|
+
}
|
|
76
|
+
function startWakeSession() {
|
|
77
|
+
const Ctor2 = getRecognitionCtor();
|
|
78
|
+
if (!Ctor2 || !wakeListening || wakeDetected)
|
|
79
|
+
return;
|
|
80
|
+
// PRIVACY — HONEST LIMITATION (web only): the Web Speech API has NO
|
|
81
|
+
// on-device mode. On Chrome/Edge the passive audio is sent to the BROWSER
|
|
82
|
+
// VENDOR's cloud speech service (e.g. Google) for transcription — unlike the
|
|
83
|
+
// native SDKs, the web wake-word can NOT keep the passive audio on-device.
|
|
84
|
+
// It only runs while the tab is visible, behind the developer toggle + the
|
|
85
|
+
// user's explicit hands-free consent, and the consent copy must disclose
|
|
86
|
+
// this. Fiodos itself still never receives the passive audio (only the
|
|
87
|
+
// command text after the phrase).
|
|
88
|
+
// Only run while the tab is visible (honest: no background listening).
|
|
89
|
+
if (typeof document !== 'undefined' && document.hidden) {
|
|
90
|
+
scheduleWakeRestart();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const rec = new Ctor2();
|
|
94
|
+
wakeRec = rec;
|
|
95
|
+
rec.lang = wakeLocale;
|
|
96
|
+
rec.continuous = true;
|
|
97
|
+
rec.interimResults = true;
|
|
98
|
+
rec.maxAlternatives = 1;
|
|
99
|
+
rec.onresult = (event) => {
|
|
100
|
+
if (!wakeListening || wakeDetected)
|
|
101
|
+
return;
|
|
102
|
+
let text = '';
|
|
103
|
+
for (let i = 0; i < event.results.length; i += 1) {
|
|
104
|
+
const result = event.results[i];
|
|
105
|
+
if (!result)
|
|
106
|
+
continue;
|
|
107
|
+
const alt = result[0];
|
|
108
|
+
text = `${text} ${alt?.transcript ?? ''}`.trim();
|
|
109
|
+
}
|
|
110
|
+
const match = detectWakePhrase(text, wakePhrases);
|
|
111
|
+
if (!match.matched)
|
|
112
|
+
return;
|
|
113
|
+
wakeDetected = true;
|
|
114
|
+
const events = wakeEvents;
|
|
115
|
+
teardownWake();
|
|
116
|
+
events?.onDetected(match.command);
|
|
117
|
+
};
|
|
118
|
+
rec.onerror = (event) => {
|
|
119
|
+
if (event.error === 'aborted' || event.error === 'no-speech') {
|
|
120
|
+
return; // transient — onend will restart
|
|
121
|
+
}
|
|
122
|
+
if (!wakeListening || wakeDetected)
|
|
123
|
+
return;
|
|
124
|
+
wakeEvents?.onError?.(new Error(event.error || 'wake recognition error'));
|
|
125
|
+
};
|
|
126
|
+
rec.onend = () => {
|
|
127
|
+
if (!wakeListening || wakeDetected)
|
|
128
|
+
return;
|
|
129
|
+
// Browser ended the session (silence / time) — restart while listening.
|
|
130
|
+
scheduleWakeRestart();
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
rec.start();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
scheduleWakeRestart();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// WebAudio playback: survives the autoplay-after-await gate AND ignores the
|
|
140
|
+
// iOS hardware mute switch (an <audio> element does not), so the assistant's
|
|
141
|
+
// reply is actually audible on phones. The context is unlocked on the orb
|
|
142
|
+
// tap via primePlayback(); decoded MP3s play through it.
|
|
143
|
+
let audioCtx = null;
|
|
144
|
+
let currentSource = null;
|
|
145
|
+
function getAudioCtxCtor() {
|
|
146
|
+
if (typeof window === 'undefined')
|
|
147
|
+
return null;
|
|
148
|
+
const w = window;
|
|
149
|
+
return w.AudioContext ?? w.webkitAudioContext ?? null;
|
|
150
|
+
}
|
|
151
|
+
function ensureAudioCtx() {
|
|
152
|
+
if (audioCtx)
|
|
153
|
+
return audioCtx;
|
|
154
|
+
const Ctor = getAudioCtxCtor();
|
|
155
|
+
if (!Ctor)
|
|
156
|
+
return null;
|
|
157
|
+
try {
|
|
158
|
+
audioCtx = new Ctor();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
audioCtx = null;
|
|
162
|
+
}
|
|
163
|
+
return audioCtx;
|
|
164
|
+
}
|
|
165
|
+
function base64ToArrayBuffer(base64) {
|
|
166
|
+
const binary = atob(base64);
|
|
167
|
+
const len = binary.length;
|
|
168
|
+
const bytes = new Uint8Array(len);
|
|
169
|
+
for (let i = 0; i < len; i += 1)
|
|
170
|
+
bytes[i] = binary.charCodeAt(i);
|
|
171
|
+
return bytes.buffer;
|
|
172
|
+
}
|
|
173
|
+
function stopWebAudioSource() {
|
|
174
|
+
if (currentSource) {
|
|
175
|
+
try {
|
|
176
|
+
currentSource.onended = null;
|
|
177
|
+
currentSource.stop();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* already stopped */
|
|
181
|
+
}
|
|
182
|
+
currentSource = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function playViaWebAudio(base64) {
|
|
186
|
+
const ctx = ensureAudioCtx();
|
|
187
|
+
if (!ctx)
|
|
188
|
+
return false;
|
|
189
|
+
try {
|
|
190
|
+
if (ctx.state === 'suspended')
|
|
191
|
+
await ctx.resume();
|
|
192
|
+
const decoded = await ctx.decodeAudioData(base64ToArrayBuffer(base64));
|
|
193
|
+
return await new Promise((resolve) => {
|
|
194
|
+
stopWebAudioSource();
|
|
195
|
+
const source = ctx.createBufferSource();
|
|
196
|
+
currentSource = source;
|
|
197
|
+
source.buffer = decoded;
|
|
198
|
+
source.connect(ctx.destination);
|
|
199
|
+
source.onended = () => {
|
|
200
|
+
source.onended = null;
|
|
201
|
+
if (currentSource === source)
|
|
202
|
+
currentSource = null;
|
|
203
|
+
resolve(true);
|
|
204
|
+
};
|
|
205
|
+
source.start();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function clearStopTimer() {
|
|
213
|
+
if (stopTimer) {
|
|
214
|
+
clearTimeout(stopTimer);
|
|
215
|
+
stopTimer = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function teardownRecognition() {
|
|
219
|
+
clearStopTimer();
|
|
220
|
+
if (recognition) {
|
|
221
|
+
recognition.onresult = null;
|
|
222
|
+
recognition.onerror = null;
|
|
223
|
+
recognition.onend = null;
|
|
224
|
+
recognition.onstart = null;
|
|
225
|
+
}
|
|
226
|
+
recognition = null;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
isRecognitionAvailable() {
|
|
230
|
+
return Ctor != null;
|
|
231
|
+
},
|
|
232
|
+
async startListening(opts, events) {
|
|
233
|
+
if (!Ctor) {
|
|
234
|
+
events.onError?.(new Error('SpeechRecognition unavailable in this browser'));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Push-to-talk and wake-word share the single browser recognizer: stop
|
|
238
|
+
// passive listening first so the explicit turn owns the mic.
|
|
239
|
+
if (wakeListening) {
|
|
240
|
+
wakeListening = false;
|
|
241
|
+
wakeDetected = false;
|
|
242
|
+
wakeEvents = null;
|
|
243
|
+
teardownWake();
|
|
244
|
+
}
|
|
245
|
+
if (recognition) {
|
|
246
|
+
aborted = true;
|
|
247
|
+
try {
|
|
248
|
+
recognition.abort();
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
/* ignore */
|
|
252
|
+
}
|
|
253
|
+
teardownRecognition();
|
|
254
|
+
}
|
|
255
|
+
finalTranscript = '';
|
|
256
|
+
aborted = false;
|
|
257
|
+
settled = false;
|
|
258
|
+
const rec = new Ctor();
|
|
259
|
+
recognition = rec;
|
|
260
|
+
rec.lang = opts.locale;
|
|
261
|
+
rec.continuous = opts.continuous ?? true;
|
|
262
|
+
rec.interimResults = true;
|
|
263
|
+
rec.maxAlternatives = 1;
|
|
264
|
+
rec.onresult = (event) => {
|
|
265
|
+
let interim = '';
|
|
266
|
+
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
|
267
|
+
const result = event.results[i];
|
|
268
|
+
if (!result)
|
|
269
|
+
continue;
|
|
270
|
+
const alt = result[0];
|
|
271
|
+
const transcript = alt?.transcript ?? '';
|
|
272
|
+
if (result.isFinal) {
|
|
273
|
+
finalTranscript = `${finalTranscript} ${transcript}`.trim();
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
interim = `${interim} ${transcript}`.trim();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const combined = `${finalTranscript} ${interim}`.trim();
|
|
280
|
+
if (combined)
|
|
281
|
+
events.onInterim?.(combined);
|
|
282
|
+
};
|
|
283
|
+
rec.onerror = (event) => {
|
|
284
|
+
if (event.error === 'aborted' || aborted) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (event.error === 'no-speech') {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
settled = true;
|
|
291
|
+
events.onError?.(new Error(event.error || 'speech recognition error'));
|
|
292
|
+
};
|
|
293
|
+
rec.onend = () => {
|
|
294
|
+
clearStopTimer();
|
|
295
|
+
const wasAborted = aborted;
|
|
296
|
+
const text = finalTranscript.trim();
|
|
297
|
+
teardownRecognition();
|
|
298
|
+
if (settled) {
|
|
299
|
+
events.onEnd?.();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (wasAborted) {
|
|
303
|
+
events.onEnd?.();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (text) {
|
|
307
|
+
events.onFinal?.(text);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
events.onEnd?.();
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
try {
|
|
314
|
+
rec.start();
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
teardownRecognition();
|
|
318
|
+
events.onError?.(e);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (opts.maxSeconds && opts.maxSeconds > 0) {
|
|
322
|
+
stopTimer = setTimeout(() => {
|
|
323
|
+
try {
|
|
324
|
+
rec.stop();
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
/* ignore */
|
|
328
|
+
}
|
|
329
|
+
}, opts.maxSeconds * 1000);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
stopListening() {
|
|
333
|
+
clearStopTimer();
|
|
334
|
+
if (recognition) {
|
|
335
|
+
try {
|
|
336
|
+
recognition.stop();
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* ignore */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
cancelListening() {
|
|
344
|
+
clearStopTimer();
|
|
345
|
+
aborted = true;
|
|
346
|
+
if (recognition) {
|
|
347
|
+
try {
|
|
348
|
+
recognition.abort();
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
/* ignore */
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
primePlayback() {
|
|
356
|
+
// Must run synchronously inside the user gesture (orb tap). Creating /
|
|
357
|
+
// resuming the AudioContext here grants it permission for the session so
|
|
358
|
+
// later programmatic playback (after the network round-trip) is allowed
|
|
359
|
+
// and audible even with the silent switch on.
|
|
360
|
+
const ctx = ensureAudioCtx();
|
|
361
|
+
if (!ctx)
|
|
362
|
+
return;
|
|
363
|
+
try {
|
|
364
|
+
if (ctx.state === 'suspended')
|
|
365
|
+
void ctx.resume();
|
|
366
|
+
// A 1-frame silent buffer fully unlocks playback on iOS Safari.
|
|
367
|
+
const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
|
|
368
|
+
const source = ctx.createBufferSource();
|
|
369
|
+
source.buffer = buffer;
|
|
370
|
+
source.connect(ctx.destination);
|
|
371
|
+
source.start(0);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
/* best-effort unlock */
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
async playAudioBase64Mp3(base64) {
|
|
378
|
+
if (typeof window === 'undefined')
|
|
379
|
+
return;
|
|
380
|
+
this.stopPlayback();
|
|
381
|
+
// Preferred path: WebAudio (audible on mobile + survives the async gate).
|
|
382
|
+
if (await playViaWebAudio(base64))
|
|
383
|
+
return;
|
|
384
|
+
// Fallback: <audio> element (desktop browsers without a usable context).
|
|
385
|
+
if (typeof Audio === 'undefined')
|
|
386
|
+
return;
|
|
387
|
+
return new Promise((resolve) => {
|
|
388
|
+
try {
|
|
389
|
+
const el = new Audio(`data:audio/mp3;base64,${base64}`);
|
|
390
|
+
audioEl = el;
|
|
391
|
+
const done = () => {
|
|
392
|
+
el.onended = null;
|
|
393
|
+
el.onerror = null;
|
|
394
|
+
if (audioEl === el)
|
|
395
|
+
audioEl = null;
|
|
396
|
+
resolve();
|
|
397
|
+
};
|
|
398
|
+
el.onended = done;
|
|
399
|
+
el.onerror = done;
|
|
400
|
+
void el.play().catch(() => done());
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
resolve();
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
async speakWithDeviceTts(text, ttsOptions) {
|
|
408
|
+
if (options.disableDeviceTtsFallback)
|
|
409
|
+
return;
|
|
410
|
+
if (typeof window === 'undefined' || !window.speechSynthesis)
|
|
411
|
+
return;
|
|
412
|
+
return new Promise((resolve) => {
|
|
413
|
+
try {
|
|
414
|
+
const utter = new SpeechSynthesisUtterance(text);
|
|
415
|
+
utter.lang = ttsOptions.locale;
|
|
416
|
+
utter.onend = () => resolve();
|
|
417
|
+
utter.onerror = () => resolve();
|
|
418
|
+
window.speechSynthesis.speak(utter);
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
resolve();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
},
|
|
425
|
+
stopPlayback() {
|
|
426
|
+
stopWebAudioSource();
|
|
427
|
+
if (audioEl) {
|
|
428
|
+
try {
|
|
429
|
+
audioEl.pause();
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
/* ignore */
|
|
433
|
+
}
|
|
434
|
+
audioEl = null;
|
|
435
|
+
}
|
|
436
|
+
if (typeof window !== 'undefined' && window.speechSynthesis) {
|
|
437
|
+
try {
|
|
438
|
+
window.speechSynthesis.cancel();
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
/* ignore */
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
// ── Wake-word ("Oye Fiodos") — DESKTOP-ONLY honest support ──────────────────
|
|
446
|
+
// Real on desktop browsers with Web Speech (Chrome/Edge) while the tab is
|
|
447
|
+
// active/visible. Refused on iOS web (no usable continuous STT) and where the
|
|
448
|
+
// API is absent — we never fake passive listening the browser blocks.
|
|
449
|
+
supportsWakeWordDetection() {
|
|
450
|
+
return Ctor != null && !isIosWeb();
|
|
451
|
+
},
|
|
452
|
+
isWakeWordListening() {
|
|
453
|
+
return wakeListening;
|
|
454
|
+
},
|
|
455
|
+
async startWakeWordDetection(opts, events) {
|
|
456
|
+
if (!Ctor || isIosWeb()) {
|
|
457
|
+
events.onError?.(new Error('wake_word_unsupported'));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
wakePhrases = opts.phrases;
|
|
461
|
+
wakeLocale = opts.locale;
|
|
462
|
+
wakeEvents = events;
|
|
463
|
+
wakeListening = true;
|
|
464
|
+
wakeDetected = false;
|
|
465
|
+
startWakeSession();
|
|
466
|
+
},
|
|
467
|
+
stopWakeWordDetection() {
|
|
468
|
+
wakeListening = false;
|
|
469
|
+
wakeDetected = false;
|
|
470
|
+
wakeEvents = null;
|
|
471
|
+
teardownWake();
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Fiodos backend — implements @fiodos/core's
|
|
3
|
+
* AgentBackendClient. Identical wire contract to @fiodos/react and
|
|
4
|
+
* @fiodos/react-native (POST /assistant/chat, /assistant/preview-tts, /health,
|
|
5
|
+
* /v1/client/config), so every framework talks to the SAME backend the SAME
|
|
6
|
+
* way. The only platform dependency is `fetch` + `AbortController` (native in
|
|
7
|
+
* the browser). No client source code is ever sent — only the manifest payload.
|
|
8
|
+
*/
|
|
9
|
+
import { type AgentBackendClient, type AppManifest } from '@fiodos/core';
|
|
10
|
+
export interface FiodosBackendClientOptions {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
/** Client API key sent as x-api-key. */
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
/** End-user id sent as x-user-id (rate limiting / telemetry correlation). */
|
|
15
|
+
getUserId?: () => string | null;
|
|
16
|
+
/** Extra headers merged into every request. */
|
|
17
|
+
headers?: Record<string, string>;
|
|
18
|
+
turnTimeoutMs?: number;
|
|
19
|
+
ttsTimeoutMs?: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function createFiodosBackendClient(options: FiodosBackendClientOptions): AgentBackendClient;
|
|
22
|
+
/** Serializes the manifest payloads for a turn request (used by AgentController). */
|
|
23
|
+
export declare function buildManifestPayload(manifest: AppManifest): {
|
|
24
|
+
appFlow?: string | undefined;
|
|
25
|
+
appType?: string | undefined;
|
|
26
|
+
manifestVersion: string;
|
|
27
|
+
manifestRoutes: import("@fiodos/core").SerializedRouteIntent[];
|
|
28
|
+
manifestActions: import("@fiodos/core").SerializedActionIntent[];
|
|
29
|
+
appName: string;
|
|
30
|
+
appDescription: string;
|
|
31
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the Fiodos backend — implements @fiodos/core's
|
|
3
|
+
* AgentBackendClient. Identical wire contract to @fiodos/react and
|
|
4
|
+
* @fiodos/react-native (POST /assistant/chat, /assistant/preview-tts, /health,
|
|
5
|
+
* /v1/client/config), so every framework talks to the SAME backend the SAME
|
|
6
|
+
* way. The only platform dependency is `fetch` + `AbortController` (native in
|
|
7
|
+
* the browser). No client source code is ever sent — only the manifest payload.
|
|
8
|
+
*/
|
|
9
|
+
import { manifestActionsForBackend, manifestRoutesForBackend, parseAgentAction, } from '@fiodos/core';
|
|
10
|
+
import { AgentApiError } from './errors.js';
|
|
11
|
+
const DEFAULT_TURN_TIMEOUT_MS = 15000;
|
|
12
|
+
const DEFAULT_TTS_TIMEOUT_MS = 12000;
|
|
13
|
+
async function fetchWithTimeout(url, init, timeoutMs, externalSignal) {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
let timedOut = false;
|
|
16
|
+
const timer = setTimeout(() => {
|
|
17
|
+
timedOut = true;
|
|
18
|
+
controller.abort();
|
|
19
|
+
}, timeoutMs);
|
|
20
|
+
if (externalSignal) {
|
|
21
|
+
if (externalSignal.aborted)
|
|
22
|
+
controller.abort();
|
|
23
|
+
else
|
|
24
|
+
externalSignal.addEventListener('abort', () => controller.abort());
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (timedOut)
|
|
31
|
+
throw new AgentApiError('timeout');
|
|
32
|
+
if (externalSignal?.aborted)
|
|
33
|
+
throw new AgentApiError('cancelled');
|
|
34
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
35
|
+
throw new AgentApiError('network', msg);
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function errorFromResponse(res) {
|
|
42
|
+
let body = {};
|
|
43
|
+
try {
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
if (text)
|
|
46
|
+
body = JSON.parse(text);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* unparseable body — classify by status alone */
|
|
50
|
+
}
|
|
51
|
+
const detail = typeof body.detail === 'string'
|
|
52
|
+
? body.detail
|
|
53
|
+
: typeof body.error === 'string'
|
|
54
|
+
? body.error
|
|
55
|
+
: body.message;
|
|
56
|
+
if (res.status === 401 || res.status === 403) {
|
|
57
|
+
return new AgentApiError('unauthorized', detail, res.status);
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 429) {
|
|
60
|
+
const quotaSignal = typeof body.remaining === 'number' || (detail ?? '').toLowerCase().includes('quota');
|
|
61
|
+
return new AgentApiError(quotaSignal ? 'quota_exceeded' : 'rate_limited', detail, res.status);
|
|
62
|
+
}
|
|
63
|
+
if (res.status === 404)
|
|
64
|
+
return new AgentApiError('not_found', detail, res.status);
|
|
65
|
+
if (res.status >= 500)
|
|
66
|
+
return new AgentApiError('server_error', detail, res.status);
|
|
67
|
+
return new AgentApiError('unknown', detail ?? res.statusText, res.status);
|
|
68
|
+
}
|
|
69
|
+
export function createFiodosBackendClient(options) {
|
|
70
|
+
const baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
71
|
+
const turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
|
|
72
|
+
const ttsTimeoutMs = options.ttsTimeoutMs ?? DEFAULT_TTS_TIMEOUT_MS;
|
|
73
|
+
function buildHeaders() {
|
|
74
|
+
const userId = options.getUserId?.();
|
|
75
|
+
return {
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
...(options.apiKey ? { 'x-api-key': options.apiKey } : {}),
|
|
78
|
+
...(userId ? { 'x-user-id': userId } : {}),
|
|
79
|
+
...(options.headers ?? {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
async sendTurn(request, opts) {
|
|
84
|
+
const body = {
|
|
85
|
+
message: request.message.trim(),
|
|
86
|
+
language: request.language,
|
|
87
|
+
manifest_version: request.manifestVersion,
|
|
88
|
+
manifest_routes: request.manifestRoutes,
|
|
89
|
+
manifest_actions: request.manifestActions,
|
|
90
|
+
app_name: request.appName,
|
|
91
|
+
app_description: request.appDescription,
|
|
92
|
+
};
|
|
93
|
+
if (request.appType?.trim())
|
|
94
|
+
body.app_type = request.appType.trim();
|
|
95
|
+
if (request.appFlow?.trim())
|
|
96
|
+
body.app_flow = request.appFlow.trim();
|
|
97
|
+
if (request.voice)
|
|
98
|
+
body.voice = request.voice;
|
|
99
|
+
if (request.screenContext?.trim())
|
|
100
|
+
body.screen_context = request.screenContext.trim();
|
|
101
|
+
if (request.currentRoute?.trim())
|
|
102
|
+
body.current_route = request.currentRoute.trim();
|
|
103
|
+
if (request.conversationHistory?.length) {
|
|
104
|
+
body.conversation_history = request.conversationHistory.map((t) => ({
|
|
105
|
+
role: t.role,
|
|
106
|
+
content: t.content.trim(),
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
if (request.conversationSummary?.trim()) {
|
|
110
|
+
body.conversation_summary = request.conversationSummary.trim();
|
|
111
|
+
}
|
|
112
|
+
if (request.llmModel?.trim()) {
|
|
113
|
+
body.llm_model = request.llmModel.trim();
|
|
114
|
+
}
|
|
115
|
+
if (request.sessionId?.trim()) {
|
|
116
|
+
body.session_id = request.sessionId.trim();
|
|
117
|
+
}
|
|
118
|
+
const res = await fetchWithTimeout(`${baseUrl}/assistant/chat`, { method: 'POST', headers: buildHeaders(), body: JSON.stringify(body) }, turnTimeoutMs, opts?.signal);
|
|
119
|
+
if (!res.ok)
|
|
120
|
+
throw await errorFromResponse(res);
|
|
121
|
+
let data;
|
|
122
|
+
try {
|
|
123
|
+
data = (await res.json());
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new AgentApiError('invalid_response');
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
reply: (data.reply ?? '').trim(),
|
|
130
|
+
audioBase64: data.audio_base64 ?? null,
|
|
131
|
+
action: parseAgentAction(data.action),
|
|
132
|
+
wasTruncated: data.was_truncated === true,
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
async previewTts(voice, text) {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetchWithTimeout(`${baseUrl}/assistant/preview-tts`, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: buildHeaders(),
|
|
140
|
+
body: JSON.stringify({ voice, text }),
|
|
141
|
+
}, ttsTimeoutMs);
|
|
142
|
+
if (!res.ok)
|
|
143
|
+
return null;
|
|
144
|
+
const data = (await res.json());
|
|
145
|
+
return data.audio_base64?.length ? data.audio_base64 : null;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
async warmUp() {
|
|
152
|
+
try {
|
|
153
|
+
await fetchWithTimeout(`${baseUrl}/health`, { method: 'GET', headers: buildHeaders() }, 5000);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* best-effort cold-start warm-up */
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
async fetchPublishedConfig() {
|
|
160
|
+
const res = await fetchWithTimeout(`${baseUrl}/v1/client/config`, { method: 'GET', headers: buildHeaders() }, 8000);
|
|
161
|
+
if (!res.ok)
|
|
162
|
+
throw await errorFromResponse(res);
|
|
163
|
+
return (await res.json());
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** Serializes the manifest payloads for a turn request (used by AgentController). */
|
|
168
|
+
export function buildManifestPayload(manifest) {
|
|
169
|
+
return {
|
|
170
|
+
manifestVersion: manifest.version,
|
|
171
|
+
manifestRoutes: manifestRoutesForBackend(manifest),
|
|
172
|
+
manifestActions: manifestActionsForBackend(manifest),
|
|
173
|
+
appName: manifest.appName,
|
|
174
|
+
appDescription: manifest.appDescription,
|
|
175
|
+
...(manifest.appType ? { appType: manifest.appType } : {}),
|
|
176
|
+
...(manifest.appFlow ? { appFlow: manifest.appFlow } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|