@fiodos/web-core 0.1.4 → 0.1.7
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/dist/cjs/adapters/webVoiceAdapter.js +122 -16
- package/dist/cjs/controller/AgentController.d.ts +9 -0
- package/dist/cjs/controller/AgentController.js +73 -8
- package/dist/cjs/orb/mountOrb.js +1 -0
- package/dist/cjs/orb/orbView.js +57 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/adapters/webVoiceAdapter.js +122 -16
- package/dist/esm/controller/AgentController.d.ts +9 -0
- package/dist/esm/controller/AgentController.js +74 -9
- package/dist/esm/orb/mountOrb.js +1 -0
- package/dist/esm/orb/orbView.js +57 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +3 -2
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createWebVoiceAdapter = createWebVoiceAdapter;
|
|
4
4
|
const core_1 = require("@fiodos/core");
|
|
5
|
+
// ── Device TTS (SpeechSynthesis) helpers ────────────────────────────────────
|
|
6
|
+
// Chrome loads voices asynchronously: calling speak() before getVoices() is
|
|
7
|
+
// populated produces NO sound. Resolve the voice list once (event or timeout).
|
|
8
|
+
function loadVoicesOnce(synth) {
|
|
9
|
+
const ready = synth.getVoices();
|
|
10
|
+
if (ready.length)
|
|
11
|
+
return Promise.resolve(ready);
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
const finish = () => {
|
|
15
|
+
if (settled)
|
|
16
|
+
return;
|
|
17
|
+
settled = true;
|
|
18
|
+
resolve(synth.getVoices());
|
|
19
|
+
};
|
|
20
|
+
try {
|
|
21
|
+
synth.addEventListener('voiceschanged', finish, { once: true });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* older engines: the timeout below is the only path */
|
|
25
|
+
}
|
|
26
|
+
// Some engines never emit `voiceschanged` — never block the reply forever.
|
|
27
|
+
setTimeout(finish, 600);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Best voice for a BCP-47 locale: exact → region-agnostic → language → default. */
|
|
31
|
+
function pickVoiceForLocale(voices, locale) {
|
|
32
|
+
if (!voices.length)
|
|
33
|
+
return undefined;
|
|
34
|
+
const lc = locale.toLowerCase();
|
|
35
|
+
const lang = lc.split('-')[0] ?? lc;
|
|
36
|
+
return (voices.find((v) => v.lang?.toLowerCase() === lc) ??
|
|
37
|
+
voices.find((v) => v.lang?.toLowerCase().startsWith(`${lang}-`)) ??
|
|
38
|
+
voices.find((v) => v.lang?.toLowerCase().startsWith(lang)) ??
|
|
39
|
+
voices.find((v) => v.default) ??
|
|
40
|
+
voices[0]);
|
|
41
|
+
}
|
|
5
42
|
function getRecognitionCtor() {
|
|
6
43
|
if (typeof window === 'undefined')
|
|
7
44
|
return null;
|
|
@@ -33,6 +70,9 @@ function createWebVoiceAdapter(options = {}) {
|
|
|
33
70
|
let aborted = false;
|
|
34
71
|
let settled = false;
|
|
35
72
|
let audioEl = null;
|
|
73
|
+
// Strong reference to the in-flight utterance: Chrome garbage-collects it
|
|
74
|
+
// otherwise, cutting device speech off mid-sentence (or producing silence).
|
|
75
|
+
let ttsUtterance = null;
|
|
36
76
|
// ── Wake-word state (separate recognition instance; the browser allows only
|
|
37
77
|
// one active session, so push-to-talk stops wake first). Desktop-only:
|
|
38
78
|
// foreground tab while the page is visible — there is no passive background
|
|
@@ -361,20 +401,40 @@ function createWebVoiceAdapter(options = {}) {
|
|
|
361
401
|
// later programmatic playback (after the network round-trip) is allowed
|
|
362
402
|
// and audible even with the silent switch on.
|
|
363
403
|
const ctx = ensureAudioCtx();
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
404
|
+
if (ctx) {
|
|
405
|
+
try {
|
|
406
|
+
if (ctx.state === 'suspended')
|
|
407
|
+
void ctx.resume();
|
|
408
|
+
// A 1-frame silent buffer fully unlocks playback on iOS Safari.
|
|
409
|
+
const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
|
|
410
|
+
const source = ctx.createBufferSource();
|
|
411
|
+
source.buffer = buffer;
|
|
412
|
+
source.connect(ctx.destination);
|
|
413
|
+
source.start(0);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
/* best-effort unlock */
|
|
417
|
+
}
|
|
375
418
|
}
|
|
376
|
-
|
|
377
|
-
|
|
419
|
+
// Unlock device TTS (SpeechSynthesis) too. The spoken reply happens AFTER
|
|
420
|
+
// the network round-trip — outside this gesture — and Safari/Chrome stay
|
|
421
|
+
// SILENT for a first programmatic speak that wasn't preceded by a
|
|
422
|
+
// gesture-initiated one. Warm the engine here: kick off voice loading and
|
|
423
|
+
// speak a zero-volume blank utterance inside the tap so the real reply is
|
|
424
|
+
// allowed to play later.
|
|
425
|
+
if (!options.disableDeviceTtsFallback && typeof window !== 'undefined' && window.speechSynthesis) {
|
|
426
|
+
try {
|
|
427
|
+
const synth = window.speechSynthesis;
|
|
428
|
+
synth.getVoices(); // triggers async voice loading on Chrome
|
|
429
|
+
if (synth.paused)
|
|
430
|
+
synth.resume();
|
|
431
|
+
const warm = new SpeechSynthesisUtterance(' ');
|
|
432
|
+
warm.volume = 0;
|
|
433
|
+
synth.speak(warm);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
/* best-effort unlock */
|
|
437
|
+
}
|
|
378
438
|
}
|
|
379
439
|
},
|
|
380
440
|
async playAudioBase64Mp3(base64) {
|
|
@@ -412,15 +472,61 @@ function createWebVoiceAdapter(options = {}) {
|
|
|
412
472
|
return;
|
|
413
473
|
if (typeof window === 'undefined' || !window.speechSynthesis)
|
|
414
474
|
return;
|
|
475
|
+
const synth = window.speechSynthesis;
|
|
476
|
+
// Voices load lazily on Chrome — speaking before they exist is silent.
|
|
477
|
+
const voices = await loadVoicesOnce(synth);
|
|
415
478
|
return new Promise((resolve) => {
|
|
479
|
+
let keepAlive = null;
|
|
480
|
+
const cleanup = () => {
|
|
481
|
+
if (keepAlive) {
|
|
482
|
+
clearInterval(keepAlive);
|
|
483
|
+
keepAlive = null;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
416
486
|
try {
|
|
487
|
+
// Clear any previous/wedged queue. After a prior cancel() (e.g. the
|
|
488
|
+
// stopPlayback() fired when listening started) Chrome on macOS can sit
|
|
489
|
+
// in a stuck "paused" state that does NOT report paused===true, so a
|
|
490
|
+
// conditional resume() never fires and every later speak() is silent.
|
|
491
|
+
synth.cancel();
|
|
417
492
|
const utter = new SpeechSynthesisUtterance(text);
|
|
418
493
|
utter.lang = ttsOptions.locale;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
494
|
+
const voice = pickVoiceForLocale(voices, ttsOptions.locale);
|
|
495
|
+
if (voice)
|
|
496
|
+
utter.voice = voice;
|
|
497
|
+
utter.volume = 1;
|
|
498
|
+
utter.rate = 1;
|
|
499
|
+
utter.pitch = 1;
|
|
500
|
+
// Keep a strong ref so Chrome can't GC the utterance mid-speech.
|
|
501
|
+
ttsUtterance = utter;
|
|
502
|
+
const finish = () => {
|
|
503
|
+
cleanup();
|
|
504
|
+
utter.onstart = null;
|
|
505
|
+
utter.onend = null;
|
|
506
|
+
utter.onerror = null;
|
|
507
|
+
if (ttsUtterance === utter)
|
|
508
|
+
ttsUtterance = null;
|
|
509
|
+
resolve();
|
|
510
|
+
};
|
|
511
|
+
utter.onend = finish;
|
|
512
|
+
utter.onerror = finish;
|
|
513
|
+
synth.speak(utter);
|
|
514
|
+
// THE FIX: resume() unconditionally right after queuing. This un-wedges
|
|
515
|
+
// the engine from the silent stuck-paused state described above.
|
|
516
|
+
synth.resume();
|
|
517
|
+
// Chrome desktop stops speaking after ~15s; pulse pause/resume to keep
|
|
518
|
+
// long replies going, and stop the timer once it has finished.
|
|
519
|
+
keepAlive = setInterval(() => {
|
|
520
|
+
if (!synth.speaking && !synth.pending) {
|
|
521
|
+
cleanup();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
synth.pause();
|
|
525
|
+
synth.resume();
|
|
526
|
+
}, 9000);
|
|
422
527
|
}
|
|
423
528
|
catch {
|
|
529
|
+
cleanup();
|
|
424
530
|
resolve();
|
|
425
531
|
}
|
|
426
532
|
});
|
|
@@ -74,6 +74,8 @@ export declare class AgentController {
|
|
|
74
74
|
private abort;
|
|
75
75
|
private watchdog;
|
|
76
76
|
private confirmWindow;
|
|
77
|
+
private speakTimer;
|
|
78
|
+
private serverTtsUnavailable;
|
|
77
79
|
private pendingConsentIntent;
|
|
78
80
|
private readonly listeners;
|
|
79
81
|
private unsubscribeSpeech;
|
|
@@ -95,7 +97,14 @@ export declare class AgentController {
|
|
|
95
97
|
private setPhase;
|
|
96
98
|
private clearWatchdog;
|
|
97
99
|
private clearConfirmWindow;
|
|
100
|
+
private clearSpeakTimer;
|
|
98
101
|
private speakReply;
|
|
102
|
+
/**
|
|
103
|
+
* Never let the 'speaking' phase hang. If playback/TTS never reports
|
|
104
|
+
* completion (a stalled engine, a never-firing onend), stop it and continue
|
|
105
|
+
* after a generous, length-based cap so the orb returns to idle on its own.
|
|
106
|
+
*/
|
|
107
|
+
private raceSpeakingWatchdog;
|
|
99
108
|
private beginConfirmationVoiceWindow;
|
|
100
109
|
confirmPendingAction(): Promise<void>;
|
|
101
110
|
cancelPendingAction(): void;
|
|
@@ -51,6 +51,12 @@ class AgentController {
|
|
|
51
51
|
this.abort = null;
|
|
52
52
|
this.watchdog = null;
|
|
53
53
|
this.confirmWindow = null;
|
|
54
|
+
// Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
|
|
55
|
+
// never reports completion (e.g. a stalled SpeechSynthesis engine).
|
|
56
|
+
this.speakTimer = null;
|
|
57
|
+
// Once the backend shows it has no server-side TTS (a reply arrives with null
|
|
58
|
+
// audio), skip the extra /preview-tts round-trip and speak via device voice.
|
|
59
|
+
this.serverTtsUnavailable = false;
|
|
54
60
|
this.pendingConsentIntent = null;
|
|
55
61
|
this.listeners = new Set();
|
|
56
62
|
this.unsubscribeSpeech = null;
|
|
@@ -87,7 +93,11 @@ class AgentController {
|
|
|
87
93
|
});
|
|
88
94
|
this.screenStore = (0, screenContextStore_1.createScreenContextStore)({
|
|
89
95
|
getCurrentRoute: () => config.navigation.getCurrentRoute(),
|
|
90
|
-
|
|
96
|
+
// The integrator's fallback wins; otherwise the orb gets screen awareness
|
|
97
|
+
// out of the box from the matched manifest route (label + description +
|
|
98
|
+
// available actions). Null when no route matches → raw route, as today.
|
|
99
|
+
routeFallback: config.routeContextFallback ??
|
|
100
|
+
((route) => (0, core_1.defaultRouteContextText)(route, config.manifest, this.messages)),
|
|
91
101
|
});
|
|
92
102
|
this.speech = (0, speechSession_1.createSpeechSession)({
|
|
93
103
|
adapter: config.voice,
|
|
@@ -179,6 +189,12 @@ class AgentController {
|
|
|
179
189
|
}
|
|
180
190
|
this.confirmationVoiceActive = false;
|
|
181
191
|
}
|
|
192
|
+
clearSpeakTimer() {
|
|
193
|
+
if (this.speakTimer) {
|
|
194
|
+
clearTimeout(this.speakTimer);
|
|
195
|
+
this.speakTimer = null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
182
198
|
// ── Speech playback (ONE consistent voice) ─────────────────────────────────
|
|
183
199
|
async speakReply(text, audioBase64) {
|
|
184
200
|
if (this.silentTurn)
|
|
@@ -188,24 +204,62 @@ class AgentController {
|
|
|
188
204
|
this.setPhase('speaking');
|
|
189
205
|
try {
|
|
190
206
|
let audio = audioBase64;
|
|
191
|
-
if (!audio && text) {
|
|
192
|
-
audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
|
|
193
|
-
}
|
|
194
207
|
if (audio) {
|
|
195
|
-
|
|
208
|
+
this.serverTtsUnavailable = false; // server voice is working
|
|
196
209
|
}
|
|
197
|
-
|
|
198
|
-
|
|
210
|
+
// Once we know the backend ships no server TTS, skip the dead
|
|
211
|
+
// /preview-tts round-trip (pure latency) and use the device voice.
|
|
212
|
+
if (!audio && text && !this.serverTtsUnavailable) {
|
|
213
|
+
try {
|
|
214
|
+
audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
|
|
215
|
+
if (!audio)
|
|
216
|
+
this.serverTtsUnavailable = true;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
audio = null;
|
|
220
|
+
this.serverTtsUnavailable = true;
|
|
221
|
+
}
|
|
199
222
|
}
|
|
223
|
+
const playback = audio
|
|
224
|
+
? this.config.voice.playAudioBase64Mp3(audio)
|
|
225
|
+
: text
|
|
226
|
+
? this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale })
|
|
227
|
+
: Promise.resolve();
|
|
228
|
+
await this.raceSpeakingWatchdog(playback, text);
|
|
200
229
|
}
|
|
201
230
|
catch {
|
|
202
231
|
/* playback best-effort */
|
|
203
232
|
}
|
|
204
233
|
finally {
|
|
234
|
+
this.clearSpeakTimer();
|
|
205
235
|
if (this.phase === 'speaking')
|
|
206
236
|
this.setPhase('idle');
|
|
207
237
|
}
|
|
208
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Never let the 'speaking' phase hang. If playback/TTS never reports
|
|
241
|
+
* completion (a stalled engine, a never-firing onend), stop it and continue
|
|
242
|
+
* after a generous, length-based cap so the orb returns to idle on its own.
|
|
243
|
+
*/
|
|
244
|
+
raceSpeakingWatchdog(playback, text) {
|
|
245
|
+
const capMs = Math.min(60000, 5000 + text.length * 90);
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
let settled = false;
|
|
248
|
+
const finish = () => {
|
|
249
|
+
if (settled)
|
|
250
|
+
return;
|
|
251
|
+
settled = true;
|
|
252
|
+
this.clearSpeakTimer();
|
|
253
|
+
resolve();
|
|
254
|
+
};
|
|
255
|
+
this.clearSpeakTimer();
|
|
256
|
+
this.speakTimer = setTimeout(() => {
|
|
257
|
+
this.config.voice.stopPlayback();
|
|
258
|
+
finish();
|
|
259
|
+
}, capMs);
|
|
260
|
+
playback.then(finish, finish);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
209
263
|
// ── Confirmation resolution ─────────────────────────────────────────────────
|
|
210
264
|
beginConfirmationVoiceWindow() {
|
|
211
265
|
const pending = this.pending;
|
|
@@ -378,6 +432,10 @@ class AgentController {
|
|
|
378
432
|
}, { signal: controller.signal });
|
|
379
433
|
reply = turn.reply;
|
|
380
434
|
audioBase64 = turn.audioBase64;
|
|
435
|
+
// Learn whether this backend ships server-side TTS: a non-empty reply
|
|
436
|
+
// with no audio means it doesn't — later lines then speak instantly.
|
|
437
|
+
if (reply)
|
|
438
|
+
this.serverTtsUnavailable = !audioBase64;
|
|
381
439
|
const decision = await (0, turnEngine_1.decideAction)({
|
|
382
440
|
action: turn.action,
|
|
383
441
|
manifest: this.config.manifest,
|
|
@@ -544,8 +602,14 @@ class AgentController {
|
|
|
544
602
|
this.setPhase('thinking');
|
|
545
603
|
return;
|
|
546
604
|
}
|
|
547
|
-
if (this.phase === '
|
|
605
|
+
if (this.phase === 'speaking') {
|
|
606
|
+
// Tap while the assistant is talking → stop the reply and reset, so the
|
|
607
|
+
// orb is immediately usable again (no more "stuck pulsing").
|
|
608
|
+
this.cancelAll();
|
|
548
609
|
return;
|
|
610
|
+
}
|
|
611
|
+
if (this.phase === 'thinking')
|
|
612
|
+
return; // a turn is in flight; let it finish
|
|
549
613
|
const consented = await this.consent.hasConsented();
|
|
550
614
|
if (!consented) {
|
|
551
615
|
this.pendingConsentIntent = 'voice';
|
|
@@ -597,6 +661,7 @@ class AgentController {
|
|
|
597
661
|
this.abort = null;
|
|
598
662
|
this.clearWatchdog();
|
|
599
663
|
this.clearConfirmWindow();
|
|
664
|
+
this.clearSpeakTimer();
|
|
600
665
|
this.speech.cancel();
|
|
601
666
|
this.config.voice.stopPlayback();
|
|
602
667
|
this.pending = null;
|
package/dist/cjs/orb/mountOrb.js
CHANGED
|
@@ -46,6 +46,7 @@ const CSS = `
|
|
|
46
46
|
@keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
|
|
47
47
|
@keyframes fy-spin{to{transform:rotate(360deg)}}
|
|
48
48
|
@keyframes fy-opulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.06);opacity:.85}}
|
|
49
|
+
@keyframes fy-voice{0%{transform:scale(0.6);opacity:.7}100%{transform:scale(1.35);opacity:0}}
|
|
49
50
|
.fyodos-overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(4,6,12,0.6);}
|
|
50
51
|
.fyodos-modal{width:min(420px,100%);border-radius:16px;padding:22px 24px;box-shadow:0 24px 60px rgba(0,0,0,0.5);}
|
|
51
52
|
.fyodos-modal h3{margin:0 0 10px;font-size:17px;font-weight:600;}
|
package/dist/cjs/orb/orbView.js
CHANGED
|
@@ -215,6 +215,52 @@ function buildThinkingSpinner(orbSizePx, color) {
|
|
|
215
215
|
wrap.appendChild(spin);
|
|
216
216
|
return wrap;
|
|
217
217
|
}
|
|
218
|
+
// ── Speaking pulse (port of the dashboard's no-logo "speaking" state) ─────────
|
|
219
|
+
// Two expanding rings + a pulsing centre dot — the default "talking" drawing the
|
|
220
|
+
// dashboard orb editor shows when there is no custom photo. Ratios are taken
|
|
221
|
+
// from the editor (118 / 30 / 2 px on its 380px reference orb) so the embedded
|
|
222
|
+
// orb matches it at any size.
|
|
223
|
+
const SPEAK_RING_RATIO = 118 / 380;
|
|
224
|
+
const SPEAK_DOT_RATIO = 30 / 380;
|
|
225
|
+
const SPEAK_BORDER_RATIO = 2 / 380;
|
|
226
|
+
function buildSpeakingPulse(orbSize, color) {
|
|
227
|
+
const wrap = document.createElement('div');
|
|
228
|
+
Object.assign(wrap.style, {
|
|
229
|
+
position: 'absolute',
|
|
230
|
+
inset: '0',
|
|
231
|
+
display: 'flex',
|
|
232
|
+
alignItems: 'center',
|
|
233
|
+
justifyContent: 'center',
|
|
234
|
+
pointerEvents: 'none',
|
|
235
|
+
});
|
|
236
|
+
const ringPx = orbSize * SPEAK_RING_RATIO;
|
|
237
|
+
const borderPx = Math.max(1, orbSize * SPEAK_BORDER_RATIO);
|
|
238
|
+
for (const delay of [0, 0.85]) {
|
|
239
|
+
const ring = document.createElement('div');
|
|
240
|
+
Object.assign(ring.style, {
|
|
241
|
+
position: 'absolute',
|
|
242
|
+
width: `${ringPx}px`,
|
|
243
|
+
height: `${ringPx}px`,
|
|
244
|
+
borderRadius: '50%',
|
|
245
|
+
border: `${borderPx}px solid ${color}`,
|
|
246
|
+
opacity: '0.55',
|
|
247
|
+
animation: `fy-voice 1.7s ease-out infinite ${delay}s`,
|
|
248
|
+
});
|
|
249
|
+
wrap.appendChild(ring);
|
|
250
|
+
}
|
|
251
|
+
const dotPx = orbSize * SPEAK_DOT_RATIO;
|
|
252
|
+
const dot = document.createElement('div');
|
|
253
|
+
Object.assign(dot.style, {
|
|
254
|
+
width: `${dotPx}px`,
|
|
255
|
+
height: `${dotPx}px`,
|
|
256
|
+
borderRadius: '50%',
|
|
257
|
+
background: color,
|
|
258
|
+
opacity: '0.9',
|
|
259
|
+
animation: 'fy-opulse 1s ease-in-out infinite',
|
|
260
|
+
});
|
|
261
|
+
wrap.appendChild(dot);
|
|
262
|
+
return wrap;
|
|
263
|
+
}
|
|
218
264
|
// ── Keyboard chip icon (port of ChipIconSvg) ──────────────────────────────────
|
|
219
265
|
function buildChipIcon(icon, size, color) {
|
|
220
266
|
const spec = (0, core_1.chipIconSpec)(icon);
|
|
@@ -369,7 +415,10 @@ function createOrbVisual(initial) {
|
|
|
369
415
|
waveform?.stop();
|
|
370
416
|
waveform = null;
|
|
371
417
|
fillEl.replaceChildren();
|
|
372
|
-
|
|
418
|
+
// The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
|
|
419
|
+
// rings + dot when there's no photo) to mirror the dashboard editor, so the
|
|
420
|
+
// orb no longer pulses as a whole.
|
|
421
|
+
wrapper.style.animation = '';
|
|
373
422
|
if (state === 'thinking') {
|
|
374
423
|
fillEl.appendChild(buildThinkingSpinner(size, waveColor));
|
|
375
424
|
return;
|
|
@@ -397,10 +446,17 @@ function createOrbVisual(initial) {
|
|
|
397
446
|
userSelect: 'none',
|
|
398
447
|
webkitUserSelect: 'none',
|
|
399
448
|
webkitUserDrag: 'none',
|
|
449
|
+
// While speaking, the photo gently pulses (matches the dashboard).
|
|
450
|
+
animation: state === 'speaking' ? 'fy-opulse 1s ease-in-out infinite' : '',
|
|
400
451
|
});
|
|
401
452
|
fillEl.appendChild(img);
|
|
402
453
|
return;
|
|
403
454
|
}
|
|
455
|
+
if (state === 'speaking') {
|
|
456
|
+
// No photo → the default "talking" drawing (expanding rings + centre dot).
|
|
457
|
+
fillEl.appendChild(buildSpeakingPulse(size, waveColor));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
404
460
|
if (state === 'idle') {
|
|
405
461
|
fillEl.appendChild(buildOrbMark(size));
|
|
406
462
|
}
|
package/dist/cjs/version.d.ts
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
|
|
6
6
|
* by hand — change package.json `version` and re-run the sync/release script.
|
|
7
7
|
*/
|
|
8
|
-
export declare const SDK_VERSION = "0.1.
|
|
8
|
+
export declare const SDK_VERSION = "0.1.7";
|
|
9
9
|
export declare const SDK_PACKAGE = "@fiodos/web-core";
|
package/dist/cjs/version.js
CHANGED
|
@@ -8,5 +8,5 @@ exports.SDK_PACKAGE = exports.SDK_VERSION = void 0;
|
|
|
8
8
|
* Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
|
|
9
9
|
* by hand — change package.json `version` and re-run the sync/release script.
|
|
10
10
|
*/
|
|
11
|
-
exports.SDK_VERSION = '0.1.
|
|
11
|
+
exports.SDK_VERSION = '0.1.7';
|
|
12
12
|
exports.SDK_PACKAGE = '@fiodos/web-core';
|
|
@@ -1,4 +1,41 @@
|
|
|
1
1
|
import { detectWakePhrase } from '@fiodos/core';
|
|
2
|
+
// ── Device TTS (SpeechSynthesis) helpers ────────────────────────────────────
|
|
3
|
+
// Chrome loads voices asynchronously: calling speak() before getVoices() is
|
|
4
|
+
// populated produces NO sound. Resolve the voice list once (event or timeout).
|
|
5
|
+
function loadVoicesOnce(synth) {
|
|
6
|
+
const ready = synth.getVoices();
|
|
7
|
+
if (ready.length)
|
|
8
|
+
return Promise.resolve(ready);
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
let settled = false;
|
|
11
|
+
const finish = () => {
|
|
12
|
+
if (settled)
|
|
13
|
+
return;
|
|
14
|
+
settled = true;
|
|
15
|
+
resolve(synth.getVoices());
|
|
16
|
+
};
|
|
17
|
+
try {
|
|
18
|
+
synth.addEventListener('voiceschanged', finish, { once: true });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* older engines: the timeout below is the only path */
|
|
22
|
+
}
|
|
23
|
+
// Some engines never emit `voiceschanged` — never block the reply forever.
|
|
24
|
+
setTimeout(finish, 600);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** Best voice for a BCP-47 locale: exact → region-agnostic → language → default. */
|
|
28
|
+
function pickVoiceForLocale(voices, locale) {
|
|
29
|
+
if (!voices.length)
|
|
30
|
+
return undefined;
|
|
31
|
+
const lc = locale.toLowerCase();
|
|
32
|
+
const lang = lc.split('-')[0] ?? lc;
|
|
33
|
+
return (voices.find((v) => v.lang?.toLowerCase() === lc) ??
|
|
34
|
+
voices.find((v) => v.lang?.toLowerCase().startsWith(`${lang}-`)) ??
|
|
35
|
+
voices.find((v) => v.lang?.toLowerCase().startsWith(lang)) ??
|
|
36
|
+
voices.find((v) => v.default) ??
|
|
37
|
+
voices[0]);
|
|
38
|
+
}
|
|
2
39
|
function getRecognitionCtor() {
|
|
3
40
|
if (typeof window === 'undefined')
|
|
4
41
|
return null;
|
|
@@ -30,6 +67,9 @@ export function createWebVoiceAdapter(options = {}) {
|
|
|
30
67
|
let aborted = false;
|
|
31
68
|
let settled = false;
|
|
32
69
|
let audioEl = null;
|
|
70
|
+
// Strong reference to the in-flight utterance: Chrome garbage-collects it
|
|
71
|
+
// otherwise, cutting device speech off mid-sentence (or producing silence).
|
|
72
|
+
let ttsUtterance = null;
|
|
33
73
|
// ── Wake-word state (separate recognition instance; the browser allows only
|
|
34
74
|
// one active session, so push-to-talk stops wake first). Desktop-only:
|
|
35
75
|
// foreground tab while the page is visible — there is no passive background
|
|
@@ -358,20 +398,40 @@ export function createWebVoiceAdapter(options = {}) {
|
|
|
358
398
|
// later programmatic playback (after the network round-trip) is allowed
|
|
359
399
|
// and audible even with the silent switch on.
|
|
360
400
|
const ctx = ensureAudioCtx();
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
401
|
+
if (ctx) {
|
|
402
|
+
try {
|
|
403
|
+
if (ctx.state === 'suspended')
|
|
404
|
+
void ctx.resume();
|
|
405
|
+
// A 1-frame silent buffer fully unlocks playback on iOS Safari.
|
|
406
|
+
const buffer = ctx.createBuffer(1, 1, ctx.sampleRate);
|
|
407
|
+
const source = ctx.createBufferSource();
|
|
408
|
+
source.buffer = buffer;
|
|
409
|
+
source.connect(ctx.destination);
|
|
410
|
+
source.start(0);
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
/* best-effort unlock */
|
|
414
|
+
}
|
|
372
415
|
}
|
|
373
|
-
|
|
374
|
-
|
|
416
|
+
// Unlock device TTS (SpeechSynthesis) too. The spoken reply happens AFTER
|
|
417
|
+
// the network round-trip — outside this gesture — and Safari/Chrome stay
|
|
418
|
+
// SILENT for a first programmatic speak that wasn't preceded by a
|
|
419
|
+
// gesture-initiated one. Warm the engine here: kick off voice loading and
|
|
420
|
+
// speak a zero-volume blank utterance inside the tap so the real reply is
|
|
421
|
+
// allowed to play later.
|
|
422
|
+
if (!options.disableDeviceTtsFallback && typeof window !== 'undefined' && window.speechSynthesis) {
|
|
423
|
+
try {
|
|
424
|
+
const synth = window.speechSynthesis;
|
|
425
|
+
synth.getVoices(); // triggers async voice loading on Chrome
|
|
426
|
+
if (synth.paused)
|
|
427
|
+
synth.resume();
|
|
428
|
+
const warm = new SpeechSynthesisUtterance(' ');
|
|
429
|
+
warm.volume = 0;
|
|
430
|
+
synth.speak(warm);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
/* best-effort unlock */
|
|
434
|
+
}
|
|
375
435
|
}
|
|
376
436
|
},
|
|
377
437
|
async playAudioBase64Mp3(base64) {
|
|
@@ -409,15 +469,61 @@ export function createWebVoiceAdapter(options = {}) {
|
|
|
409
469
|
return;
|
|
410
470
|
if (typeof window === 'undefined' || !window.speechSynthesis)
|
|
411
471
|
return;
|
|
472
|
+
const synth = window.speechSynthesis;
|
|
473
|
+
// Voices load lazily on Chrome — speaking before they exist is silent.
|
|
474
|
+
const voices = await loadVoicesOnce(synth);
|
|
412
475
|
return new Promise((resolve) => {
|
|
476
|
+
let keepAlive = null;
|
|
477
|
+
const cleanup = () => {
|
|
478
|
+
if (keepAlive) {
|
|
479
|
+
clearInterval(keepAlive);
|
|
480
|
+
keepAlive = null;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
413
483
|
try {
|
|
484
|
+
// Clear any previous/wedged queue. After a prior cancel() (e.g. the
|
|
485
|
+
// stopPlayback() fired when listening started) Chrome on macOS can sit
|
|
486
|
+
// in a stuck "paused" state that does NOT report paused===true, so a
|
|
487
|
+
// conditional resume() never fires and every later speak() is silent.
|
|
488
|
+
synth.cancel();
|
|
414
489
|
const utter = new SpeechSynthesisUtterance(text);
|
|
415
490
|
utter.lang = ttsOptions.locale;
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
491
|
+
const voice = pickVoiceForLocale(voices, ttsOptions.locale);
|
|
492
|
+
if (voice)
|
|
493
|
+
utter.voice = voice;
|
|
494
|
+
utter.volume = 1;
|
|
495
|
+
utter.rate = 1;
|
|
496
|
+
utter.pitch = 1;
|
|
497
|
+
// Keep a strong ref so Chrome can't GC the utterance mid-speech.
|
|
498
|
+
ttsUtterance = utter;
|
|
499
|
+
const finish = () => {
|
|
500
|
+
cleanup();
|
|
501
|
+
utter.onstart = null;
|
|
502
|
+
utter.onend = null;
|
|
503
|
+
utter.onerror = null;
|
|
504
|
+
if (ttsUtterance === utter)
|
|
505
|
+
ttsUtterance = null;
|
|
506
|
+
resolve();
|
|
507
|
+
};
|
|
508
|
+
utter.onend = finish;
|
|
509
|
+
utter.onerror = finish;
|
|
510
|
+
synth.speak(utter);
|
|
511
|
+
// THE FIX: resume() unconditionally right after queuing. This un-wedges
|
|
512
|
+
// the engine from the silent stuck-paused state described above.
|
|
513
|
+
synth.resume();
|
|
514
|
+
// Chrome desktop stops speaking after ~15s; pulse pause/resume to keep
|
|
515
|
+
// long replies going, and stop the timer once it has finished.
|
|
516
|
+
keepAlive = setInterval(() => {
|
|
517
|
+
if (!synth.speaking && !synth.pending) {
|
|
518
|
+
cleanup();
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
synth.pause();
|
|
522
|
+
synth.resume();
|
|
523
|
+
}, 9000);
|
|
419
524
|
}
|
|
420
525
|
catch {
|
|
526
|
+
cleanup();
|
|
421
527
|
resolve();
|
|
422
528
|
}
|
|
423
529
|
});
|
|
@@ -74,6 +74,8 @@ export declare class AgentController {
|
|
|
74
74
|
private abort;
|
|
75
75
|
private watchdog;
|
|
76
76
|
private confirmWindow;
|
|
77
|
+
private speakTimer;
|
|
78
|
+
private serverTtsUnavailable;
|
|
77
79
|
private pendingConsentIntent;
|
|
78
80
|
private readonly listeners;
|
|
79
81
|
private unsubscribeSpeech;
|
|
@@ -95,7 +97,14 @@ export declare class AgentController {
|
|
|
95
97
|
private setPhase;
|
|
96
98
|
private clearWatchdog;
|
|
97
99
|
private clearConfirmWindow;
|
|
100
|
+
private clearSpeakTimer;
|
|
98
101
|
private speakReply;
|
|
102
|
+
/**
|
|
103
|
+
* Never let the 'speaking' phase hang. If playback/TTS never reports
|
|
104
|
+
* completion (a stalled engine, a never-firing onend), stop it and continue
|
|
105
|
+
* after a generous, length-based cap so the orb returns to idle on its own.
|
|
106
|
+
*/
|
|
107
|
+
private raceSpeakingWatchdog;
|
|
99
108
|
private beginConfirmationVoiceWindow;
|
|
100
109
|
confirmPendingAction(): Promise<void>;
|
|
101
110
|
cancelPendingAction(): void;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* deliberate modal tap; a loose "yes" never confirms them.
|
|
16
16
|
* - The agent only ever executes actions declared in the manifest.
|
|
17
17
|
*/
|
|
18
|
-
import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, createConversationSession, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
|
|
18
|
+
import { combineContexts, createActionExecutor, createConsentManager, createSafeTelemetry, createSanitizer, createConversationSession, defaultRouteContextText, formatMessage, prepareConversationForTurn, recordConversationExchange, resolveConfirmationLexicon, resolveMessages, validateManifest, } from '@fiodos/core';
|
|
19
19
|
import { AgentApiError } from '../api/errors.js';
|
|
20
20
|
import { formatAgentTurnError } from '../api/formatTurnError.js';
|
|
21
21
|
import { buildManifestPayload } from '../api/backendClient.js';
|
|
@@ -48,6 +48,12 @@ export class AgentController {
|
|
|
48
48
|
this.abort = null;
|
|
49
49
|
this.watchdog = null;
|
|
50
50
|
this.confirmWindow = null;
|
|
51
|
+
// Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
|
|
52
|
+
// never reports completion (e.g. a stalled SpeechSynthesis engine).
|
|
53
|
+
this.speakTimer = null;
|
|
54
|
+
// Once the backend shows it has no server-side TTS (a reply arrives with null
|
|
55
|
+
// audio), skip the extra /preview-tts round-trip and speak via device voice.
|
|
56
|
+
this.serverTtsUnavailable = false;
|
|
51
57
|
this.pendingConsentIntent = null;
|
|
52
58
|
this.listeners = new Set();
|
|
53
59
|
this.unsubscribeSpeech = null;
|
|
@@ -84,7 +90,11 @@ export class AgentController {
|
|
|
84
90
|
});
|
|
85
91
|
this.screenStore = createScreenContextStore({
|
|
86
92
|
getCurrentRoute: () => config.navigation.getCurrentRoute(),
|
|
87
|
-
|
|
93
|
+
// The integrator's fallback wins; otherwise the orb gets screen awareness
|
|
94
|
+
// out of the box from the matched manifest route (label + description +
|
|
95
|
+
// available actions). Null when no route matches → raw route, as today.
|
|
96
|
+
routeFallback: config.routeContextFallback ??
|
|
97
|
+
((route) => defaultRouteContextText(route, config.manifest, this.messages)),
|
|
88
98
|
});
|
|
89
99
|
this.speech = createSpeechSession({
|
|
90
100
|
adapter: config.voice,
|
|
@@ -176,6 +186,12 @@ export class AgentController {
|
|
|
176
186
|
}
|
|
177
187
|
this.confirmationVoiceActive = false;
|
|
178
188
|
}
|
|
189
|
+
clearSpeakTimer() {
|
|
190
|
+
if (this.speakTimer) {
|
|
191
|
+
clearTimeout(this.speakTimer);
|
|
192
|
+
this.speakTimer = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
179
195
|
// ── Speech playback (ONE consistent voice) ─────────────────────────────────
|
|
180
196
|
async speakReply(text, audioBase64) {
|
|
181
197
|
if (this.silentTurn)
|
|
@@ -185,24 +201,62 @@ export class AgentController {
|
|
|
185
201
|
this.setPhase('speaking');
|
|
186
202
|
try {
|
|
187
203
|
let audio = audioBase64;
|
|
188
|
-
if (!audio && text) {
|
|
189
|
-
audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
|
|
190
|
-
}
|
|
191
204
|
if (audio) {
|
|
192
|
-
|
|
205
|
+
this.serverTtsUnavailable = false; // server voice is working
|
|
193
206
|
}
|
|
194
|
-
|
|
195
|
-
|
|
207
|
+
// Once we know the backend ships no server TTS, skip the dead
|
|
208
|
+
// /preview-tts round-trip (pure latency) and use the device voice.
|
|
209
|
+
if (!audio && text && !this.serverTtsUnavailable) {
|
|
210
|
+
try {
|
|
211
|
+
audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
|
|
212
|
+
if (!audio)
|
|
213
|
+
this.serverTtsUnavailable = true;
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
audio = null;
|
|
217
|
+
this.serverTtsUnavailable = true;
|
|
218
|
+
}
|
|
196
219
|
}
|
|
220
|
+
const playback = audio
|
|
221
|
+
? this.config.voice.playAudioBase64Mp3(audio)
|
|
222
|
+
: text
|
|
223
|
+
? this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale })
|
|
224
|
+
: Promise.resolve();
|
|
225
|
+
await this.raceSpeakingWatchdog(playback, text);
|
|
197
226
|
}
|
|
198
227
|
catch {
|
|
199
228
|
/* playback best-effort */
|
|
200
229
|
}
|
|
201
230
|
finally {
|
|
231
|
+
this.clearSpeakTimer();
|
|
202
232
|
if (this.phase === 'speaking')
|
|
203
233
|
this.setPhase('idle');
|
|
204
234
|
}
|
|
205
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Never let the 'speaking' phase hang. If playback/TTS never reports
|
|
238
|
+
* completion (a stalled engine, a never-firing onend), stop it and continue
|
|
239
|
+
* after a generous, length-based cap so the orb returns to idle on its own.
|
|
240
|
+
*/
|
|
241
|
+
raceSpeakingWatchdog(playback, text) {
|
|
242
|
+
const capMs = Math.min(60000, 5000 + text.length * 90);
|
|
243
|
+
return new Promise((resolve) => {
|
|
244
|
+
let settled = false;
|
|
245
|
+
const finish = () => {
|
|
246
|
+
if (settled)
|
|
247
|
+
return;
|
|
248
|
+
settled = true;
|
|
249
|
+
this.clearSpeakTimer();
|
|
250
|
+
resolve();
|
|
251
|
+
};
|
|
252
|
+
this.clearSpeakTimer();
|
|
253
|
+
this.speakTimer = setTimeout(() => {
|
|
254
|
+
this.config.voice.stopPlayback();
|
|
255
|
+
finish();
|
|
256
|
+
}, capMs);
|
|
257
|
+
playback.then(finish, finish);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
206
260
|
// ── Confirmation resolution ─────────────────────────────────────────────────
|
|
207
261
|
beginConfirmationVoiceWindow() {
|
|
208
262
|
const pending = this.pending;
|
|
@@ -375,6 +429,10 @@ export class AgentController {
|
|
|
375
429
|
}, { signal: controller.signal });
|
|
376
430
|
reply = turn.reply;
|
|
377
431
|
audioBase64 = turn.audioBase64;
|
|
432
|
+
// Learn whether this backend ships server-side TTS: a non-empty reply
|
|
433
|
+
// with no audio means it doesn't — later lines then speak instantly.
|
|
434
|
+
if (reply)
|
|
435
|
+
this.serverTtsUnavailable = !audioBase64;
|
|
378
436
|
const decision = await decideAction({
|
|
379
437
|
action: turn.action,
|
|
380
438
|
manifest: this.config.manifest,
|
|
@@ -541,8 +599,14 @@ export class AgentController {
|
|
|
541
599
|
this.setPhase('thinking');
|
|
542
600
|
return;
|
|
543
601
|
}
|
|
544
|
-
if (this.phase === '
|
|
602
|
+
if (this.phase === 'speaking') {
|
|
603
|
+
// Tap while the assistant is talking → stop the reply and reset, so the
|
|
604
|
+
// orb is immediately usable again (no more "stuck pulsing").
|
|
605
|
+
this.cancelAll();
|
|
545
606
|
return;
|
|
607
|
+
}
|
|
608
|
+
if (this.phase === 'thinking')
|
|
609
|
+
return; // a turn is in flight; let it finish
|
|
546
610
|
const consented = await this.consent.hasConsented();
|
|
547
611
|
if (!consented) {
|
|
548
612
|
this.pendingConsentIntent = 'voice';
|
|
@@ -594,6 +658,7 @@ export class AgentController {
|
|
|
594
658
|
this.abort = null;
|
|
595
659
|
this.clearWatchdog();
|
|
596
660
|
this.clearConfirmWindow();
|
|
661
|
+
this.clearSpeakTimer();
|
|
597
662
|
this.speech.cancel();
|
|
598
663
|
this.config.voice.stopPlayback();
|
|
599
664
|
this.pending = null;
|
package/dist/esm/orb/mountOrb.js
CHANGED
|
@@ -43,6 +43,7 @@ const CSS = `
|
|
|
43
43
|
@keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
|
|
44
44
|
@keyframes fy-spin{to{transform:rotate(360deg)}}
|
|
45
45
|
@keyframes fy-opulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.06);opacity:.85}}
|
|
46
|
+
@keyframes fy-voice{0%{transform:scale(0.6);opacity:.7}100%{transform:scale(1.35);opacity:0}}
|
|
46
47
|
.fyodos-overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(4,6,12,0.6);}
|
|
47
48
|
.fyodos-modal{width:min(420px,100%);border-radius:16px;padding:22px 24px;box-shadow:0 24px 60px rgba(0,0,0,0.5);}
|
|
48
49
|
.fyodos-modal h3{margin:0 0 10px;font-size:17px;font-weight:600;}
|
package/dist/esm/orb/orbView.js
CHANGED
|
@@ -210,6 +210,52 @@ function buildThinkingSpinner(orbSizePx, color) {
|
|
|
210
210
|
wrap.appendChild(spin);
|
|
211
211
|
return wrap;
|
|
212
212
|
}
|
|
213
|
+
// ── Speaking pulse (port of the dashboard's no-logo "speaking" state) ─────────
|
|
214
|
+
// Two expanding rings + a pulsing centre dot — the default "talking" drawing the
|
|
215
|
+
// dashboard orb editor shows when there is no custom photo. Ratios are taken
|
|
216
|
+
// from the editor (118 / 30 / 2 px on its 380px reference orb) so the embedded
|
|
217
|
+
// orb matches it at any size.
|
|
218
|
+
const SPEAK_RING_RATIO = 118 / 380;
|
|
219
|
+
const SPEAK_DOT_RATIO = 30 / 380;
|
|
220
|
+
const SPEAK_BORDER_RATIO = 2 / 380;
|
|
221
|
+
function buildSpeakingPulse(orbSize, color) {
|
|
222
|
+
const wrap = document.createElement('div');
|
|
223
|
+
Object.assign(wrap.style, {
|
|
224
|
+
position: 'absolute',
|
|
225
|
+
inset: '0',
|
|
226
|
+
display: 'flex',
|
|
227
|
+
alignItems: 'center',
|
|
228
|
+
justifyContent: 'center',
|
|
229
|
+
pointerEvents: 'none',
|
|
230
|
+
});
|
|
231
|
+
const ringPx = orbSize * SPEAK_RING_RATIO;
|
|
232
|
+
const borderPx = Math.max(1, orbSize * SPEAK_BORDER_RATIO);
|
|
233
|
+
for (const delay of [0, 0.85]) {
|
|
234
|
+
const ring = document.createElement('div');
|
|
235
|
+
Object.assign(ring.style, {
|
|
236
|
+
position: 'absolute',
|
|
237
|
+
width: `${ringPx}px`,
|
|
238
|
+
height: `${ringPx}px`,
|
|
239
|
+
borderRadius: '50%',
|
|
240
|
+
border: `${borderPx}px solid ${color}`,
|
|
241
|
+
opacity: '0.55',
|
|
242
|
+
animation: `fy-voice 1.7s ease-out infinite ${delay}s`,
|
|
243
|
+
});
|
|
244
|
+
wrap.appendChild(ring);
|
|
245
|
+
}
|
|
246
|
+
const dotPx = orbSize * SPEAK_DOT_RATIO;
|
|
247
|
+
const dot = document.createElement('div');
|
|
248
|
+
Object.assign(dot.style, {
|
|
249
|
+
width: `${dotPx}px`,
|
|
250
|
+
height: `${dotPx}px`,
|
|
251
|
+
borderRadius: '50%',
|
|
252
|
+
background: color,
|
|
253
|
+
opacity: '0.9',
|
|
254
|
+
animation: 'fy-opulse 1s ease-in-out infinite',
|
|
255
|
+
});
|
|
256
|
+
wrap.appendChild(dot);
|
|
257
|
+
return wrap;
|
|
258
|
+
}
|
|
213
259
|
// ── Keyboard chip icon (port of ChipIconSvg) ──────────────────────────────────
|
|
214
260
|
function buildChipIcon(icon, size, color) {
|
|
215
261
|
const spec = chipIconSpec(icon);
|
|
@@ -364,7 +410,10 @@ export function createOrbVisual(initial) {
|
|
|
364
410
|
waveform?.stop();
|
|
365
411
|
waveform = null;
|
|
366
412
|
fillEl.replaceChildren();
|
|
367
|
-
|
|
413
|
+
// The "speaking" feedback now lives INSIDE the fill (a pulsing logo, or the
|
|
414
|
+
// rings + dot when there's no photo) to mirror the dashboard editor, so the
|
|
415
|
+
// orb no longer pulses as a whole.
|
|
416
|
+
wrapper.style.animation = '';
|
|
368
417
|
if (state === 'thinking') {
|
|
369
418
|
fillEl.appendChild(buildThinkingSpinner(size, waveColor));
|
|
370
419
|
return;
|
|
@@ -392,10 +441,17 @@ export function createOrbVisual(initial) {
|
|
|
392
441
|
userSelect: 'none',
|
|
393
442
|
webkitUserSelect: 'none',
|
|
394
443
|
webkitUserDrag: 'none',
|
|
444
|
+
// While speaking, the photo gently pulses (matches the dashboard).
|
|
445
|
+
animation: state === 'speaking' ? 'fy-opulse 1s ease-in-out infinite' : '',
|
|
395
446
|
});
|
|
396
447
|
fillEl.appendChild(img);
|
|
397
448
|
return;
|
|
398
449
|
}
|
|
450
|
+
if (state === 'speaking') {
|
|
451
|
+
// No photo → the default "talking" drawing (expanding rings + centre dot).
|
|
452
|
+
fillEl.appendChild(buildSpeakingPulse(size, waveColor));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
399
455
|
if (state === 'idle') {
|
|
400
456
|
fillEl.appendChild(buildOrbMark(size));
|
|
401
457
|
}
|
package/dist/esm/version.d.ts
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
|
|
6
6
|
* by hand — change package.json `version` and re-run the sync/release script.
|
|
7
7
|
*/
|
|
8
|
-
export declare const SDK_VERSION = "0.1.
|
|
8
|
+
export declare const SDK_VERSION = "0.1.7";
|
|
9
9
|
export declare const SDK_PACKAGE = "@fiodos/web-core";
|
package/dist/esm/version.js
CHANGED
|
@@ -5,5 +5,5 @@
|
|
|
5
5
|
* Auto-generated by scripts/sync-sdk-version.mjs from package.json. Do not edit
|
|
6
6
|
* by hand — change package.json `version` and re-run the sync/release script.
|
|
7
7
|
*/
|
|
8
|
-
export const SDK_VERSION = '0.1.
|
|
8
|
+
export const SDK_VERSION = '0.1.7';
|
|
9
9
|
export const SDK_PACKAGE = '@fiodos/web-core';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiodos/web-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Framework-agnostic browser layer for the Fiodos agent: a vanilla AgentController (orchestrator), DOM adapters (navigation/voice/storage), HTTP client and decision engine over @fiodos/core. Shared base for @fiodos/vue, @fiodos/svelte and @fiodos/angular (no framework imports).",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"publishConfig": {
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && node ../../scripts/fix-esm-extensions.mjs dist/esm && node ../../scripts/mark-dual.mjs",
|
|
27
27
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
28
|
-
"test": "node --import tsx --test test/*.test.ts"
|
|
28
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
29
|
+
"prepublishOnly": "node ../../scripts/sync-sdk-version.mjs && npm run build"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@fiodos/core": "^0.1.0"
|