@fiodos/web-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +77 -0
- package/dist/cjs/adapters/webNavigationAdapter.d.ts +31 -0
- package/dist/cjs/adapters/webNavigationAdapter.js +45 -0
- package/dist/cjs/adapters/webStorageAdapter.d.ts +13 -0
- package/dist/cjs/adapters/webStorageAdapter.js +57 -0
- package/dist/cjs/adapters/webVoiceAdapter.d.ts +16 -0
- package/dist/cjs/adapters/webVoiceAdapter.js +477 -0
- package/dist/cjs/api/backendClient.d.ts +31 -0
- package/dist/cjs/api/backendClient.js +182 -0
- package/dist/cjs/api/backendTelemetry.d.ts +23 -0
- package/dist/cjs/api/backendTelemetry.js +80 -0
- package/dist/cjs/api/clientBootstrap.d.ts +44 -0
- package/dist/cjs/api/clientBootstrap.js +68 -0
- package/dist/cjs/api/errors.d.ts +11 -0
- package/dist/cjs/api/errors.js +16 -0
- package/dist/cjs/bridge/createBridge.d.ts +13 -0
- package/dist/cjs/bridge/createBridge.js +15 -0
- package/dist/cjs/config/types.d.ts +63 -0
- package/dist/cjs/config/types.js +9 -0
- package/dist/cjs/context/screenContextStore.d.ts +26 -0
- package/dist/cjs/context/screenContextStore.js +25 -0
- package/dist/cjs/controller/AgentController.d.ts +115 -0
- package/dist/cjs/controller/AgentController.js +594 -0
- package/dist/cjs/core/turnEngine.d.ts +70 -0
- package/dist/cjs/core/turnEngine.js +96 -0
- package/dist/cjs/dropin/createFiodosAgent.d.ts +60 -0
- package/dist/cjs/dropin/createFiodosAgent.js +143 -0
- package/dist/cjs/dropin/createFyodosAgent.d.ts +60 -0
- package/dist/cjs/dropin/createFyodosAgent.js +143 -0
- package/dist/cjs/index.d.ts +45 -0
- package/dist/cjs/index.js +67 -0
- package/dist/cjs/orb/mountOrb.d.ts +24 -0
- package/dist/cjs/orb/mountOrb.js +694 -0
- package/dist/cjs/orb/orbView.d.ts +56 -0
- package/dist/cjs/orb/orbView.js +426 -0
- package/dist/cjs/orb/publishedConfig.d.ts +32 -0
- package/dist/cjs/orb/publishedConfig.js +70 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/speech/speechSession.d.ts +35 -0
- package/dist/cjs/speech/speechSession.js +60 -0
- package/dist/cjs/ui/messages.d.ts +36 -0
- package/dist/cjs/ui/messages.js +41 -0
- package/dist/esm/adapters/webNavigationAdapter.d.ts +31 -0
- package/dist/esm/adapters/webNavigationAdapter.js +41 -0
- package/dist/esm/adapters/webStorageAdapter.d.ts +13 -0
- package/dist/esm/adapters/webStorageAdapter.js +54 -0
- package/dist/esm/adapters/webVoiceAdapter.d.ts +16 -0
- package/dist/esm/adapters/webVoiceAdapter.js +474 -0
- package/dist/esm/api/backendClient.d.ts +31 -0
- package/dist/esm/api/backendClient.js +178 -0
- package/dist/esm/api/backendTelemetry.d.ts +23 -0
- package/dist/esm/api/backendTelemetry.js +77 -0
- package/dist/esm/api/clientBootstrap.d.ts +44 -0
- package/dist/esm/api/clientBootstrap.js +63 -0
- package/dist/esm/api/errors.d.ts +11 -0
- package/dist/esm/api/errors.js +11 -0
- package/dist/esm/bridge/createBridge.d.ts +13 -0
- package/dist/esm/bridge/createBridge.js +12 -0
- package/dist/esm/config/types.d.ts +63 -0
- package/dist/esm/config/types.js +6 -0
- package/dist/esm/context/screenContextStore.d.ts +26 -0
- package/dist/esm/context/screenContextStore.js +22 -0
- package/dist/esm/controller/AgentController.d.ts +115 -0
- package/dist/esm/controller/AgentController.js +590 -0
- package/dist/esm/core/turnEngine.d.ts +70 -0
- package/dist/esm/core/turnEngine.js +92 -0
- package/dist/esm/dropin/createFiodosAgent.d.ts +60 -0
- package/dist/esm/dropin/createFiodosAgent.js +140 -0
- package/dist/esm/dropin/createFyodosAgent.d.ts +60 -0
- package/dist/esm/dropin/createFyodosAgent.js +140 -0
- package/dist/esm/index.d.ts +45 -0
- package/dist/esm/index.js +38 -0
- package/dist/esm/orb/mountOrb.d.ts +24 -0
- package/dist/esm/orb/mountOrb.js +691 -0
- package/dist/esm/orb/orbView.d.ts +56 -0
- package/dist/esm/orb/orbView.js +421 -0
- package/dist/esm/orb/publishedConfig.d.ts +32 -0
- package/dist/esm/orb/publishedConfig.js +67 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/speech/speechSession.d.ts +35 -0
- package/dist/esm/speech/speechSession.js +57 -0
- package/dist/esm/ui/messages.d.ts +36 -0
- package/dist/esm/ui/messages.js +38 -0
- package/package.json +38 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orbView — framework-free DOM builders that render the Fiodos orb EXACTLY like
|
|
3
|
+
* the reference (React Native QuickBite) and @fiodos/react's <OrbSphere />.
|
|
4
|
+
*
|
|
5
|
+
* Every visual reuses the SAME @fiodos/core math (double-border layout, fill /
|
|
6
|
+
* inner-border color resolution, brand-mark bars, keyboard-chip icon specs), so
|
|
7
|
+
* the embedded vanilla orb matches the dashboard preview, React and React Native
|
|
8
|
+
* pixel-for-pixel — no "basic circle" divergence. Vue / Svelte / Angular mount
|
|
9
|
+
* THIS orb through createFiodosAgent → mountOrb.
|
|
10
|
+
*/
|
|
11
|
+
import { type KeyboardChipIcon } from '@fiodos/core';
|
|
12
|
+
/** Visual appearance of the orb — the subset needed to render the sphere. */
|
|
13
|
+
export interface OrbAppearance {
|
|
14
|
+
accentColor: string;
|
|
15
|
+
backgroundColor: string;
|
|
16
|
+
glowColor?: string;
|
|
17
|
+
colorKey?: string;
|
|
18
|
+
glowIntensity?: number;
|
|
19
|
+
innerBorderColor?: string;
|
|
20
|
+
innerBorderWidth?: number;
|
|
21
|
+
size: number;
|
|
22
|
+
waveformColor?: string;
|
|
23
|
+
logoDataUrl?: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** Keyboard-chip theme (published from the dashboard). */
|
|
26
|
+
export interface ChipTheme {
|
|
27
|
+
size?: number;
|
|
28
|
+
shape?: 'circle' | 'rounded' | 'square';
|
|
29
|
+
backgroundColor?: string;
|
|
30
|
+
borderColor?: string;
|
|
31
|
+
iconColor?: string;
|
|
32
|
+
icon?: KeyboardChipIcon;
|
|
33
|
+
iconImageDataUrl?: string | null;
|
|
34
|
+
iconScale?: number;
|
|
35
|
+
innerBorderColor?: string;
|
|
36
|
+
innerBorderWidth?: number;
|
|
37
|
+
borderWidth?: number;
|
|
38
|
+
}
|
|
39
|
+
export declare const DEFAULT_ORB_APPEARANCE: OrbAppearance;
|
|
40
|
+
export type OrbVisualState = 'idle' | 'listening' | 'thinking' | 'speaking';
|
|
41
|
+
/** Builds the keyboard chip button (port of @fiodos/react's <KeyboardChip />). */
|
|
42
|
+
export declare function buildKeyboardChip(opts: {
|
|
43
|
+
theme: ChipTheme;
|
|
44
|
+
accentColor: string;
|
|
45
|
+
backgroundColor: string;
|
|
46
|
+
label: string;
|
|
47
|
+
onClick: () => void;
|
|
48
|
+
}): HTMLButtonElement;
|
|
49
|
+
export interface OrbVisual {
|
|
50
|
+
/** The outer wrapper element (handles the speaking pulse). */
|
|
51
|
+
element: HTMLDivElement;
|
|
52
|
+
applyAppearance(appearance: OrbAppearance): void;
|
|
53
|
+
applyState(state: OrbVisualState, volume: number): void;
|
|
54
|
+
}
|
|
55
|
+
/** Builds a self-contained orb visual that mirrors @fiodos/react's OrbSphere. */
|
|
56
|
+
export declare function createOrbVisual(initial: OrbAppearance): OrbVisual;
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orbView — framework-free DOM builders that render the Fiodos orb EXACTLY like
|
|
3
|
+
* the reference (React Native QuickBite) and @fiodos/react's <OrbSphere />.
|
|
4
|
+
*
|
|
5
|
+
* Every visual reuses the SAME @fiodos/core math (double-border layout, fill /
|
|
6
|
+
* inner-border color resolution, brand-mark bars, keyboard-chip icon specs), so
|
|
7
|
+
* the embedded vanilla orb matches the dashboard preview, React and React Native
|
|
8
|
+
* pixel-for-pixel — no "basic circle" divergence. Vue / Svelte / Angular mount
|
|
9
|
+
* THIS orb through createFiodosAgent → mountOrb.
|
|
10
|
+
*/
|
|
11
|
+
import { chipBorderRadius, chipIconBaseSizePx, chipIconCenterOffset, chipIconSpec, computeOutwardBorderLayout, normalizeChipIconScale, normalizeKeyboardChipIcon, orbFillDepthBoxShadow, orbOuterGlowBoxShadow, resolveChipIconColor, resolveFiodosOrbMarkBars, resolveInnerBorderColor, resolveOrbBackgroundColor, } from '@fiodos/core';
|
|
12
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
13
|
+
export const DEFAULT_ORB_APPEARANCE = {
|
|
14
|
+
accentColor: '#4f8cff',
|
|
15
|
+
backgroundColor: '#1b2a4a',
|
|
16
|
+
colorKey: 'azul',
|
|
17
|
+
glowIntensity: 100,
|
|
18
|
+
size: 56,
|
|
19
|
+
};
|
|
20
|
+
function svgEl(tag, attrs) {
|
|
21
|
+
const el = document.createElementNS(SVG_NS, tag);
|
|
22
|
+
for (const [k, v] of Object.entries(attrs))
|
|
23
|
+
el.setAttribute(k, String(v));
|
|
24
|
+
return el;
|
|
25
|
+
}
|
|
26
|
+
/** Outside → in: exterior glow border, interior hairline ring, then solid fill. */
|
|
27
|
+
function buildDoubleBorder(opts) {
|
|
28
|
+
const { fillPx, interiorPx, exteriorPx, totalPx, fillRadius, midPx } = computeOutwardBorderLayout(opts.fillDiameter, opts.exteriorWidthSlider, opts.interiorWidthSlider);
|
|
29
|
+
const isCircle = typeof opts.borderRadius === 'number';
|
|
30
|
+
const fillRadiusStyle = isCircle ? `${fillRadius}px` : String(opts.borderRadius);
|
|
31
|
+
const midRadiusStyle = isCircle ? `${midPx / 2}px` : String(opts.borderRadius);
|
|
32
|
+
const totalRadiusStyle = isCircle ? `${totalPx / 2}px` : String(opts.borderRadius);
|
|
33
|
+
const outerGlow = opts.outerGlow !== false;
|
|
34
|
+
const fillDepth = opts.fillDepth !== false;
|
|
35
|
+
const root = document.createElement('div');
|
|
36
|
+
Object.assign(root.style, {
|
|
37
|
+
boxSizing: 'content-box',
|
|
38
|
+
border: exteriorPx > 0 ? `${exteriorPx}px solid ${opts.exteriorColor}` : '',
|
|
39
|
+
borderRadius: totalRadiusStyle,
|
|
40
|
+
background: 'transparent',
|
|
41
|
+
display: 'inline-flex',
|
|
42
|
+
alignItems: 'center',
|
|
43
|
+
justifyContent: 'center',
|
|
44
|
+
flexShrink: '0',
|
|
45
|
+
boxShadow: outerGlow && exteriorPx > 0 ? orbOuterGlowBoxShadow(totalPx, opts.exteriorColor) : '',
|
|
46
|
+
});
|
|
47
|
+
const mid = document.createElement('div');
|
|
48
|
+
Object.assign(mid.style, {
|
|
49
|
+
boxSizing: 'content-box',
|
|
50
|
+
border: interiorPx > 0 ? `${interiorPx}px solid ${opts.interiorColor}` : '',
|
|
51
|
+
borderRadius: midRadiusStyle,
|
|
52
|
+
background: 'transparent',
|
|
53
|
+
display: 'inline-flex',
|
|
54
|
+
alignItems: 'center',
|
|
55
|
+
justifyContent: 'center',
|
|
56
|
+
flexShrink: '0',
|
|
57
|
+
});
|
|
58
|
+
const fill = document.createElement('div');
|
|
59
|
+
Object.assign(fill.style, {
|
|
60
|
+
width: `${fillPx}px`,
|
|
61
|
+
height: `${fillPx}px`,
|
|
62
|
+
borderRadius: fillRadiusStyle,
|
|
63
|
+
background: opts.fill,
|
|
64
|
+
overflow: 'hidden',
|
|
65
|
+
position: 'relative',
|
|
66
|
+
flexShrink: '0',
|
|
67
|
+
display: 'flex',
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
justifyContent: 'center',
|
|
70
|
+
boxShadow: fillDepth && fillPx > 0 ? orbFillDepthBoxShadow(opts.fillDiameter) : '',
|
|
71
|
+
});
|
|
72
|
+
mid.appendChild(fill);
|
|
73
|
+
root.appendChild(mid);
|
|
74
|
+
return { root, fill };
|
|
75
|
+
}
|
|
76
|
+
// ── Brand mark (port of FiodosOrbMark) ───────────────────────────────────────
|
|
77
|
+
function buildOrbMark(orbSize) {
|
|
78
|
+
const { bars, color, radius } = resolveFiodosOrbMarkBars(orbSize);
|
|
79
|
+
const svg = svgEl('svg', {
|
|
80
|
+
width: orbSize,
|
|
81
|
+
height: orbSize,
|
|
82
|
+
viewBox: `0 0 ${orbSize} ${orbSize}`,
|
|
83
|
+
'aria-hidden': 'true',
|
|
84
|
+
});
|
|
85
|
+
svg.style.display = 'block';
|
|
86
|
+
for (const b of bars) {
|
|
87
|
+
svg.appendChild(svgEl('rect', { x: b.x, y: b.y, width: b.width, height: b.height, rx: radius, ry: radius, fill: color }));
|
|
88
|
+
}
|
|
89
|
+
return svg;
|
|
90
|
+
}
|
|
91
|
+
// ── Waveform (port of VoiceWaveform: 9 bars, bell profile, rAF) ───────────────
|
|
92
|
+
const WAVE_BARS = 9;
|
|
93
|
+
const WAVE_BASE = [0.25, 0.45, 0.65, 0.85, 1.0, 0.85, 0.65, 0.45, 0.25];
|
|
94
|
+
const WAVE_IDLE = [0, 0.7, 1.4, 2.1, 2.8, 2.1, 1.4, 0.7, 0.0];
|
|
95
|
+
const WAVE_REF_HEIGHT = 26;
|
|
96
|
+
function buildWaveform(color, height) {
|
|
97
|
+
const scale = height / WAVE_REF_HEIGHT;
|
|
98
|
+
const w = 3 * scale;
|
|
99
|
+
const g = 3 * scale;
|
|
100
|
+
const minBarPx = 3 * scale;
|
|
101
|
+
const el = document.createElement('div');
|
|
102
|
+
Object.assign(el.style, {
|
|
103
|
+
display: 'flex',
|
|
104
|
+
flexDirection: 'row',
|
|
105
|
+
alignItems: 'center',
|
|
106
|
+
justifyContent: 'center',
|
|
107
|
+
gap: `${g}px`,
|
|
108
|
+
height: `${height}px`,
|
|
109
|
+
width: '100%',
|
|
110
|
+
maxWidth: '88%',
|
|
111
|
+
});
|
|
112
|
+
const bars = [];
|
|
113
|
+
for (let i = 0; i < WAVE_BARS; i++) {
|
|
114
|
+
const bar = document.createElement('div');
|
|
115
|
+
Object.assign(bar.style, {
|
|
116
|
+
width: `${w}px`,
|
|
117
|
+
height: `${height}px`,
|
|
118
|
+
borderRadius: `${Math.max(1, 2 * scale)}px`,
|
|
119
|
+
backgroundColor: color,
|
|
120
|
+
transformOrigin: 'center center',
|
|
121
|
+
transform: `scaleY(${minBarPx / height})`,
|
|
122
|
+
flexShrink: '0',
|
|
123
|
+
});
|
|
124
|
+
bars.push(bar);
|
|
125
|
+
el.appendChild(bar);
|
|
126
|
+
}
|
|
127
|
+
let raf = 0;
|
|
128
|
+
let tick = 0;
|
|
129
|
+
let last = 0;
|
|
130
|
+
let volume = 0;
|
|
131
|
+
const minScale = minBarPx / height;
|
|
132
|
+
const loop = (now) => {
|
|
133
|
+
raf = requestAnimationFrame(loop);
|
|
134
|
+
if (now - last < 80)
|
|
135
|
+
return;
|
|
136
|
+
last = now;
|
|
137
|
+
tick += 0.22;
|
|
138
|
+
bars.forEach((bar, i) => {
|
|
139
|
+
let target;
|
|
140
|
+
if (volume < 0.05) {
|
|
141
|
+
const sine = (Math.sin(tick + (WAVE_IDLE[i] ?? 0)) + 1) / 2;
|
|
142
|
+
target = minScale + sine * (0.22 - minScale);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const noise = Math.sin(tick * 2.3 + (WAVE_IDLE[i] ?? 0)) * 0.18 + 0.82 + Math.random() * 0.18;
|
|
146
|
+
target = minScale + volume * (WAVE_BASE[i] ?? 0.5) * (1 - minScale) * noise;
|
|
147
|
+
}
|
|
148
|
+
bar.style.transform = `scaleY(${Math.min(1, Math.max(minScale, target))})`;
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
el,
|
|
153
|
+
start() {
|
|
154
|
+
if (raf)
|
|
155
|
+
return;
|
|
156
|
+
last = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
157
|
+
raf = requestAnimationFrame(loop);
|
|
158
|
+
},
|
|
159
|
+
stop() {
|
|
160
|
+
if (raf)
|
|
161
|
+
cancelAnimationFrame(raf);
|
|
162
|
+
raf = 0;
|
|
163
|
+
},
|
|
164
|
+
setVolume(v) {
|
|
165
|
+
volume = v;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// ── Thinking spinner (port of ThinkingSpinner: 8-bar open arc) ────────────────
|
|
170
|
+
const SPIN_BARS = 8;
|
|
171
|
+
const SPIN_GAP_DEG = 68;
|
|
172
|
+
const SPIN_ARC_SPAN = 360 - SPIN_GAP_DEG;
|
|
173
|
+
const SPIN_ARC_START = SPIN_GAP_DEG / 2;
|
|
174
|
+
const SPIN_OPACITIES = [0.15, 0.25, 0.35, 0.5, 0.65, 0.78, 0.88, 1];
|
|
175
|
+
function buildThinkingSpinner(orbSizePx, color) {
|
|
176
|
+
const size = Math.max(28, orbSizePx * 0.52);
|
|
177
|
+
const wrap = document.createElement('div');
|
|
178
|
+
Object.assign(wrap.style, {
|
|
179
|
+
position: 'absolute',
|
|
180
|
+
inset: '0',
|
|
181
|
+
display: 'flex',
|
|
182
|
+
alignItems: 'center',
|
|
183
|
+
justifyContent: 'center',
|
|
184
|
+
pointerEvents: 'none',
|
|
185
|
+
});
|
|
186
|
+
const spin = document.createElement('div');
|
|
187
|
+
Object.assign(spin.style, {
|
|
188
|
+
width: `${size}px`,
|
|
189
|
+
height: `${size}px`,
|
|
190
|
+
animation: 'fy-spin 0.92s linear infinite',
|
|
191
|
+
});
|
|
192
|
+
spin.setAttribute('role', 'progressbar');
|
|
193
|
+
spin.setAttribute('aria-label', 'Loading');
|
|
194
|
+
const svg = svgEl('svg', { width: size, height: size, viewBox: '0 0 40 40', 'aria-hidden': 'true' });
|
|
195
|
+
const step = SPIN_ARC_SPAN / (SPIN_BARS - 1);
|
|
196
|
+
for (let i = 0; i < SPIN_BARS; i++) {
|
|
197
|
+
const angle = SPIN_ARC_START + step * i;
|
|
198
|
+
svg.appendChild(svgEl('rect', {
|
|
199
|
+
x: '18.25',
|
|
200
|
+
y: '5.5',
|
|
201
|
+
width: '3.5',
|
|
202
|
+
height: '11',
|
|
203
|
+
rx: '1.75',
|
|
204
|
+
fill: color,
|
|
205
|
+
opacity: SPIN_OPACITIES[i] ?? 1,
|
|
206
|
+
transform: `rotate(${angle} 20 20)`,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
spin.appendChild(svg);
|
|
210
|
+
wrap.appendChild(spin);
|
|
211
|
+
return wrap;
|
|
212
|
+
}
|
|
213
|
+
// ── Keyboard chip icon (port of ChipIconSvg) ──────────────────────────────────
|
|
214
|
+
function buildChipIcon(icon, size, color) {
|
|
215
|
+
const spec = chipIconSpec(icon);
|
|
216
|
+
const strokeW = 1.8;
|
|
217
|
+
const { x: ox, y: oy } = chipIconCenterOffset(spec);
|
|
218
|
+
const svg = svgEl('svg', { width: size, height: size, viewBox: spec.viewBox, fill: 'none', 'aria-hidden': 'true' });
|
|
219
|
+
svg.style.display = 'block';
|
|
220
|
+
svg.style.pointerEvents = 'none';
|
|
221
|
+
const g = svgEl('g', { transform: `translate(${ox} ${oy})` });
|
|
222
|
+
if (spec.keyboardFrame && spec.keyDots) {
|
|
223
|
+
const f = spec.keyboardFrame;
|
|
224
|
+
g.appendChild(svgEl('rect', { x: f.x, y: f.y, width: f.w, height: f.h, rx: f.rx, stroke: color, 'stroke-width': strokeW }));
|
|
225
|
+
for (const [cx, cy] of spec.keyDots) {
|
|
226
|
+
g.appendChild(svgEl('rect', { x: cx - 0.6, y: cy - 0.6, width: 1.2, height: 1.2, rx: 0.2, fill: color }));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else if (spec.chatFrame && spec.chatLines) {
|
|
230
|
+
const f = spec.chatFrame;
|
|
231
|
+
const cx = f.x + f.w / 2;
|
|
232
|
+
const baseY = f.y + f.h;
|
|
233
|
+
const lineStroke = Math.max(1.15, strokeW * 0.65);
|
|
234
|
+
g.appendChild(svgEl('rect', { x: f.x, y: f.y, width: f.w, height: f.h, rx: f.rx, stroke: color, 'stroke-width': strokeW }));
|
|
235
|
+
if (spec.chatTail) {
|
|
236
|
+
const t = spec.chatTail;
|
|
237
|
+
g.appendChild(svgEl('polygon', { points: `${t.left},${baseY} ${t.tipX},${t.tipY} ${t.right},${baseY}`, fill: color }));
|
|
238
|
+
}
|
|
239
|
+
for (const { y, w } of spec.chatLines) {
|
|
240
|
+
g.appendChild(svgEl('line', {
|
|
241
|
+
x1: cx - w / 2,
|
|
242
|
+
y1: y,
|
|
243
|
+
x2: cx + w / 2,
|
|
244
|
+
y2: y,
|
|
245
|
+
stroke: color,
|
|
246
|
+
'stroke-width': lineStroke,
|
|
247
|
+
'stroke-linecap': 'round',
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
for (const p of spec.paths) {
|
|
253
|
+
g.appendChild(svgEl('path', {
|
|
254
|
+
d: p.d,
|
|
255
|
+
stroke: color,
|
|
256
|
+
fill: 'none',
|
|
257
|
+
'stroke-width': strokeW,
|
|
258
|
+
'stroke-linecap': 'round',
|
|
259
|
+
'stroke-linejoin': 'round',
|
|
260
|
+
}));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
svg.appendChild(g);
|
|
264
|
+
return svg;
|
|
265
|
+
}
|
|
266
|
+
/** Builds the keyboard chip button (port of @fiodos/react's <KeyboardChip />). */
|
|
267
|
+
export function buildKeyboardChip(opts) {
|
|
268
|
+
const { theme } = opts;
|
|
269
|
+
const chipSize = theme.size ?? 30;
|
|
270
|
+
const chipShape = theme.shape ?? 'circle';
|
|
271
|
+
const radius = chipShape === 'circle' ? '50%' : `${chipBorderRadius(chipShape, chipSize)}px`;
|
|
272
|
+
const chipBg = theme.backgroundColor ?? opts.backgroundColor;
|
|
273
|
+
const chipBorder = theme.borderColor ?? opts.accentColor;
|
|
274
|
+
const chipIconColor = resolveChipIconColor(theme.iconColor, chipBg, opts.accentColor);
|
|
275
|
+
const chipIconPreset = normalizeKeyboardChipIcon(theme.icon);
|
|
276
|
+
const chipIconScale = normalizeChipIconScale(theme.iconScale ?? 1);
|
|
277
|
+
const iconSize = Math.round(chipIconBaseSizePx(chipSize) * chipIconScale);
|
|
278
|
+
const layout = computeOutwardBorderLayout(chipSize, theme.borderWidth ?? 100, theme.innerBorderWidth);
|
|
279
|
+
const btn = document.createElement('button');
|
|
280
|
+
btn.type = 'button';
|
|
281
|
+
btn.setAttribute('aria-label', opts.label);
|
|
282
|
+
Object.assign(btn.style, {
|
|
283
|
+
border: 'none',
|
|
284
|
+
background: 'transparent',
|
|
285
|
+
padding: '0',
|
|
286
|
+
cursor: 'pointer',
|
|
287
|
+
lineHeight: '0',
|
|
288
|
+
flexShrink: '0',
|
|
289
|
+
width: `${layout.totalPx}px`,
|
|
290
|
+
height: `${layout.totalPx}px`,
|
|
291
|
+
display: 'flex',
|
|
292
|
+
alignItems: 'center',
|
|
293
|
+
justifyContent: 'center',
|
|
294
|
+
});
|
|
295
|
+
btn.onclick = opts.onClick;
|
|
296
|
+
const { root, fill } = buildDoubleBorder({
|
|
297
|
+
fillDiameter: chipSize,
|
|
298
|
+
borderRadius: radius,
|
|
299
|
+
exteriorColor: chipBorder,
|
|
300
|
+
exteriorWidthSlider: theme.borderWidth ?? 100,
|
|
301
|
+
interiorColor: resolveInnerBorderColor(theme.innerBorderColor),
|
|
302
|
+
interiorWidthSlider: theme.innerBorderWidth,
|
|
303
|
+
fill: chipBg,
|
|
304
|
+
outerGlow: false,
|
|
305
|
+
fillDepth: false,
|
|
306
|
+
});
|
|
307
|
+
if (theme.iconImageDataUrl) {
|
|
308
|
+
const img = document.createElement('img');
|
|
309
|
+
img.src = theme.iconImageDataUrl;
|
|
310
|
+
img.alt = '';
|
|
311
|
+
Object.assign(img.style, {
|
|
312
|
+
width: `${iconSize}px`,
|
|
313
|
+
height: `${iconSize}px`,
|
|
314
|
+
objectFit: 'contain',
|
|
315
|
+
display: 'block',
|
|
316
|
+
});
|
|
317
|
+
fill.appendChild(img);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
fill.appendChild(buildChipIcon(chipIconPreset, iconSize, chipIconColor));
|
|
321
|
+
}
|
|
322
|
+
btn.appendChild(root);
|
|
323
|
+
return btn;
|
|
324
|
+
}
|
|
325
|
+
/** Builds a self-contained orb visual that mirrors @fiodos/react's OrbSphere. */
|
|
326
|
+
export function createOrbVisual(initial) {
|
|
327
|
+
const wrapper = document.createElement('div');
|
|
328
|
+
wrapper.style.display = 'inline-flex';
|
|
329
|
+
let appearance = initial;
|
|
330
|
+
let state = 'idle';
|
|
331
|
+
let volume = 0;
|
|
332
|
+
let fillEl = null;
|
|
333
|
+
let waveform = null;
|
|
334
|
+
function rebuild() {
|
|
335
|
+
const size = appearance.size ?? DEFAULT_ORB_APPEARANCE.size;
|
|
336
|
+
const fill = resolveOrbBackgroundColor({
|
|
337
|
+
colorKey: appearance.colorKey ?? DEFAULT_ORB_APPEARANCE.colorKey,
|
|
338
|
+
backgroundColor: appearance.backgroundColor,
|
|
339
|
+
accentColor: appearance.accentColor,
|
|
340
|
+
});
|
|
341
|
+
const exteriorColor = appearance.glowColor?.startsWith('#') ? appearance.glowColor : appearance.accentColor;
|
|
342
|
+
const interiorColor = resolveInnerBorderColor(appearance.innerBorderColor);
|
|
343
|
+
waveform?.stop();
|
|
344
|
+
waveform = null;
|
|
345
|
+
wrapper.replaceChildren();
|
|
346
|
+
const { root, fill: fillNode } = buildDoubleBorder({
|
|
347
|
+
fillDiameter: size,
|
|
348
|
+
borderRadius: size,
|
|
349
|
+
exteriorColor,
|
|
350
|
+
exteriorWidthSlider: appearance.glowIntensity ?? DEFAULT_ORB_APPEARANCE.glowIntensity,
|
|
351
|
+
interiorColor,
|
|
352
|
+
interiorWidthSlider: appearance.innerBorderWidth,
|
|
353
|
+
fill,
|
|
354
|
+
});
|
|
355
|
+
fillEl = fillNode;
|
|
356
|
+
wrapper.appendChild(root);
|
|
357
|
+
renderContent();
|
|
358
|
+
}
|
|
359
|
+
function renderContent() {
|
|
360
|
+
if (!fillEl)
|
|
361
|
+
return;
|
|
362
|
+
const size = appearance.size ?? DEFAULT_ORB_APPEARANCE.size;
|
|
363
|
+
const waveColor = appearance.waveformColor ?? '#ffffff';
|
|
364
|
+
waveform?.stop();
|
|
365
|
+
waveform = null;
|
|
366
|
+
fillEl.replaceChildren();
|
|
367
|
+
wrapper.style.animation = state === 'speaking' ? 'fy-opulse 1.1s ease-in-out infinite' : '';
|
|
368
|
+
if (state === 'thinking') {
|
|
369
|
+
fillEl.appendChild(buildThinkingSpinner(size, waveColor));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (state === 'listening') {
|
|
373
|
+
waveform = buildWaveform(waveColor, Math.round(size * 0.46));
|
|
374
|
+
waveform.setVolume(volume);
|
|
375
|
+
fillEl.appendChild(waveform.el);
|
|
376
|
+
waveform.start();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (appearance.logoDataUrl) {
|
|
380
|
+
const img = document.createElement('img');
|
|
381
|
+
img.src = appearance.logoDataUrl;
|
|
382
|
+
img.alt = '';
|
|
383
|
+
img.draggable = false;
|
|
384
|
+
img.addEventListener('dragstart', (e) => e.preventDefault());
|
|
385
|
+
Object.assign(img.style, {
|
|
386
|
+
width: '64%',
|
|
387
|
+
height: '64%',
|
|
388
|
+
objectFit: 'contain',
|
|
389
|
+
// The orb moves as a whole via the button's pointer drag — never let the
|
|
390
|
+
// browser grab/select the PNG itself.
|
|
391
|
+
pointerEvents: 'none',
|
|
392
|
+
userSelect: 'none',
|
|
393
|
+
webkitUserSelect: 'none',
|
|
394
|
+
webkitUserDrag: 'none',
|
|
395
|
+
});
|
|
396
|
+
fillEl.appendChild(img);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (state === 'idle') {
|
|
400
|
+
fillEl.appendChild(buildOrbMark(size));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
rebuild();
|
|
404
|
+
return {
|
|
405
|
+
element: wrapper,
|
|
406
|
+
applyAppearance(next) {
|
|
407
|
+
appearance = next;
|
|
408
|
+
rebuild();
|
|
409
|
+
},
|
|
410
|
+
applyState(nextState, nextVolume) {
|
|
411
|
+
volume = nextVolume;
|
|
412
|
+
if (nextState !== state) {
|
|
413
|
+
state = nextState;
|
|
414
|
+
renderContent();
|
|
415
|
+
}
|
|
416
|
+
else if (state === 'listening') {
|
|
417
|
+
waveform?.setVolume(volume);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published orb config watcher — framework-free.
|
|
3
|
+
*
|
|
4
|
+
* The vanilla web equivalent of @fiodos/react-native's usePublishedOrbConfig and
|
|
5
|
+
* @fiodos/react's usePublishedOrbConfig hook. Fetches the dashboard-published orb
|
|
6
|
+
* config (GET /v1/client/config via the controller's backend client) and maps it
|
|
7
|
+
* with the SAME @fiodos/core mapper (mapPublishedConfigToOrbProps), so color /
|
|
8
|
+
* size / glow / logo / keyboard-chip / screen position apply on web exactly as on
|
|
9
|
+
* mobile — in real time, via polling. So Vue/Svelte/Angular read the dashboard
|
|
10
|
+
* config identically to the reference, instead of ignoring it.
|
|
11
|
+
*/
|
|
12
|
+
import { type OrbScreenPosition, type PublishedModalTheme } from '@fiodos/core';
|
|
13
|
+
import type { AgentController } from '../controller/AgentController.js';
|
|
14
|
+
import { type ChipTheme, type OrbAppearance } from './orbView.js';
|
|
15
|
+
export interface ResolvedOrbConfig {
|
|
16
|
+
appearance: OrbAppearance;
|
|
17
|
+
keyboardChip: ChipTheme | null;
|
|
18
|
+
modalTheme: PublishedModalTheme | undefined;
|
|
19
|
+
screenPosition: OrbScreenPosition | undefined;
|
|
20
|
+
}
|
|
21
|
+
export interface WatchPublishedConfigOptions {
|
|
22
|
+
/** Poll interval (ms) to pick up dashboard changes in real time. Default 12000. */
|
|
23
|
+
pollMs?: number;
|
|
24
|
+
/** Called with the resolved config (or null when nothing is published). */
|
|
25
|
+
onChange: (config: ResolvedOrbConfig | null) => void;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Starts watching the published config. Returns an unsubscribe function.
|
|
29
|
+
* Best-effort: a failed fetch reports `null` (the orb keeps its current/default
|
|
30
|
+
* look) and never throws into the host app.
|
|
31
|
+
*/
|
|
32
|
+
export declare function watchPublishedConfig(controller: AgentController, options: WatchPublishedConfigOptions): () => void;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published orb config watcher — framework-free.
|
|
3
|
+
*
|
|
4
|
+
* The vanilla web equivalent of @fiodos/react-native's usePublishedOrbConfig and
|
|
5
|
+
* @fiodos/react's usePublishedOrbConfig hook. Fetches the dashboard-published orb
|
|
6
|
+
* config (GET /v1/client/config via the controller's backend client) and maps it
|
|
7
|
+
* with the SAME @fiodos/core mapper (mapPublishedConfigToOrbProps), so color /
|
|
8
|
+
* size / glow / logo / keyboard-chip / screen position apply on web exactly as on
|
|
9
|
+
* mobile — in real time, via polling. So Vue/Svelte/Angular read the dashboard
|
|
10
|
+
* config identically to the reference, instead of ignoring it.
|
|
11
|
+
*/
|
|
12
|
+
import { mapPublishedConfigToOrbProps, } from '@fiodos/core';
|
|
13
|
+
import { DEFAULT_ORB_APPEARANCE } from './orbView.js';
|
|
14
|
+
function toResolved(props) {
|
|
15
|
+
const theme = props.theme;
|
|
16
|
+
const appearance = {
|
|
17
|
+
accentColor: theme.accentColor ?? DEFAULT_ORB_APPEARANCE.accentColor,
|
|
18
|
+
backgroundColor: theme.backgroundColor ?? DEFAULT_ORB_APPEARANCE.backgroundColor,
|
|
19
|
+
glowColor: theme.glowColor,
|
|
20
|
+
colorKey: theme.colorKey,
|
|
21
|
+
glowIntensity: theme.glowIntensity,
|
|
22
|
+
innerBorderColor: theme.innerBorderColor,
|
|
23
|
+
innerBorderWidth: theme.innerBorderWidth,
|
|
24
|
+
size: theme.size ?? DEFAULT_ORB_APPEARANCE.size,
|
|
25
|
+
waveformColor: theme.waveformColor,
|
|
26
|
+
logoDataUrl: props.logoDataUrl,
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
appearance,
|
|
30
|
+
keyboardChip: theme.keyboardChip ?? null,
|
|
31
|
+
modalTheme: props.modalTheme,
|
|
32
|
+
screenPosition: props.screenPosition,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Starts watching the published config. Returns an unsubscribe function.
|
|
37
|
+
* Best-effort: a failed fetch reports `null` (the orb keeps its current/default
|
|
38
|
+
* look) and never throws into the host app.
|
|
39
|
+
*/
|
|
40
|
+
export function watchPublishedConfig(controller, options) {
|
|
41
|
+
const fetcher = controller.config.backend.fetchPublishedConfig;
|
|
42
|
+
if (!fetcher) {
|
|
43
|
+
options.onChange(null);
|
|
44
|
+
return () => { };
|
|
45
|
+
}
|
|
46
|
+
let cancelled = false;
|
|
47
|
+
const load = async () => {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetcher.call(controller.config.backend);
|
|
50
|
+
if (cancelled)
|
|
51
|
+
return;
|
|
52
|
+
options.onChange(res.published ? toResolved(mapPublishedConfigToOrbProps(res.published)) : null);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
if (!cancelled)
|
|
56
|
+
options.onChange(null);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
void load();
|
|
60
|
+
const pollMs = options.pollMs ?? 12000;
|
|
61
|
+
const timer = pollMs > 0 ? setInterval(() => void load(), pollMs) : null;
|
|
62
|
+
return () => {
|
|
63
|
+
cancelled = true;
|
|
64
|
+
if (timer)
|
|
65
|
+
clearInterval(timer);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-free speech session — wraps a @fiodos/core VoiceAdapter's
|
|
3
|
+
* recognition into the state machine the orchestrator depends on:
|
|
4
|
+
*
|
|
5
|
+
* idle → recording → transcribing → (transcript | nothing) → idle
|
|
6
|
+
*
|
|
7
|
+
* This is the vanilla equivalent of @fiodos/react's `useSpeechSession` hook:
|
|
8
|
+
* the same state machine, exposed via a `subscribe` listener instead of React
|
|
9
|
+
* state, so it drives any framework.
|
|
10
|
+
*/
|
|
11
|
+
import type { VoiceAdapter } from '@fiodos/core';
|
|
12
|
+
export type SpeechSessionState = 'idle' | 'recording' | 'transcribing';
|
|
13
|
+
export interface SpeechSessionSnapshot {
|
|
14
|
+
state: SpeechSessionState;
|
|
15
|
+
interimText: string;
|
|
16
|
+
volumeLevel: number;
|
|
17
|
+
}
|
|
18
|
+
export interface SpeechSessionOptions {
|
|
19
|
+
adapter: VoiceAdapter;
|
|
20
|
+
/** STT locale — always explicit, from the SDK config. */
|
|
21
|
+
locale: string;
|
|
22
|
+
maxSeconds: number;
|
|
23
|
+
onTranscript: (text: string) => void;
|
|
24
|
+
/** Recognition error (permission denied, native failure, unavailable). */
|
|
25
|
+
onError?: (error: unknown) => void;
|
|
26
|
+
}
|
|
27
|
+
export interface SpeechSession {
|
|
28
|
+
getSnapshot(): SpeechSessionSnapshot;
|
|
29
|
+
subscribe(listener: (snapshot: SpeechSessionSnapshot) => void): () => void;
|
|
30
|
+
start(): Promise<void>;
|
|
31
|
+
stop(): void;
|
|
32
|
+
cancel(): void;
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
export declare function createSpeechSession(options: SpeechSessionOptions): SpeechSession;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function createSpeechSession(options) {
|
|
2
|
+
const { adapter, locale, maxSeconds } = options;
|
|
3
|
+
let snapshot = { state: 'idle', interimText: '', volumeLevel: 0 };
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
function emit(next) {
|
|
6
|
+
snapshot = { ...snapshot, ...next };
|
|
7
|
+
for (const l of listeners)
|
|
8
|
+
l(snapshot);
|
|
9
|
+
}
|
|
10
|
+
function resetToIdle() {
|
|
11
|
+
emit({ state: 'idle', interimText: '', volumeLevel: 0 });
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
getSnapshot() {
|
|
15
|
+
return snapshot;
|
|
16
|
+
},
|
|
17
|
+
subscribe(listener) {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
async start() {
|
|
22
|
+
if (snapshot.state !== 'idle')
|
|
23
|
+
return;
|
|
24
|
+
emit({ state: 'recording', interimText: '', volumeLevel: 0.85 });
|
|
25
|
+
await adapter.startListening({ locale, continuous: true, maxSeconds }, {
|
|
26
|
+
onInterim: (text) => emit({ interimText: text }),
|
|
27
|
+
onFinal: (text) => {
|
|
28
|
+
resetToIdle();
|
|
29
|
+
if (text.trim())
|
|
30
|
+
options.onTranscript(text);
|
|
31
|
+
},
|
|
32
|
+
onEnd: () => {
|
|
33
|
+
if (snapshot.state !== 'idle')
|
|
34
|
+
resetToIdle();
|
|
35
|
+
},
|
|
36
|
+
onError: (error) => {
|
|
37
|
+
resetToIdle();
|
|
38
|
+
options.onError?.(error);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
stop() {
|
|
43
|
+
if (snapshot.state !== 'recording')
|
|
44
|
+
return;
|
|
45
|
+
emit({ state: 'transcribing', volumeLevel: 0 });
|
|
46
|
+
adapter.stopListening();
|
|
47
|
+
},
|
|
48
|
+
cancel() {
|
|
49
|
+
adapter.cancelListening();
|
|
50
|
+
resetToIdle();
|
|
51
|
+
},
|
|
52
|
+
dispose() {
|
|
53
|
+
adapter.cancelListening();
|
|
54
|
+
listeners.clear();
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|