@fiodos/web-core 0.1.3 → 0.1.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/dist/cjs/adapters/webVoiceAdapter.js +63 -3
- package/dist/cjs/controller/AgentController.d.ts +8 -0
- package/dist/cjs/controller/AgentController.js +55 -8
- package/dist/cjs/orb/mountOrb.js +31 -7
- 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 +63 -3
- package/dist/esm/controller/AgentController.d.ts +8 -0
- package/dist/esm/controller/AgentController.js +55 -8
- package/dist/esm/orb/mountOrb.js +32 -8
- 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
|
|
@@ -412,13 +452,33 @@ function createWebVoiceAdapter(options = {}) {
|
|
|
412
452
|
return;
|
|
413
453
|
if (typeof window === 'undefined' || !window.speechSynthesis)
|
|
414
454
|
return;
|
|
455
|
+
const synth = window.speechSynthesis;
|
|
456
|
+
// Voices load lazily on Chrome — speaking before they exist is silent.
|
|
457
|
+
const voices = await loadVoicesOnce(synth);
|
|
415
458
|
return new Promise((resolve) => {
|
|
416
459
|
try {
|
|
460
|
+
// Clear any utterance the engine wedged on (a known Chrome failure
|
|
461
|
+
// mode) and unpause before queuing the reply.
|
|
462
|
+
if (synth.speaking || synth.pending)
|
|
463
|
+
synth.cancel();
|
|
464
|
+
if (synth.paused)
|
|
465
|
+
synth.resume();
|
|
417
466
|
const utter = new SpeechSynthesisUtterance(text);
|
|
418
467
|
utter.lang = ttsOptions.locale;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
468
|
+
const voice = pickVoiceForLocale(voices, ttsOptions.locale);
|
|
469
|
+
if (voice)
|
|
470
|
+
utter.voice = voice;
|
|
471
|
+
ttsUtterance = utter;
|
|
472
|
+
const finish = () => {
|
|
473
|
+
utter.onend = null;
|
|
474
|
+
utter.onerror = null;
|
|
475
|
+
if (ttsUtterance === utter)
|
|
476
|
+
ttsUtterance = null;
|
|
477
|
+
resolve();
|
|
478
|
+
};
|
|
479
|
+
utter.onend = finish;
|
|
480
|
+
utter.onerror = finish;
|
|
481
|
+
synth.speak(utter);
|
|
422
482
|
}
|
|
423
483
|
catch {
|
|
424
484
|
resolve();
|
|
@@ -74,6 +74,7 @@ export declare class AgentController {
|
|
|
74
74
|
private abort;
|
|
75
75
|
private watchdog;
|
|
76
76
|
private confirmWindow;
|
|
77
|
+
private speakTimer;
|
|
77
78
|
private pendingConsentIntent;
|
|
78
79
|
private readonly listeners;
|
|
79
80
|
private unsubscribeSpeech;
|
|
@@ -95,7 +96,14 @@ export declare class AgentController {
|
|
|
95
96
|
private setPhase;
|
|
96
97
|
private clearWatchdog;
|
|
97
98
|
private clearConfirmWindow;
|
|
99
|
+
private clearSpeakTimer;
|
|
98
100
|
private speakReply;
|
|
101
|
+
/**
|
|
102
|
+
* Never let the 'speaking' phase hang. If playback/TTS never reports
|
|
103
|
+
* completion (a stalled engine, a never-firing onend), stop it and continue
|
|
104
|
+
* after a generous, length-based cap so the orb returns to idle on its own.
|
|
105
|
+
*/
|
|
106
|
+
private raceSpeakingWatchdog;
|
|
99
107
|
private beginConfirmationVoiceWindow;
|
|
100
108
|
confirmPendingAction(): Promise<void>;
|
|
101
109
|
cancelPendingAction(): void;
|
|
@@ -51,6 +51,9 @@ 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;
|
|
54
57
|
this.pendingConsentIntent = null;
|
|
55
58
|
this.listeners = new Set();
|
|
56
59
|
this.unsubscribeSpeech = null;
|
|
@@ -179,6 +182,12 @@ class AgentController {
|
|
|
179
182
|
}
|
|
180
183
|
this.confirmationVoiceActive = false;
|
|
181
184
|
}
|
|
185
|
+
clearSpeakTimer() {
|
|
186
|
+
if (this.speakTimer) {
|
|
187
|
+
clearTimeout(this.speakTimer);
|
|
188
|
+
this.speakTimer = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
182
191
|
// ── Speech playback (ONE consistent voice) ─────────────────────────────────
|
|
183
192
|
async speakReply(text, audioBase64) {
|
|
184
193
|
if (this.silentTurn)
|
|
@@ -189,23 +198,54 @@ class AgentController {
|
|
|
189
198
|
try {
|
|
190
199
|
let audio = audioBase64;
|
|
191
200
|
if (!audio && text) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
201
|
+
// A server-TTS failure must NOT skip the device-voice fallback below.
|
|
202
|
+
try {
|
|
203
|
+
audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
audio = null;
|
|
207
|
+
}
|
|
199
208
|
}
|
|
209
|
+
const playback = audio
|
|
210
|
+
? this.config.voice.playAudioBase64Mp3(audio)
|
|
211
|
+
: text
|
|
212
|
+
? this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale })
|
|
213
|
+
: Promise.resolve();
|
|
214
|
+
await this.raceSpeakingWatchdog(playback, text);
|
|
200
215
|
}
|
|
201
216
|
catch {
|
|
202
217
|
/* playback best-effort */
|
|
203
218
|
}
|
|
204
219
|
finally {
|
|
220
|
+
this.clearSpeakTimer();
|
|
205
221
|
if (this.phase === 'speaking')
|
|
206
222
|
this.setPhase('idle');
|
|
207
223
|
}
|
|
208
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Never let the 'speaking' phase hang. If playback/TTS never reports
|
|
227
|
+
* completion (a stalled engine, a never-firing onend), stop it and continue
|
|
228
|
+
* after a generous, length-based cap so the orb returns to idle on its own.
|
|
229
|
+
*/
|
|
230
|
+
raceSpeakingWatchdog(playback, text) {
|
|
231
|
+
const capMs = Math.min(60000, 5000 + text.length * 90);
|
|
232
|
+
return new Promise((resolve) => {
|
|
233
|
+
let settled = false;
|
|
234
|
+
const finish = () => {
|
|
235
|
+
if (settled)
|
|
236
|
+
return;
|
|
237
|
+
settled = true;
|
|
238
|
+
this.clearSpeakTimer();
|
|
239
|
+
resolve();
|
|
240
|
+
};
|
|
241
|
+
this.clearSpeakTimer();
|
|
242
|
+
this.speakTimer = setTimeout(() => {
|
|
243
|
+
this.config.voice.stopPlayback();
|
|
244
|
+
finish();
|
|
245
|
+
}, capMs);
|
|
246
|
+
playback.then(finish, finish);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
209
249
|
// ── Confirmation resolution ─────────────────────────────────────────────────
|
|
210
250
|
beginConfirmationVoiceWindow() {
|
|
211
251
|
const pending = this.pending;
|
|
@@ -544,8 +584,14 @@ class AgentController {
|
|
|
544
584
|
this.setPhase('thinking');
|
|
545
585
|
return;
|
|
546
586
|
}
|
|
547
|
-
if (this.phase === '
|
|
587
|
+
if (this.phase === 'speaking') {
|
|
588
|
+
// Tap while the assistant is talking → stop the reply and reset, so the
|
|
589
|
+
// orb is immediately usable again (no more "stuck pulsing").
|
|
590
|
+
this.cancelAll();
|
|
548
591
|
return;
|
|
592
|
+
}
|
|
593
|
+
if (this.phase === 'thinking')
|
|
594
|
+
return; // a turn is in flight; let it finish
|
|
549
595
|
const consented = await this.consent.hasConsented();
|
|
550
596
|
if (!consented) {
|
|
551
597
|
this.pendingConsentIntent = 'voice';
|
|
@@ -597,6 +643,7 @@ class AgentController {
|
|
|
597
643
|
this.abort = null;
|
|
598
644
|
this.clearWatchdog();
|
|
599
645
|
this.clearConfirmWindow();
|
|
646
|
+
this.clearSpeakTimer();
|
|
600
647
|
this.speech.cancel();
|
|
601
648
|
this.config.voice.stopPlayback();
|
|
602
649
|
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;}
|
|
@@ -65,6 +66,18 @@ function ensureStyles() {
|
|
|
65
66
|
el.textContent = CSS;
|
|
66
67
|
document.head.appendChild(el);
|
|
67
68
|
}
|
|
69
|
+
// Content box of the layout viewport. `documentElement.clientWidth/Height`
|
|
70
|
+
// EXCLUDE a classic scrollbar that occupies real width/height (Windows, some
|
|
71
|
+
// Linux/Chromium); overlay scrollbars (macOS, mobile) report the same value as
|
|
72
|
+
// the layout viewport. Anchoring against this — not `innerWidth` — keeps a
|
|
73
|
+
// right/bottom-edge orb just INSIDE the scrollbar instead of under/over it.
|
|
74
|
+
function viewportContentSize() {
|
|
75
|
+
const de = typeof document !== 'undefined' ? document.documentElement : null;
|
|
76
|
+
return {
|
|
77
|
+
width: de?.clientWidth || window.innerWidth,
|
|
78
|
+
height: de?.clientHeight || window.innerHeight,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
68
81
|
function cornerToPosition(corner) {
|
|
69
82
|
switch (corner) {
|
|
70
83
|
case 'bottom-left':
|
|
@@ -161,14 +174,15 @@ function mountOrb(controller, options = {}) {
|
|
|
161
174
|
}
|
|
162
175
|
function reposition() {
|
|
163
176
|
const size = orbDiameterPx();
|
|
164
|
-
const
|
|
177
|
+
const content = viewportContentSize();
|
|
178
|
+
const { left, top } = (0, core_1.resolveOrbAnchorPx)({
|
|
165
179
|
viewportWidth: window.innerWidth,
|
|
166
180
|
viewportHeight: window.innerHeight,
|
|
181
|
+
contentWidth: content.width,
|
|
182
|
+
contentHeight: content.height,
|
|
167
183
|
orbDiameter: size,
|
|
168
184
|
position: effectivePosition(),
|
|
169
185
|
});
|
|
170
|
-
const left = Math.max(8, window.innerWidth - insets.right - size);
|
|
171
|
-
const top = Math.max(8, window.innerHeight - insets.bottom - size);
|
|
172
186
|
root.style.left = `${left}px`;
|
|
173
187
|
root.style.top = `${top}px`;
|
|
174
188
|
root.style.right = '';
|
|
@@ -197,6 +211,14 @@ function mountOrb(controller, options = {}) {
|
|
|
197
211
|
}
|
|
198
212
|
}
|
|
199
213
|
window.addEventListener('resize', reposition);
|
|
214
|
+
// Recalc when a scrollbar appears/disappears as the page content changes (the
|
|
215
|
+
// content box shrinks/grows even when the window size doesn't), so the orb
|
|
216
|
+
// never ends up overlapped by — or detached from — the scrollbar edge.
|
|
217
|
+
let contentObserver;
|
|
218
|
+
if (typeof ResizeObserver !== 'undefined' && document.documentElement) {
|
|
219
|
+
contentObserver = new ResizeObserver(() => reposition());
|
|
220
|
+
contentObserver.observe(document.documentElement);
|
|
221
|
+
}
|
|
200
222
|
// Keep the conversation bar above the keyboard as the visual viewport resizes
|
|
201
223
|
// (keyboard open/close) or scrolls on mobile browsers.
|
|
202
224
|
const visualViewport = window.visualViewport;
|
|
@@ -240,9 +262,10 @@ function mountOrb(controller, options = {}) {
|
|
|
240
262
|
dragging = true;
|
|
241
263
|
e.preventDefault();
|
|
242
264
|
const size = orbDiameterPx();
|
|
265
|
+
const content = viewportContentSize();
|
|
243
266
|
const margin = 8;
|
|
244
|
-
const maxLeft =
|
|
245
|
-
const maxTop =
|
|
267
|
+
const maxLeft = content.width - margin - size;
|
|
268
|
+
const maxTop = content.height - margin - size;
|
|
246
269
|
const left = Math.min(maxLeft, Math.max(margin, originLeft + dx));
|
|
247
270
|
const top = Math.min(maxTop, Math.max(margin, originTop + dy));
|
|
248
271
|
root.style.left = `${left}px`;
|
|
@@ -252,8 +275,8 @@ function mountOrb(controller, options = {}) {
|
|
|
252
275
|
draggedPosition = (0, core_1.viewportPixelsToOrbScreenPosition)({
|
|
253
276
|
centerX: left + size / 2,
|
|
254
277
|
centerY: top + size / 2,
|
|
255
|
-
viewportWidth:
|
|
256
|
-
viewportHeight:
|
|
278
|
+
viewportWidth: content.width,
|
|
279
|
+
viewportHeight: content.height,
|
|
257
280
|
orbDiameter: size,
|
|
258
281
|
});
|
|
259
282
|
applyDeployment();
|
|
@@ -688,6 +711,7 @@ function mountOrb(controller, options = {}) {
|
|
|
688
711
|
unsubscribe();
|
|
689
712
|
stopWatch();
|
|
690
713
|
window.removeEventListener('resize', reposition);
|
|
714
|
+
contentObserver?.disconnect();
|
|
691
715
|
visualViewport?.removeEventListener('resize', onViewportChange);
|
|
692
716
|
visualViewport?.removeEventListener('scroll', onViewportChange);
|
|
693
717
|
clearOverlay();
|
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.5";
|
|
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.5';
|
|
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
|
|
@@ -409,13 +449,33 @@ export function createWebVoiceAdapter(options = {}) {
|
|
|
409
449
|
return;
|
|
410
450
|
if (typeof window === 'undefined' || !window.speechSynthesis)
|
|
411
451
|
return;
|
|
452
|
+
const synth = window.speechSynthesis;
|
|
453
|
+
// Voices load lazily on Chrome — speaking before they exist is silent.
|
|
454
|
+
const voices = await loadVoicesOnce(synth);
|
|
412
455
|
return new Promise((resolve) => {
|
|
413
456
|
try {
|
|
457
|
+
// Clear any utterance the engine wedged on (a known Chrome failure
|
|
458
|
+
// mode) and unpause before queuing the reply.
|
|
459
|
+
if (synth.speaking || synth.pending)
|
|
460
|
+
synth.cancel();
|
|
461
|
+
if (synth.paused)
|
|
462
|
+
synth.resume();
|
|
414
463
|
const utter = new SpeechSynthesisUtterance(text);
|
|
415
464
|
utter.lang = ttsOptions.locale;
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
465
|
+
const voice = pickVoiceForLocale(voices, ttsOptions.locale);
|
|
466
|
+
if (voice)
|
|
467
|
+
utter.voice = voice;
|
|
468
|
+
ttsUtterance = utter;
|
|
469
|
+
const finish = () => {
|
|
470
|
+
utter.onend = null;
|
|
471
|
+
utter.onerror = null;
|
|
472
|
+
if (ttsUtterance === utter)
|
|
473
|
+
ttsUtterance = null;
|
|
474
|
+
resolve();
|
|
475
|
+
};
|
|
476
|
+
utter.onend = finish;
|
|
477
|
+
utter.onerror = finish;
|
|
478
|
+
synth.speak(utter);
|
|
419
479
|
}
|
|
420
480
|
catch {
|
|
421
481
|
resolve();
|
|
@@ -74,6 +74,7 @@ export declare class AgentController {
|
|
|
74
74
|
private abort;
|
|
75
75
|
private watchdog;
|
|
76
76
|
private confirmWindow;
|
|
77
|
+
private speakTimer;
|
|
77
78
|
private pendingConsentIntent;
|
|
78
79
|
private readonly listeners;
|
|
79
80
|
private unsubscribeSpeech;
|
|
@@ -95,7 +96,14 @@ export declare class AgentController {
|
|
|
95
96
|
private setPhase;
|
|
96
97
|
private clearWatchdog;
|
|
97
98
|
private clearConfirmWindow;
|
|
99
|
+
private clearSpeakTimer;
|
|
98
100
|
private speakReply;
|
|
101
|
+
/**
|
|
102
|
+
* Never let the 'speaking' phase hang. If playback/TTS never reports
|
|
103
|
+
* completion (a stalled engine, a never-firing onend), stop it and continue
|
|
104
|
+
* after a generous, length-based cap so the orb returns to idle on its own.
|
|
105
|
+
*/
|
|
106
|
+
private raceSpeakingWatchdog;
|
|
99
107
|
private beginConfirmationVoiceWindow;
|
|
100
108
|
confirmPendingAction(): Promise<void>;
|
|
101
109
|
cancelPendingAction(): void;
|
|
@@ -48,6 +48,9 @@ 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;
|
|
51
54
|
this.pendingConsentIntent = null;
|
|
52
55
|
this.listeners = new Set();
|
|
53
56
|
this.unsubscribeSpeech = null;
|
|
@@ -176,6 +179,12 @@ export class AgentController {
|
|
|
176
179
|
}
|
|
177
180
|
this.confirmationVoiceActive = false;
|
|
178
181
|
}
|
|
182
|
+
clearSpeakTimer() {
|
|
183
|
+
if (this.speakTimer) {
|
|
184
|
+
clearTimeout(this.speakTimer);
|
|
185
|
+
this.speakTimer = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
179
188
|
// ── Speech playback (ONE consistent voice) ─────────────────────────────────
|
|
180
189
|
async speakReply(text, audioBase64) {
|
|
181
190
|
if (this.silentTurn)
|
|
@@ -186,23 +195,54 @@ export class AgentController {
|
|
|
186
195
|
try {
|
|
187
196
|
let audio = audioBase64;
|
|
188
197
|
if (!audio && text) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
198
|
+
// A server-TTS failure must NOT skip the device-voice fallback below.
|
|
199
|
+
try {
|
|
200
|
+
audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
audio = null;
|
|
204
|
+
}
|
|
196
205
|
}
|
|
206
|
+
const playback = audio
|
|
207
|
+
? this.config.voice.playAudioBase64Mp3(audio)
|
|
208
|
+
: text
|
|
209
|
+
? this.config.voice.speakWithDeviceTts(text, { locale: this.ttsLocale })
|
|
210
|
+
: Promise.resolve();
|
|
211
|
+
await this.raceSpeakingWatchdog(playback, text);
|
|
197
212
|
}
|
|
198
213
|
catch {
|
|
199
214
|
/* playback best-effort */
|
|
200
215
|
}
|
|
201
216
|
finally {
|
|
217
|
+
this.clearSpeakTimer();
|
|
202
218
|
if (this.phase === 'speaking')
|
|
203
219
|
this.setPhase('idle');
|
|
204
220
|
}
|
|
205
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Never let the 'speaking' phase hang. If playback/TTS never reports
|
|
224
|
+
* completion (a stalled engine, a never-firing onend), stop it and continue
|
|
225
|
+
* after a generous, length-based cap so the orb returns to idle on its own.
|
|
226
|
+
*/
|
|
227
|
+
raceSpeakingWatchdog(playback, text) {
|
|
228
|
+
const capMs = Math.min(60000, 5000 + text.length * 90);
|
|
229
|
+
return new Promise((resolve) => {
|
|
230
|
+
let settled = false;
|
|
231
|
+
const finish = () => {
|
|
232
|
+
if (settled)
|
|
233
|
+
return;
|
|
234
|
+
settled = true;
|
|
235
|
+
this.clearSpeakTimer();
|
|
236
|
+
resolve();
|
|
237
|
+
};
|
|
238
|
+
this.clearSpeakTimer();
|
|
239
|
+
this.speakTimer = setTimeout(() => {
|
|
240
|
+
this.config.voice.stopPlayback();
|
|
241
|
+
finish();
|
|
242
|
+
}, capMs);
|
|
243
|
+
playback.then(finish, finish);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
206
246
|
// ── Confirmation resolution ─────────────────────────────────────────────────
|
|
207
247
|
beginConfirmationVoiceWindow() {
|
|
208
248
|
const pending = this.pending;
|
|
@@ -541,8 +581,14 @@ export class AgentController {
|
|
|
541
581
|
this.setPhase('thinking');
|
|
542
582
|
return;
|
|
543
583
|
}
|
|
544
|
-
if (this.phase === '
|
|
584
|
+
if (this.phase === 'speaking') {
|
|
585
|
+
// Tap while the assistant is talking → stop the reply and reset, so the
|
|
586
|
+
// orb is immediately usable again (no more "stuck pulsing").
|
|
587
|
+
this.cancelAll();
|
|
545
588
|
return;
|
|
589
|
+
}
|
|
590
|
+
if (this.phase === 'thinking')
|
|
591
|
+
return; // a turn is in flight; let it finish
|
|
546
592
|
const consented = await this.consent.hasConsented();
|
|
547
593
|
if (!consented) {
|
|
548
594
|
this.pendingConsentIntent = 'voice';
|
|
@@ -594,6 +640,7 @@ export class AgentController {
|
|
|
594
640
|
this.abort = null;
|
|
595
641
|
this.clearWatchdog();
|
|
596
642
|
this.clearConfirmWindow();
|
|
643
|
+
this.clearSpeakTimer();
|
|
597
644
|
this.speech.cancel();
|
|
598
645
|
this.config.voice.stopPlayback();
|
|
599
646
|
this.pending = null;
|
package/dist/esm/orb/mountOrb.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - CONFIRMATIONS: the same security model (manifest decides; strict phrase /
|
|
17
17
|
* deliberate tap for payment-grade; loose "yes" never resolves strict).
|
|
18
18
|
*/
|
|
19
|
-
import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics,
|
|
19
|
+
import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
|
|
20
20
|
import { buildKeyboardChip, createOrbVisual, DEFAULT_ORB_APPEARANCE, } from './orbView.js';
|
|
21
21
|
import { watchPublishedConfig } from './publishedConfig.js';
|
|
22
22
|
const STYLE_ID = 'fyodos-orb-styles';
|
|
@@ -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;}
|
|
@@ -62,6 +63,18 @@ function ensureStyles() {
|
|
|
62
63
|
el.textContent = CSS;
|
|
63
64
|
document.head.appendChild(el);
|
|
64
65
|
}
|
|
66
|
+
// Content box of the layout viewport. `documentElement.clientWidth/Height`
|
|
67
|
+
// EXCLUDE a classic scrollbar that occupies real width/height (Windows, some
|
|
68
|
+
// Linux/Chromium); overlay scrollbars (macOS, mobile) report the same value as
|
|
69
|
+
// the layout viewport. Anchoring against this — not `innerWidth` — keeps a
|
|
70
|
+
// right/bottom-edge orb just INSIDE the scrollbar instead of under/over it.
|
|
71
|
+
function viewportContentSize() {
|
|
72
|
+
const de = typeof document !== 'undefined' ? document.documentElement : null;
|
|
73
|
+
return {
|
|
74
|
+
width: de?.clientWidth || window.innerWidth,
|
|
75
|
+
height: de?.clientHeight || window.innerHeight,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
65
78
|
function cornerToPosition(corner) {
|
|
66
79
|
switch (corner) {
|
|
67
80
|
case 'bottom-left':
|
|
@@ -158,14 +171,15 @@ export function mountOrb(controller, options = {}) {
|
|
|
158
171
|
}
|
|
159
172
|
function reposition() {
|
|
160
173
|
const size = orbDiameterPx();
|
|
161
|
-
const
|
|
174
|
+
const content = viewportContentSize();
|
|
175
|
+
const { left, top } = resolveOrbAnchorPx({
|
|
162
176
|
viewportWidth: window.innerWidth,
|
|
163
177
|
viewportHeight: window.innerHeight,
|
|
178
|
+
contentWidth: content.width,
|
|
179
|
+
contentHeight: content.height,
|
|
164
180
|
orbDiameter: size,
|
|
165
181
|
position: effectivePosition(),
|
|
166
182
|
});
|
|
167
|
-
const left = Math.max(8, window.innerWidth - insets.right - size);
|
|
168
|
-
const top = Math.max(8, window.innerHeight - insets.bottom - size);
|
|
169
183
|
root.style.left = `${left}px`;
|
|
170
184
|
root.style.top = `${top}px`;
|
|
171
185
|
root.style.right = '';
|
|
@@ -194,6 +208,14 @@ export function mountOrb(controller, options = {}) {
|
|
|
194
208
|
}
|
|
195
209
|
}
|
|
196
210
|
window.addEventListener('resize', reposition);
|
|
211
|
+
// Recalc when a scrollbar appears/disappears as the page content changes (the
|
|
212
|
+
// content box shrinks/grows even when the window size doesn't), so the orb
|
|
213
|
+
// never ends up overlapped by — or detached from — the scrollbar edge.
|
|
214
|
+
let contentObserver;
|
|
215
|
+
if (typeof ResizeObserver !== 'undefined' && document.documentElement) {
|
|
216
|
+
contentObserver = new ResizeObserver(() => reposition());
|
|
217
|
+
contentObserver.observe(document.documentElement);
|
|
218
|
+
}
|
|
197
219
|
// Keep the conversation bar above the keyboard as the visual viewport resizes
|
|
198
220
|
// (keyboard open/close) or scrolls on mobile browsers.
|
|
199
221
|
const visualViewport = window.visualViewport;
|
|
@@ -237,9 +259,10 @@ export function mountOrb(controller, options = {}) {
|
|
|
237
259
|
dragging = true;
|
|
238
260
|
e.preventDefault();
|
|
239
261
|
const size = orbDiameterPx();
|
|
262
|
+
const content = viewportContentSize();
|
|
240
263
|
const margin = 8;
|
|
241
|
-
const maxLeft =
|
|
242
|
-
const maxTop =
|
|
264
|
+
const maxLeft = content.width - margin - size;
|
|
265
|
+
const maxTop = content.height - margin - size;
|
|
243
266
|
const left = Math.min(maxLeft, Math.max(margin, originLeft + dx));
|
|
244
267
|
const top = Math.min(maxTop, Math.max(margin, originTop + dy));
|
|
245
268
|
root.style.left = `${left}px`;
|
|
@@ -249,8 +272,8 @@ export function mountOrb(controller, options = {}) {
|
|
|
249
272
|
draggedPosition = viewportPixelsToOrbScreenPosition({
|
|
250
273
|
centerX: left + size / 2,
|
|
251
274
|
centerY: top + size / 2,
|
|
252
|
-
viewportWidth:
|
|
253
|
-
viewportHeight:
|
|
275
|
+
viewportWidth: content.width,
|
|
276
|
+
viewportHeight: content.height,
|
|
254
277
|
orbDiameter: size,
|
|
255
278
|
});
|
|
256
279
|
applyDeployment();
|
|
@@ -685,6 +708,7 @@ export function mountOrb(controller, options = {}) {
|
|
|
685
708
|
unsubscribe();
|
|
686
709
|
stopWatch();
|
|
687
710
|
window.removeEventListener('resize', reposition);
|
|
711
|
+
contentObserver?.disconnect();
|
|
688
712
|
visualViewport?.removeEventListener('resize', onViewportChange);
|
|
689
713
|
visualViewport?.removeEventListener('scroll', onViewportChange);
|
|
690
714
|
clearOverlay();
|
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.5";
|
|
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.5';
|
|
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.5",
|
|
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"
|