@fiodos/web-core 0.1.5 → 0.1.9
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 +65 -19
- package/dist/cjs/controller/AgentController.d.ts +1 -0
- package/dist/cjs/controller/AgentController.js +21 -3
- package/dist/cjs/dropin/createFiodosAgent.d.ts +5 -1
- package/dist/cjs/dropin/createFiodosAgent.js +18 -1
- package/dist/cjs/orb/mountOrb.js +122 -1
- package/dist/cjs/orb/publishedConfig.d.ts +11 -1
- package/dist/cjs/orb/publishedConfig.js +6 -2
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/esm/adapters/webVoiceAdapter.js +65 -19
- package/dist/esm/controller/AgentController.d.ts +1 -0
- package/dist/esm/controller/AgentController.js +22 -4
- package/dist/esm/dropin/createFiodosAgent.d.ts +5 -1
- package/dist/esm/dropin/createFiodosAgent.js +18 -1
- package/dist/esm/orb/mountOrb.js +123 -2
- package/dist/esm/orb/publishedConfig.d.ts +11 -1
- package/dist/esm/orb/publishedConfig.js +7 -3
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +2 -2
|
@@ -401,20 +401,40 @@ function createWebVoiceAdapter(options = {}) {
|
|
|
401
401
|
// later programmatic playback (after the network round-trip) is allowed
|
|
402
402
|
// and audible even with the silent switch on.
|
|
403
403
|
const ctx = ensureAudioCtx();
|
|
404
|
-
if (
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
+
}
|
|
415
418
|
}
|
|
416
|
-
|
|
417
|
-
|
|
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
|
+
}
|
|
418
438
|
}
|
|
419
439
|
},
|
|
420
440
|
async playAudioBase64Mp3(base64) {
|
|
@@ -456,20 +476,32 @@ function createWebVoiceAdapter(options = {}) {
|
|
|
456
476
|
// Voices load lazily on Chrome — speaking before they exist is silent.
|
|
457
477
|
const voices = await loadVoicesOnce(synth);
|
|
458
478
|
return new Promise((resolve) => {
|
|
479
|
+
let keepAlive = null;
|
|
480
|
+
const cleanup = () => {
|
|
481
|
+
if (keepAlive) {
|
|
482
|
+
clearInterval(keepAlive);
|
|
483
|
+
keepAlive = null;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
459
486
|
try {
|
|
460
|
-
// Clear any
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
synth.resume();
|
|
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();
|
|
466
492
|
const utter = new SpeechSynthesisUtterance(text);
|
|
467
493
|
utter.lang = ttsOptions.locale;
|
|
468
494
|
const voice = pickVoiceForLocale(voices, ttsOptions.locale);
|
|
469
495
|
if (voice)
|
|
470
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.
|
|
471
501
|
ttsUtterance = utter;
|
|
472
502
|
const finish = () => {
|
|
503
|
+
cleanup();
|
|
504
|
+
utter.onstart = null;
|
|
473
505
|
utter.onend = null;
|
|
474
506
|
utter.onerror = null;
|
|
475
507
|
if (ttsUtterance === utter)
|
|
@@ -479,8 +511,22 @@ function createWebVoiceAdapter(options = {}) {
|
|
|
479
511
|
utter.onend = finish;
|
|
480
512
|
utter.onerror = finish;
|
|
481
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);
|
|
482
527
|
}
|
|
483
528
|
catch {
|
|
529
|
+
cleanup();
|
|
484
530
|
resolve();
|
|
485
531
|
}
|
|
486
532
|
});
|
|
@@ -54,6 +54,9 @@ class AgentController {
|
|
|
54
54
|
// Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
|
|
55
55
|
// never reports completion (e.g. a stalled SpeechSynthesis engine).
|
|
56
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;
|
|
57
60
|
this.pendingConsentIntent = null;
|
|
58
61
|
this.listeners = new Set();
|
|
59
62
|
this.unsubscribeSpeech = null;
|
|
@@ -90,7 +93,11 @@ class AgentController {
|
|
|
90
93
|
});
|
|
91
94
|
this.screenStore = (0, screenContextStore_1.createScreenContextStore)({
|
|
92
95
|
getCurrentRoute: () => config.navigation.getCurrentRoute(),
|
|
93
|
-
|
|
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)),
|
|
94
101
|
});
|
|
95
102
|
this.speech = (0, speechSession_1.createSpeechSession)({
|
|
96
103
|
adapter: config.voice,
|
|
@@ -197,13 +204,20 @@ class AgentController {
|
|
|
197
204
|
this.setPhase('speaking');
|
|
198
205
|
try {
|
|
199
206
|
let audio = audioBase64;
|
|
200
|
-
if (
|
|
201
|
-
|
|
207
|
+
if (audio) {
|
|
208
|
+
this.serverTtsUnavailable = false; // server voice is working
|
|
209
|
+
}
|
|
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) {
|
|
202
213
|
try {
|
|
203
214
|
audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
|
|
215
|
+
if (!audio)
|
|
216
|
+
this.serverTtsUnavailable = true;
|
|
204
217
|
}
|
|
205
218
|
catch {
|
|
206
219
|
audio = null;
|
|
220
|
+
this.serverTtsUnavailable = true;
|
|
207
221
|
}
|
|
208
222
|
}
|
|
209
223
|
const playback = audio
|
|
@@ -418,6 +432,10 @@ class AgentController {
|
|
|
418
432
|
}, { signal: controller.signal });
|
|
419
433
|
reply = turn.reply;
|
|
420
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;
|
|
421
439
|
const decision = await (0, turnEngine_1.decideAction)({
|
|
422
440
|
action: turn.action,
|
|
423
441
|
manifest: this.config.manifest,
|
|
@@ -27,8 +27,12 @@ export interface CreateFiodosAgentOptions {
|
|
|
27
27
|
manifest?: AppManifest;
|
|
28
28
|
/** Action handlers / context validators (defaults to empty registries). */
|
|
29
29
|
registries?: ActionRegistries;
|
|
30
|
-
/** Agent
|
|
30
|
+
/** Agent UI locale (e.g. 'en', 'es'). Defaults to `'en'`. */
|
|
31
31
|
locale?: string;
|
|
32
|
+
/** Live locale resolver — overrides `locale` when set. */
|
|
33
|
+
getLocale?: () => string;
|
|
34
|
+
/** Auto-detect locale when `locale` / `getLocale` are unset. Default: none (English). */
|
|
35
|
+
localeDetection?: 'browser' | 'document';
|
|
32
36
|
/** STT locale (e.g. 'es-ES', 'en-US'). Defaults to the browser language. */
|
|
33
37
|
sttLocale?: string;
|
|
34
38
|
/** Host router navigate delegate. Defaults to location.assign. */
|
|
@@ -32,10 +32,27 @@ function browserLocale() {
|
|
|
32
32
|
const lang = typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en-US';
|
|
33
33
|
return { locale: lang.split('-')[0] || 'en', sttLocale: lang };
|
|
34
34
|
}
|
|
35
|
+
function resolveAgentLocale(options) {
|
|
36
|
+
const fromApp = options.getLocale?.()?.trim();
|
|
37
|
+
if (fromApp)
|
|
38
|
+
return fromApp;
|
|
39
|
+
if (options.locale?.trim())
|
|
40
|
+
return options.locale.trim();
|
|
41
|
+
if (options.localeDetection === 'browser')
|
|
42
|
+
return browserLocale().locale;
|
|
43
|
+
if (options.localeDetection === 'document') {
|
|
44
|
+
const docLang = typeof document !== 'undefined'
|
|
45
|
+
? document.documentElement.lang?.split(/[-_]/)[0]?.trim()
|
|
46
|
+
: '';
|
|
47
|
+
if (docLang)
|
|
48
|
+
return docLang;
|
|
49
|
+
}
|
|
50
|
+
return 'en';
|
|
51
|
+
}
|
|
35
52
|
/** Build the controller + adapters from a known manifest (shared by both paths). */
|
|
36
53
|
function assemble(options, manifest, baseUrl) {
|
|
37
54
|
const fallback = browserLocale();
|
|
38
|
-
const locale = options
|
|
55
|
+
const locale = resolveAgentLocale(options);
|
|
39
56
|
const sttLocale = options.sttLocale ?? options.locale ?? fallback.sttLocale;
|
|
40
57
|
const backend = (0, backendClient_1.createFiodosBackendClient)({
|
|
41
58
|
baseUrl,
|
package/dist/cjs/orb/mountOrb.js
CHANGED
|
@@ -42,6 +42,8 @@ const CSS = `
|
|
|
42
42
|
.fyodos-bubble-row{display:flex;gap:8px;align-items:flex-end;padding:8px 10px;border-top:1px solid rgba(160,180,220,0.18);}
|
|
43
43
|
.fyodos-bubble-row input{flex:1;border:1px solid rgba(15,23,42,0.12);outline:none;border-radius:10px;padding:9px 11px;font-size:15px;}
|
|
44
44
|
.fyodos-bubble-row button{appearance:none;border:none;flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;min-width:32px;padding:0;font-size:17px;font-weight:700;line-height:1;color:#000;cursor:pointer;}
|
|
45
|
+
.fyodos-thought{position:absolute;appearance:none;display:inline-block;width:max-content;max-width:min(280px,70vw);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;line-height:1.25;font-weight:500;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,0.22);animation:fyodos-thought-in .18s ease-out;}
|
|
46
|
+
@keyframes fyodos-thought-in{from{opacity:0;transform:translateY(4px) scale(0.96)}to{opacity:1;transform:none}}
|
|
45
47
|
@keyframes fyodos-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
46
48
|
@keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
|
|
47
49
|
@keyframes fy-spin{to{transform:rotate(360deg)}}
|
|
@@ -130,6 +132,12 @@ function mountOrb(controller, options = {}) {
|
|
|
130
132
|
let publishedAppearance = null;
|
|
131
133
|
let chipTheme = null;
|
|
132
134
|
let modalTheme;
|
|
135
|
+
let bubbleTheme = (0, core_1.resolveBubbleTheme)(null);
|
|
136
|
+
let bubbleEnabled = true;
|
|
137
|
+
// Dashboard VOICE switch. Default true (voice on). When false the orb never
|
|
138
|
+
// listens/speaks: a tap opens the keyboard directly and text turns are silent.
|
|
139
|
+
let voiceInputEnabled = true;
|
|
140
|
+
let currentRoute = null;
|
|
133
141
|
let screenPosition = options.corner
|
|
134
142
|
? cornerToPosition(options.corner)
|
|
135
143
|
: { ...core_1.DEFAULT_ORB_SCREEN_POSITION };
|
|
@@ -160,6 +168,22 @@ function mountOrb(controller, options = {}) {
|
|
|
160
168
|
anchor.appendChild(btn);
|
|
161
169
|
root.appendChild(anchor);
|
|
162
170
|
let chipEl = null;
|
|
171
|
+
// Thought bubble: a short, screen-aware CTA beside the orb. Created once and
|
|
172
|
+
// shown/hidden by syncThoughtBubble; tapping it opens the keyboard.
|
|
173
|
+
const thoughtBubbleEl = document.createElement('button');
|
|
174
|
+
thoughtBubbleEl.type = 'button';
|
|
175
|
+
thoughtBubbleEl.className = 'fyodos-thought';
|
|
176
|
+
thoughtBubbleEl.style.display = 'none';
|
|
177
|
+
thoughtBubbleEl.addEventListener('click', () => {
|
|
178
|
+
haptics.trigger('orbTap');
|
|
179
|
+
controller.primeAudio();
|
|
180
|
+
if (enableTextInput) {
|
|
181
|
+
openTextPanel();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
void controller.toggleListening();
|
|
185
|
+
});
|
|
186
|
+
anchor.appendChild(thoughtBubbleEl);
|
|
163
187
|
container.appendChild(root);
|
|
164
188
|
// ── Position (dashboard position; user drag is SESSION-only, never persisted) ─
|
|
165
189
|
// The orb ALWAYS starts at the dashboard-published position on every load.
|
|
@@ -209,6 +233,9 @@ function mountOrb(controller, options = {}) {
|
|
|
209
233
|
chipEl.style.right = '';
|
|
210
234
|
}
|
|
211
235
|
}
|
|
236
|
+
// Keep the thought bubble on the orb's free side too while it is visible.
|
|
237
|
+
if (thoughtBubbleEl.style.display !== 'none')
|
|
238
|
+
positionThoughtBubble();
|
|
212
239
|
}
|
|
213
240
|
window.addEventListener('resize', reposition);
|
|
214
241
|
// Recalc when a scrollbar appears/disappears as the page content changes (the
|
|
@@ -459,6 +486,65 @@ function mountOrb(controller, options = {}) {
|
|
|
459
486
|
anchor.appendChild(chipEl);
|
|
460
487
|
applyDeployment();
|
|
461
488
|
}
|
|
489
|
+
// ── Thought bubble (screen-aware CTA beside the orb) ─────────────────────────
|
|
490
|
+
function resolveCurrentBubblePrompt() {
|
|
491
|
+
return (0, core_1.resolveBubblePrompt)((0, core_1.matchRouteIntent)(currentRoute, controller.config.manifest.routes), controller.messages, { locale: controller.config.locale, actions: controller.config.manifest.actions });
|
|
492
|
+
}
|
|
493
|
+
function applyThoughtBubbleTheme() {
|
|
494
|
+
const t = bubbleTheme;
|
|
495
|
+
const { fontPx, padV, padH, approxHeight } = (0, core_1.resolveBubblePadding)(t);
|
|
496
|
+
const shapeStyle = (0, core_1.resolveBubbleShapeStyle)(t, approxHeight);
|
|
497
|
+
thoughtBubbleEl.style.fontSize = `${fontPx}px`;
|
|
498
|
+
thoughtBubbleEl.style.padding = `${padV}px ${padH}px`;
|
|
499
|
+
thoughtBubbleEl.style.color = t.textColor;
|
|
500
|
+
thoughtBubbleEl.style.background = t.backgroundColor;
|
|
501
|
+
thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
|
|
502
|
+
thoughtBubbleEl.style.borderRadius =
|
|
503
|
+
shapeStyle.borderRadius != null ? `${shapeStyle.borderRadius}px` : '';
|
|
504
|
+
thoughtBubbleEl.style.borderTopLeftRadius =
|
|
505
|
+
shapeStyle.borderTopLeftRadius != null ? `${shapeStyle.borderTopLeftRadius}px` : '';
|
|
506
|
+
thoughtBubbleEl.style.borderTopRightRadius =
|
|
507
|
+
shapeStyle.borderTopRightRadius != null ? `${shapeStyle.borderTopRightRadius}px` : '';
|
|
508
|
+
thoughtBubbleEl.style.borderBottomLeftRadius =
|
|
509
|
+
shapeStyle.borderBottomLeftRadius != null ? `${shapeStyle.borderBottomLeftRadius}px` : '';
|
|
510
|
+
thoughtBubbleEl.style.borderBottomRightRadius =
|
|
511
|
+
shapeStyle.borderBottomRightRadius != null ? `${shapeStyle.borderBottomRightRadius}px` : '';
|
|
512
|
+
thoughtBubbleEl.style.clipPath = shapeStyle.clipPath ?? '';
|
|
513
|
+
}
|
|
514
|
+
function positionThoughtBubble() {
|
|
515
|
+
const dep = (0, core_1.resolveOrbDeployment)(effectivePosition());
|
|
516
|
+
if (dep.horizontal === 'left') {
|
|
517
|
+
thoughtBubbleEl.style.right = 'calc(100% + 10px)';
|
|
518
|
+
thoughtBubbleEl.style.left = '';
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
thoughtBubbleEl.style.left = 'calc(100% + 10px)';
|
|
522
|
+
thoughtBubbleEl.style.right = '';
|
|
523
|
+
}
|
|
524
|
+
if (dep.vertical === 'up') {
|
|
525
|
+
thoughtBubbleEl.style.top = '0';
|
|
526
|
+
thoughtBubbleEl.style.bottom = '';
|
|
527
|
+
thoughtBubbleEl.style.transform = '';
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
thoughtBubbleEl.style.bottom = '0';
|
|
531
|
+
thoughtBubbleEl.style.top = '';
|
|
532
|
+
thoughtBubbleEl.style.transform = '';
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function syncThoughtBubble(state) {
|
|
536
|
+
const idle = !state.showThinking && !state.showWaveform && state.phase !== 'speaking' && !micFallbackActive;
|
|
537
|
+
const prompt = bubbleEnabled && !bubbleOpen && idle ? resolveCurrentBubblePrompt() : null;
|
|
538
|
+
if (!prompt) {
|
|
539
|
+
thoughtBubbleEl.style.display = 'none';
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
thoughtBubbleEl.textContent = prompt;
|
|
543
|
+
thoughtBubbleEl.setAttribute('aria-label', ui.orbLabel);
|
|
544
|
+
applyThoughtBubbleTheme();
|
|
545
|
+
positionThoughtBubble();
|
|
546
|
+
thoughtBubbleEl.style.display = 'block';
|
|
547
|
+
}
|
|
462
548
|
// ── Overlays (confirmation + consent) ────────────────────────────────────────
|
|
463
549
|
let overlayEl = null;
|
|
464
550
|
function clearOverlay() {
|
|
@@ -571,9 +657,16 @@ function mountOrb(controller, options = {}) {
|
|
|
571
657
|
setBubble(false);
|
|
572
658
|
return;
|
|
573
659
|
}
|
|
660
|
+
// Voice disabled in the dashboard: never listen/speak. Tap opens the keyboard
|
|
661
|
+
// directly (text turns are silent by default); animations are unchanged.
|
|
662
|
+
if (!voiceInputEnabled && enableTextInput) {
|
|
663
|
+
micFallbackActive = false;
|
|
664
|
+
openTextPanel();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
574
667
|
// `voiceAvailable` is dynamic: the Web Speech API may be present yet the mic
|
|
575
668
|
// denied/blocked, in which case we skip voice and go straight to the keyboard.
|
|
576
|
-
if (controller.voiceAvailable) {
|
|
669
|
+
if (controller.voiceAvailable && voiceInputEnabled) {
|
|
577
670
|
micFallbackActive = false;
|
|
578
671
|
void controller.toggleListening();
|
|
579
672
|
return;
|
|
@@ -663,6 +756,7 @@ function mountOrb(controller, options = {}) {
|
|
|
663
756
|
sendBtn.disabled = false;
|
|
664
757
|
}
|
|
665
758
|
syncChip(state);
|
|
759
|
+
syncThoughtBubble(state);
|
|
666
760
|
if (state.confirmationMessage !== lastConfirm) {
|
|
667
761
|
lastConfirm = state.confirmationMessage;
|
|
668
762
|
if (state.confirmationMessage)
|
|
@@ -680,6 +774,26 @@ function mountOrb(controller, options = {}) {
|
|
|
680
774
|
}
|
|
681
775
|
const unsubscribe = controller.subscribe(render);
|
|
682
776
|
render(controller.getState());
|
|
777
|
+
// Track the current route so the thought bubble re-resolves its CTA on
|
|
778
|
+
// navigation. No universal navigation event exists, so poll + listen to
|
|
779
|
+
// popstate / visibility for snappier updates.
|
|
780
|
+
const readRoute = () => {
|
|
781
|
+
let next = null;
|
|
782
|
+
try {
|
|
783
|
+
next = controller.config.navigation.getCurrentRoute();
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
/* navigation read is best-effort */
|
|
787
|
+
}
|
|
788
|
+
if (next !== currentRoute) {
|
|
789
|
+
currentRoute = next;
|
|
790
|
+
syncThoughtBubble(controller.getState());
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
readRoute();
|
|
794
|
+
const routeTimer = window.setInterval(readRoute, 700);
|
|
795
|
+
window.addEventListener('popstate', readRoute);
|
|
796
|
+
document.addEventListener('visibilitychange', readRoute);
|
|
683
797
|
// ── Live dashboard config ────────────────────────────────────────────────────
|
|
684
798
|
const stopWatch = (0, publishedConfig_1.watchPublishedConfig)(controller, {
|
|
685
799
|
pollMs: options.appearancePollMs,
|
|
@@ -692,6 +806,9 @@ function mountOrb(controller, options = {}) {
|
|
|
692
806
|
publishedAppearance = config.appearance;
|
|
693
807
|
chipTheme = config.keyboardChip;
|
|
694
808
|
modalTheme = config.modalTheme;
|
|
809
|
+
bubbleTheme = config.bubbleTheme;
|
|
810
|
+
bubbleEnabled = config.bubbleEnabled;
|
|
811
|
+
voiceInputEnabled = config.voiceInputEnabled;
|
|
695
812
|
if (usePublishedPosition && config.screenPosition)
|
|
696
813
|
screenPosition = config.screenPosition;
|
|
697
814
|
}
|
|
@@ -702,6 +819,7 @@ function mountOrb(controller, options = {}) {
|
|
|
702
819
|
chipEl = null;
|
|
703
820
|
}
|
|
704
821
|
syncChip(controller.getState());
|
|
822
|
+
syncThoughtBubble(controller.getState());
|
|
705
823
|
if (bubbleOpen)
|
|
706
824
|
applyBubbleTheme();
|
|
707
825
|
},
|
|
@@ -710,6 +828,9 @@ function mountOrb(controller, options = {}) {
|
|
|
710
828
|
unmount() {
|
|
711
829
|
unsubscribe();
|
|
712
830
|
stopWatch();
|
|
831
|
+
window.clearInterval(routeTimer);
|
|
832
|
+
window.removeEventListener('popstate', readRoute);
|
|
833
|
+
document.removeEventListener('visibilitychange', readRoute);
|
|
713
834
|
window.removeEventListener('resize', reposition);
|
|
714
835
|
contentObserver?.disconnect();
|
|
715
836
|
visualViewport?.removeEventListener('resize', onViewportChange);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* mobile — in real time, via polling. So Vue/Svelte/Angular read the dashboard
|
|
10
10
|
* config identically to the reference, instead of ignoring it.
|
|
11
11
|
*/
|
|
12
|
-
import { type OrbScreenPosition, type PublishedModalTheme } from '@fiodos/core';
|
|
12
|
+
import { type OrbScreenPosition, type PublishedModalTheme, type ResolvedBubbleTheme } from '@fiodos/core';
|
|
13
13
|
import type { AgentController } from '../controller/AgentController';
|
|
14
14
|
import { type ChipTheme, type OrbAppearance } from './orbView';
|
|
15
15
|
export interface ResolvedOrbConfig {
|
|
@@ -17,6 +17,16 @@ export interface ResolvedOrbConfig {
|
|
|
17
17
|
keyboardChip: ChipTheme | null;
|
|
18
18
|
modalTheme: PublishedModalTheme | undefined;
|
|
19
19
|
screenPosition: OrbScreenPosition | undefined;
|
|
20
|
+
/** Resolved orb thought-bubble theme (defaults filled). */
|
|
21
|
+
bubbleTheme: ResolvedBubbleTheme;
|
|
22
|
+
/** Dashboard on/off for the thought bubble. Default true (absent = enabled). */
|
|
23
|
+
bubbleEnabled: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Dashboard on/off for the orb's VOICE channel (`voiceInputEnabled`). False
|
|
26
|
+
* ONLY when the developer turned voice off; then the orb opens the keyboard
|
|
27
|
+
* directly on tap and never plays TTS. Default true (absent = voice on).
|
|
28
|
+
*/
|
|
29
|
+
voiceInputEnabled: boolean;
|
|
20
30
|
/**
|
|
21
31
|
* Dashboard master visibility switch (`orbEnabled`). False ONLY when the
|
|
22
32
|
* developer explicitly turned the orb off. The orb is hidden when false and
|
|
@@ -14,7 +14,8 @@ exports.watchPublishedConfig = watchPublishedConfig;
|
|
|
14
14
|
*/
|
|
15
15
|
const core_1 = require("@fiodos/core");
|
|
16
16
|
const orbView_1 = require("./orbView");
|
|
17
|
-
function toResolved(props,
|
|
17
|
+
function toResolved(props, published) {
|
|
18
|
+
const enabled = published.orbEnabled !== false;
|
|
18
19
|
const theme = props.theme;
|
|
19
20
|
const appearance = {
|
|
20
21
|
accentColor: theme.accentColor ?? orbView_1.DEFAULT_ORB_APPEARANCE.accentColor,
|
|
@@ -33,6 +34,9 @@ function toResolved(props, enabled) {
|
|
|
33
34
|
keyboardChip: theme.keyboardChip ?? null,
|
|
34
35
|
modalTheme: props.modalTheme,
|
|
35
36
|
screenPosition: props.screenPosition,
|
|
37
|
+
bubbleTheme: (0, core_1.resolveBubbleTheme)(published.bubbleTheme),
|
|
38
|
+
bubbleEnabled: published.bubbleEnabled !== false,
|
|
39
|
+
voiceInputEnabled: published.voiceInputEnabled !== false,
|
|
36
40
|
enabled,
|
|
37
41
|
};
|
|
38
42
|
}
|
|
@@ -54,7 +58,7 @@ function watchPublishedConfig(controller, options) {
|
|
|
54
58
|
if (cancelled)
|
|
55
59
|
return;
|
|
56
60
|
options.onChange(res.published
|
|
57
|
-
? toResolved((0, core_1.mapPublishedConfigToOrbProps)(res.published), res.published
|
|
61
|
+
? toResolved((0, core_1.mapPublishedConfigToOrbProps)(res.published), res.published)
|
|
58
62
|
: null);
|
|
59
63
|
}
|
|
60
64
|
catch {
|
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.9";
|
|
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.9';
|
|
12
12
|
exports.SDK_PACKAGE = '@fiodos/web-core';
|
|
@@ -398,20 +398,40 @@ export function createWebVoiceAdapter(options = {}) {
|
|
|
398
398
|
// later programmatic playback (after the network round-trip) is allowed
|
|
399
399
|
// and audible even with the silent switch on.
|
|
400
400
|
const ctx = ensureAudioCtx();
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
+
}
|
|
412
415
|
}
|
|
413
|
-
|
|
414
|
-
|
|
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
|
+
}
|
|
415
435
|
}
|
|
416
436
|
},
|
|
417
437
|
async playAudioBase64Mp3(base64) {
|
|
@@ -453,20 +473,32 @@ export function createWebVoiceAdapter(options = {}) {
|
|
|
453
473
|
// Voices load lazily on Chrome — speaking before they exist is silent.
|
|
454
474
|
const voices = await loadVoicesOnce(synth);
|
|
455
475
|
return new Promise((resolve) => {
|
|
476
|
+
let keepAlive = null;
|
|
477
|
+
const cleanup = () => {
|
|
478
|
+
if (keepAlive) {
|
|
479
|
+
clearInterval(keepAlive);
|
|
480
|
+
keepAlive = null;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
456
483
|
try {
|
|
457
|
-
// Clear any
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
synth.resume();
|
|
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();
|
|
463
489
|
const utter = new SpeechSynthesisUtterance(text);
|
|
464
490
|
utter.lang = ttsOptions.locale;
|
|
465
491
|
const voice = pickVoiceForLocale(voices, ttsOptions.locale);
|
|
466
492
|
if (voice)
|
|
467
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.
|
|
468
498
|
ttsUtterance = utter;
|
|
469
499
|
const finish = () => {
|
|
500
|
+
cleanup();
|
|
501
|
+
utter.onstart = null;
|
|
470
502
|
utter.onend = null;
|
|
471
503
|
utter.onerror = null;
|
|
472
504
|
if (ttsUtterance === utter)
|
|
@@ -476,8 +508,22 @@ export function createWebVoiceAdapter(options = {}) {
|
|
|
476
508
|
utter.onend = finish;
|
|
477
509
|
utter.onerror = finish;
|
|
478
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);
|
|
479
524
|
}
|
|
480
525
|
catch {
|
|
526
|
+
cleanup();
|
|
481
527
|
resolve();
|
|
482
528
|
}
|
|
483
529
|
});
|
|
@@ -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';
|
|
@@ -51,6 +51,9 @@ export class AgentController {
|
|
|
51
51
|
// Safety cap so the orb can never get stuck in 'speaking' if playback/TTS
|
|
52
52
|
// never reports completion (e.g. a stalled SpeechSynthesis engine).
|
|
53
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;
|
|
54
57
|
this.pendingConsentIntent = null;
|
|
55
58
|
this.listeners = new Set();
|
|
56
59
|
this.unsubscribeSpeech = null;
|
|
@@ -87,7 +90,11 @@ export class AgentController {
|
|
|
87
90
|
});
|
|
88
91
|
this.screenStore = createScreenContextStore({
|
|
89
92
|
getCurrentRoute: () => config.navigation.getCurrentRoute(),
|
|
90
|
-
|
|
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)),
|
|
91
98
|
});
|
|
92
99
|
this.speech = createSpeechSession({
|
|
93
100
|
adapter: config.voice,
|
|
@@ -194,13 +201,20 @@ export class AgentController {
|
|
|
194
201
|
this.setPhase('speaking');
|
|
195
202
|
try {
|
|
196
203
|
let audio = audioBase64;
|
|
197
|
-
if (
|
|
198
|
-
|
|
204
|
+
if (audio) {
|
|
205
|
+
this.serverTtsUnavailable = false; // server voice is working
|
|
206
|
+
}
|
|
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) {
|
|
199
210
|
try {
|
|
200
211
|
audio = await this.config.backend.previewTts(this.getTtsVoice() ?? '', text);
|
|
212
|
+
if (!audio)
|
|
213
|
+
this.serverTtsUnavailable = true;
|
|
201
214
|
}
|
|
202
215
|
catch {
|
|
203
216
|
audio = null;
|
|
217
|
+
this.serverTtsUnavailable = true;
|
|
204
218
|
}
|
|
205
219
|
}
|
|
206
220
|
const playback = audio
|
|
@@ -415,6 +429,10 @@ export class AgentController {
|
|
|
415
429
|
}, { signal: controller.signal });
|
|
416
430
|
reply = turn.reply;
|
|
417
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;
|
|
418
436
|
const decision = await decideAction({
|
|
419
437
|
action: turn.action,
|
|
420
438
|
manifest: this.config.manifest,
|
|
@@ -27,8 +27,12 @@ export interface CreateFiodosAgentOptions {
|
|
|
27
27
|
manifest?: AppManifest;
|
|
28
28
|
/** Action handlers / context validators (defaults to empty registries). */
|
|
29
29
|
registries?: ActionRegistries;
|
|
30
|
-
/** Agent
|
|
30
|
+
/** Agent UI locale (e.g. 'en', 'es'). Defaults to `'en'`. */
|
|
31
31
|
locale?: string;
|
|
32
|
+
/** Live locale resolver — overrides `locale` when set. */
|
|
33
|
+
getLocale?: () => string;
|
|
34
|
+
/** Auto-detect locale when `locale` / `getLocale` are unset. Default: none (English). */
|
|
35
|
+
localeDetection?: 'browser' | 'document';
|
|
32
36
|
/** STT locale (e.g. 'es-ES', 'en-US'). Defaults to the browser language. */
|
|
33
37
|
sttLocale?: string;
|
|
34
38
|
/** Host router navigate delegate. Defaults to location.assign. */
|
|
@@ -29,10 +29,27 @@ function browserLocale() {
|
|
|
29
29
|
const lang = typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en-US';
|
|
30
30
|
return { locale: lang.split('-')[0] || 'en', sttLocale: lang };
|
|
31
31
|
}
|
|
32
|
+
function resolveAgentLocale(options) {
|
|
33
|
+
const fromApp = options.getLocale?.()?.trim();
|
|
34
|
+
if (fromApp)
|
|
35
|
+
return fromApp;
|
|
36
|
+
if (options.locale?.trim())
|
|
37
|
+
return options.locale.trim();
|
|
38
|
+
if (options.localeDetection === 'browser')
|
|
39
|
+
return browserLocale().locale;
|
|
40
|
+
if (options.localeDetection === 'document') {
|
|
41
|
+
const docLang = typeof document !== 'undefined'
|
|
42
|
+
? document.documentElement.lang?.split(/[-_]/)[0]?.trim()
|
|
43
|
+
: '';
|
|
44
|
+
if (docLang)
|
|
45
|
+
return docLang;
|
|
46
|
+
}
|
|
47
|
+
return 'en';
|
|
48
|
+
}
|
|
32
49
|
/** Build the controller + adapters from a known manifest (shared by both paths). */
|
|
33
50
|
function assemble(options, manifest, baseUrl) {
|
|
34
51
|
const fallback = browserLocale();
|
|
35
|
-
const locale = options
|
|
52
|
+
const locale = resolveAgentLocale(options);
|
|
36
53
|
const sttLocale = options.sttLocale ?? options.locale ?? fallback.sttLocale;
|
|
37
54
|
const backend = createFiodosBackendClient({
|
|
38
55
|
baseUrl,
|
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, resolveOrbAnchorPx, resolveOrbDeployment, snapOrbScreenPositionToSide, viewportPixelsToOrbScreenPosition, } from '@fiodos/core';
|
|
19
|
+
import { DEFAULT_ORB_SCREEN_POSITION, createWebOrbHaptics, matchRouteIntent, resolveBubblePrompt, resolveBubbleTheme, resolveBubblePadding, resolveBubbleShapeStyle, 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';
|
|
@@ -39,6 +39,8 @@ const CSS = `
|
|
|
39
39
|
.fyodos-bubble-row{display:flex;gap:8px;align-items:flex-end;padding:8px 10px;border-top:1px solid rgba(160,180,220,0.18);}
|
|
40
40
|
.fyodos-bubble-row input{flex:1;border:1px solid rgba(15,23,42,0.12);outline:none;border-radius:10px;padding:9px 11px;font-size:15px;}
|
|
41
41
|
.fyodos-bubble-row button{appearance:none;border:none;flex-shrink:0;display:flex;align-items:center;justify-content:center;width:32px;height:32px;min-width:32px;padding:0;font-size:17px;font-weight:700;line-height:1;color:#000;cursor:pointer;}
|
|
42
|
+
.fyodos-thought{position:absolute;appearance:none;display:inline-block;width:max-content;max-width:min(280px,70vw);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:left;line-height:1.25;font-weight:500;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,0.22);animation:fyodos-thought-in .18s ease-out;}
|
|
43
|
+
@keyframes fyodos-thought-in{from{opacity:0;transform:translateY(4px) scale(0.96)}to{opacity:1;transform:none}}
|
|
42
44
|
@keyframes fyodos-fade-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
|
43
45
|
@keyframes fyodos-blink{0%,100%{opacity:.25}50%{opacity:.9}}
|
|
44
46
|
@keyframes fy-spin{to{transform:rotate(360deg)}}
|
|
@@ -127,6 +129,12 @@ export function mountOrb(controller, options = {}) {
|
|
|
127
129
|
let publishedAppearance = null;
|
|
128
130
|
let chipTheme = null;
|
|
129
131
|
let modalTheme;
|
|
132
|
+
let bubbleTheme = resolveBubbleTheme(null);
|
|
133
|
+
let bubbleEnabled = true;
|
|
134
|
+
// Dashboard VOICE switch. Default true (voice on). When false the orb never
|
|
135
|
+
// listens/speaks: a tap opens the keyboard directly and text turns are silent.
|
|
136
|
+
let voiceInputEnabled = true;
|
|
137
|
+
let currentRoute = null;
|
|
130
138
|
let screenPosition = options.corner
|
|
131
139
|
? cornerToPosition(options.corner)
|
|
132
140
|
: { ...DEFAULT_ORB_SCREEN_POSITION };
|
|
@@ -157,6 +165,22 @@ export function mountOrb(controller, options = {}) {
|
|
|
157
165
|
anchor.appendChild(btn);
|
|
158
166
|
root.appendChild(anchor);
|
|
159
167
|
let chipEl = null;
|
|
168
|
+
// Thought bubble: a short, screen-aware CTA beside the orb. Created once and
|
|
169
|
+
// shown/hidden by syncThoughtBubble; tapping it opens the keyboard.
|
|
170
|
+
const thoughtBubbleEl = document.createElement('button');
|
|
171
|
+
thoughtBubbleEl.type = 'button';
|
|
172
|
+
thoughtBubbleEl.className = 'fyodos-thought';
|
|
173
|
+
thoughtBubbleEl.style.display = 'none';
|
|
174
|
+
thoughtBubbleEl.addEventListener('click', () => {
|
|
175
|
+
haptics.trigger('orbTap');
|
|
176
|
+
controller.primeAudio();
|
|
177
|
+
if (enableTextInput) {
|
|
178
|
+
openTextPanel();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
void controller.toggleListening();
|
|
182
|
+
});
|
|
183
|
+
anchor.appendChild(thoughtBubbleEl);
|
|
160
184
|
container.appendChild(root);
|
|
161
185
|
// ── Position (dashboard position; user drag is SESSION-only, never persisted) ─
|
|
162
186
|
// The orb ALWAYS starts at the dashboard-published position on every load.
|
|
@@ -206,6 +230,9 @@ export function mountOrb(controller, options = {}) {
|
|
|
206
230
|
chipEl.style.right = '';
|
|
207
231
|
}
|
|
208
232
|
}
|
|
233
|
+
// Keep the thought bubble on the orb's free side too while it is visible.
|
|
234
|
+
if (thoughtBubbleEl.style.display !== 'none')
|
|
235
|
+
positionThoughtBubble();
|
|
209
236
|
}
|
|
210
237
|
window.addEventListener('resize', reposition);
|
|
211
238
|
// Recalc when a scrollbar appears/disappears as the page content changes (the
|
|
@@ -456,6 +483,65 @@ export function mountOrb(controller, options = {}) {
|
|
|
456
483
|
anchor.appendChild(chipEl);
|
|
457
484
|
applyDeployment();
|
|
458
485
|
}
|
|
486
|
+
// ── Thought bubble (screen-aware CTA beside the orb) ─────────────────────────
|
|
487
|
+
function resolveCurrentBubblePrompt() {
|
|
488
|
+
return resolveBubblePrompt(matchRouteIntent(currentRoute, controller.config.manifest.routes), controller.messages, { locale: controller.config.locale, actions: controller.config.manifest.actions });
|
|
489
|
+
}
|
|
490
|
+
function applyThoughtBubbleTheme() {
|
|
491
|
+
const t = bubbleTheme;
|
|
492
|
+
const { fontPx, padV, padH, approxHeight } = resolveBubblePadding(t);
|
|
493
|
+
const shapeStyle = resolveBubbleShapeStyle(t, approxHeight);
|
|
494
|
+
thoughtBubbleEl.style.fontSize = `${fontPx}px`;
|
|
495
|
+
thoughtBubbleEl.style.padding = `${padV}px ${padH}px`;
|
|
496
|
+
thoughtBubbleEl.style.color = t.textColor;
|
|
497
|
+
thoughtBubbleEl.style.background = t.backgroundColor;
|
|
498
|
+
thoughtBubbleEl.style.border = `${t.borderWidth}px solid ${t.borderColor}`;
|
|
499
|
+
thoughtBubbleEl.style.borderRadius =
|
|
500
|
+
shapeStyle.borderRadius != null ? `${shapeStyle.borderRadius}px` : '';
|
|
501
|
+
thoughtBubbleEl.style.borderTopLeftRadius =
|
|
502
|
+
shapeStyle.borderTopLeftRadius != null ? `${shapeStyle.borderTopLeftRadius}px` : '';
|
|
503
|
+
thoughtBubbleEl.style.borderTopRightRadius =
|
|
504
|
+
shapeStyle.borderTopRightRadius != null ? `${shapeStyle.borderTopRightRadius}px` : '';
|
|
505
|
+
thoughtBubbleEl.style.borderBottomLeftRadius =
|
|
506
|
+
shapeStyle.borderBottomLeftRadius != null ? `${shapeStyle.borderBottomLeftRadius}px` : '';
|
|
507
|
+
thoughtBubbleEl.style.borderBottomRightRadius =
|
|
508
|
+
shapeStyle.borderBottomRightRadius != null ? `${shapeStyle.borderBottomRightRadius}px` : '';
|
|
509
|
+
thoughtBubbleEl.style.clipPath = shapeStyle.clipPath ?? '';
|
|
510
|
+
}
|
|
511
|
+
function positionThoughtBubble() {
|
|
512
|
+
const dep = resolveOrbDeployment(effectivePosition());
|
|
513
|
+
if (dep.horizontal === 'left') {
|
|
514
|
+
thoughtBubbleEl.style.right = 'calc(100% + 10px)';
|
|
515
|
+
thoughtBubbleEl.style.left = '';
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
thoughtBubbleEl.style.left = 'calc(100% + 10px)';
|
|
519
|
+
thoughtBubbleEl.style.right = '';
|
|
520
|
+
}
|
|
521
|
+
if (dep.vertical === 'up') {
|
|
522
|
+
thoughtBubbleEl.style.top = '0';
|
|
523
|
+
thoughtBubbleEl.style.bottom = '';
|
|
524
|
+
thoughtBubbleEl.style.transform = '';
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
thoughtBubbleEl.style.bottom = '0';
|
|
528
|
+
thoughtBubbleEl.style.top = '';
|
|
529
|
+
thoughtBubbleEl.style.transform = '';
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function syncThoughtBubble(state) {
|
|
533
|
+
const idle = !state.showThinking && !state.showWaveform && state.phase !== 'speaking' && !micFallbackActive;
|
|
534
|
+
const prompt = bubbleEnabled && !bubbleOpen && idle ? resolveCurrentBubblePrompt() : null;
|
|
535
|
+
if (!prompt) {
|
|
536
|
+
thoughtBubbleEl.style.display = 'none';
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
thoughtBubbleEl.textContent = prompt;
|
|
540
|
+
thoughtBubbleEl.setAttribute('aria-label', ui.orbLabel);
|
|
541
|
+
applyThoughtBubbleTheme();
|
|
542
|
+
positionThoughtBubble();
|
|
543
|
+
thoughtBubbleEl.style.display = 'block';
|
|
544
|
+
}
|
|
459
545
|
// ── Overlays (confirmation + consent) ────────────────────────────────────────
|
|
460
546
|
let overlayEl = null;
|
|
461
547
|
function clearOverlay() {
|
|
@@ -568,9 +654,16 @@ export function mountOrb(controller, options = {}) {
|
|
|
568
654
|
setBubble(false);
|
|
569
655
|
return;
|
|
570
656
|
}
|
|
657
|
+
// Voice disabled in the dashboard: never listen/speak. Tap opens the keyboard
|
|
658
|
+
// directly (text turns are silent by default); animations are unchanged.
|
|
659
|
+
if (!voiceInputEnabled && enableTextInput) {
|
|
660
|
+
micFallbackActive = false;
|
|
661
|
+
openTextPanel();
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
571
664
|
// `voiceAvailable` is dynamic: the Web Speech API may be present yet the mic
|
|
572
665
|
// denied/blocked, in which case we skip voice and go straight to the keyboard.
|
|
573
|
-
if (controller.voiceAvailable) {
|
|
666
|
+
if (controller.voiceAvailable && voiceInputEnabled) {
|
|
574
667
|
micFallbackActive = false;
|
|
575
668
|
void controller.toggleListening();
|
|
576
669
|
return;
|
|
@@ -660,6 +753,7 @@ export function mountOrb(controller, options = {}) {
|
|
|
660
753
|
sendBtn.disabled = false;
|
|
661
754
|
}
|
|
662
755
|
syncChip(state);
|
|
756
|
+
syncThoughtBubble(state);
|
|
663
757
|
if (state.confirmationMessage !== lastConfirm) {
|
|
664
758
|
lastConfirm = state.confirmationMessage;
|
|
665
759
|
if (state.confirmationMessage)
|
|
@@ -677,6 +771,26 @@ export function mountOrb(controller, options = {}) {
|
|
|
677
771
|
}
|
|
678
772
|
const unsubscribe = controller.subscribe(render);
|
|
679
773
|
render(controller.getState());
|
|
774
|
+
// Track the current route so the thought bubble re-resolves its CTA on
|
|
775
|
+
// navigation. No universal navigation event exists, so poll + listen to
|
|
776
|
+
// popstate / visibility for snappier updates.
|
|
777
|
+
const readRoute = () => {
|
|
778
|
+
let next = null;
|
|
779
|
+
try {
|
|
780
|
+
next = controller.config.navigation.getCurrentRoute();
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
/* navigation read is best-effort */
|
|
784
|
+
}
|
|
785
|
+
if (next !== currentRoute) {
|
|
786
|
+
currentRoute = next;
|
|
787
|
+
syncThoughtBubble(controller.getState());
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
readRoute();
|
|
791
|
+
const routeTimer = window.setInterval(readRoute, 700);
|
|
792
|
+
window.addEventListener('popstate', readRoute);
|
|
793
|
+
document.addEventListener('visibilitychange', readRoute);
|
|
680
794
|
// ── Live dashboard config ────────────────────────────────────────────────────
|
|
681
795
|
const stopWatch = watchPublishedConfig(controller, {
|
|
682
796
|
pollMs: options.appearancePollMs,
|
|
@@ -689,6 +803,9 @@ export function mountOrb(controller, options = {}) {
|
|
|
689
803
|
publishedAppearance = config.appearance;
|
|
690
804
|
chipTheme = config.keyboardChip;
|
|
691
805
|
modalTheme = config.modalTheme;
|
|
806
|
+
bubbleTheme = config.bubbleTheme;
|
|
807
|
+
bubbleEnabled = config.bubbleEnabled;
|
|
808
|
+
voiceInputEnabled = config.voiceInputEnabled;
|
|
692
809
|
if (usePublishedPosition && config.screenPosition)
|
|
693
810
|
screenPosition = config.screenPosition;
|
|
694
811
|
}
|
|
@@ -699,6 +816,7 @@ export function mountOrb(controller, options = {}) {
|
|
|
699
816
|
chipEl = null;
|
|
700
817
|
}
|
|
701
818
|
syncChip(controller.getState());
|
|
819
|
+
syncThoughtBubble(controller.getState());
|
|
702
820
|
if (bubbleOpen)
|
|
703
821
|
applyBubbleTheme();
|
|
704
822
|
},
|
|
@@ -707,6 +825,9 @@ export function mountOrb(controller, options = {}) {
|
|
|
707
825
|
unmount() {
|
|
708
826
|
unsubscribe();
|
|
709
827
|
stopWatch();
|
|
828
|
+
window.clearInterval(routeTimer);
|
|
829
|
+
window.removeEventListener('popstate', readRoute);
|
|
830
|
+
document.removeEventListener('visibilitychange', readRoute);
|
|
710
831
|
window.removeEventListener('resize', reposition);
|
|
711
832
|
contentObserver?.disconnect();
|
|
712
833
|
visualViewport?.removeEventListener('resize', onViewportChange);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* mobile — in real time, via polling. So Vue/Svelte/Angular read the dashboard
|
|
10
10
|
* config identically to the reference, instead of ignoring it.
|
|
11
11
|
*/
|
|
12
|
-
import { type OrbScreenPosition, type PublishedModalTheme } from '@fiodos/core';
|
|
12
|
+
import { type OrbScreenPosition, type PublishedModalTheme, type ResolvedBubbleTheme } from '@fiodos/core';
|
|
13
13
|
import type { AgentController } from '../controller/AgentController.js';
|
|
14
14
|
import { type ChipTheme, type OrbAppearance } from './orbView.js';
|
|
15
15
|
export interface ResolvedOrbConfig {
|
|
@@ -17,6 +17,16 @@ export interface ResolvedOrbConfig {
|
|
|
17
17
|
keyboardChip: ChipTheme | null;
|
|
18
18
|
modalTheme: PublishedModalTheme | undefined;
|
|
19
19
|
screenPosition: OrbScreenPosition | undefined;
|
|
20
|
+
/** Resolved orb thought-bubble theme (defaults filled). */
|
|
21
|
+
bubbleTheme: ResolvedBubbleTheme;
|
|
22
|
+
/** Dashboard on/off for the thought bubble. Default true (absent = enabled). */
|
|
23
|
+
bubbleEnabled: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Dashboard on/off for the orb's VOICE channel (`voiceInputEnabled`). False
|
|
26
|
+
* ONLY when the developer turned voice off; then the orb opens the keyboard
|
|
27
|
+
* directly on tap and never plays TTS. Default true (absent = voice on).
|
|
28
|
+
*/
|
|
29
|
+
voiceInputEnabled: boolean;
|
|
20
30
|
/**
|
|
21
31
|
* Dashboard master visibility switch (`orbEnabled`). False ONLY when the
|
|
22
32
|
* developer explicitly turned the orb off. The orb is hidden when false and
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* mobile — in real time, via polling. So Vue/Svelte/Angular read the dashboard
|
|
10
10
|
* config identically to the reference, instead of ignoring it.
|
|
11
11
|
*/
|
|
12
|
-
import { mapPublishedConfigToOrbProps, } from '@fiodos/core';
|
|
12
|
+
import { mapPublishedConfigToOrbProps, resolveBubbleTheme, } from '@fiodos/core';
|
|
13
13
|
import { DEFAULT_ORB_APPEARANCE } from './orbView.js';
|
|
14
|
-
function toResolved(props,
|
|
14
|
+
function toResolved(props, published) {
|
|
15
|
+
const enabled = published.orbEnabled !== false;
|
|
15
16
|
const theme = props.theme;
|
|
16
17
|
const appearance = {
|
|
17
18
|
accentColor: theme.accentColor ?? DEFAULT_ORB_APPEARANCE.accentColor,
|
|
@@ -30,6 +31,9 @@ function toResolved(props, enabled) {
|
|
|
30
31
|
keyboardChip: theme.keyboardChip ?? null,
|
|
31
32
|
modalTheme: props.modalTheme,
|
|
32
33
|
screenPosition: props.screenPosition,
|
|
34
|
+
bubbleTheme: resolveBubbleTheme(published.bubbleTheme),
|
|
35
|
+
bubbleEnabled: published.bubbleEnabled !== false,
|
|
36
|
+
voiceInputEnabled: published.voiceInputEnabled !== false,
|
|
33
37
|
enabled,
|
|
34
38
|
};
|
|
35
39
|
}
|
|
@@ -51,7 +55,7 @@ export function watchPublishedConfig(controller, options) {
|
|
|
51
55
|
if (cancelled)
|
|
52
56
|
return;
|
|
53
57
|
options.onChange(res.published
|
|
54
|
-
? toResolved(mapPublishedConfigToOrbProps(res.published), res.published
|
|
58
|
+
? toResolved(mapPublishedConfigToOrbProps(res.published), res.published)
|
|
55
59
|
: null);
|
|
56
60
|
}
|
|
57
61
|
catch {
|
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.9";
|
|
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.9';
|
|
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.9",
|
|
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": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"prepublishOnly": "node ../../scripts/sync-sdk-version.mjs && npm run build"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@fiodos/core": "^0.1.
|
|
32
|
+
"@fiodos/core": "^0.1.7"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^22.0.0",
|