@oshara/voice-sdk 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/README.md +198 -0
- package/dist/appearance-CNWT8x1G.cjs +2 -0
- package/dist/appearance-CNWT8x1G.cjs.map +1 -0
- package/dist/appearance-i6QBkpCk.js +650 -0
- package/dist/appearance-i6QBkpCk.js.map +1 -0
- package/dist/consent-CK9VXNPa.js +54 -0
- package/dist/consent-CK9VXNPa.js.map +1 -0
- package/dist/consent-D7QNSkQD.cjs +2 -0
- package/dist/consent-D7QNSkQD.cjs.map +1 -0
- package/dist/core/analytics.d.ts +30 -0
- package/dist/core/appearance.d.ts +113 -0
- package/dist/core/audioSettings.d.ts +69 -0
- package/dist/core/consent.d.ts +17 -0
- package/dist/core/createVoiceAgent.d.ts +79 -0
- package/dist/core/events.d.ts +103 -0
- package/dist/core/formController.d.ts +28 -0
- package/dist/core/forms.d.ts +235 -0
- package/dist/core/index.d.ts +29 -0
- package/dist/core/prevContext.d.ts +26 -0
- package/dist/core/transport.d.ts +30 -0
- package/dist/core/types.d.ts +49 -0
- package/dist/core/voice.d.ts +79 -0
- package/dist/createVoiceAgent-BM3HODS6.js +1058 -0
- package/dist/createVoiceAgent-BM3HODS6.js.map +1 -0
- package/dist/createVoiceAgent-CJWxWzz6.cjs +4 -0
- package/dist/createVoiceAgent-CJWxWzz6.cjs.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +60 -0
- package/dist/react.cjs +2 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +115 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.css +1838 -0
- package/dist/ui/index.d.ts +21 -0
- package/dist/ui/ui.d.ts +165 -0
- package/dist/ui.cjs +284 -0
- package/dist/ui.cjs.map +1 -0
- package/dist/ui.js +1153 -0
- package/dist/ui.js.map +1 -0
- package/package.json +67 -0
- package/src/core/analytics.ts +111 -0
- package/src/core/appearance.ts +464 -0
- package/src/core/audioSettings.ts +180 -0
- package/src/core/consent.ts +78 -0
- package/src/core/createVoiceAgent.ts +280 -0
- package/src/core/events.ts +120 -0
- package/src/core/formController.ts +317 -0
- package/src/core/forms.ts +861 -0
- package/src/core/index.ts +121 -0
- package/src/core/prevContext.ts +153 -0
- package/src/core/transport.ts +118 -0
- package/src/core/types.ts +66 -0
- package/src/core/voice.ts +1179 -0
- package/src/react/index.ts +238 -0
- package/src/ui/index.ts +507 -0
- package/src/ui/styles.css +1838 -0
- package/src/ui/ui.ts +1672 -0
- package/src/vite-env.d.ts +10 -0
package/src/ui/ui.ts
ADDED
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
import { AppearanceConfig, AppearanceLanguage, DEFAULT_APPEARANCE } from "../core/appearance";
|
|
2
|
+
import {
|
|
3
|
+
FormDefinition,
|
|
4
|
+
FormFieldDef,
|
|
5
|
+
FormFieldOption,
|
|
6
|
+
fieldsForStep,
|
|
7
|
+
totalSteps,
|
|
8
|
+
} from "../core/forms";
|
|
9
|
+
import stylesheet from "./styles.css?inline";
|
|
10
|
+
|
|
11
|
+
export interface FormInputBinding {
|
|
12
|
+
/** Read the current field value as a string (comma-joined for multi-select groups). */
|
|
13
|
+
read: () => string;
|
|
14
|
+
/** Toggle the disabled state across one or more underlying inputs. */
|
|
15
|
+
setDisabled: (busy: boolean) => void;
|
|
16
|
+
/** Show (or, with an empty string, clear) an inline validation error under the field. */
|
|
17
|
+
setError: (message: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Screen = "welcome" | "call" | "form";
|
|
21
|
+
|
|
22
|
+
export interface UIRefs {
|
|
23
|
+
host: HTMLDivElement;
|
|
24
|
+
shadow: ShadowRoot;
|
|
25
|
+
fab: HTMLButtonElement;
|
|
26
|
+
panel: HTMLDivElement;
|
|
27
|
+
closeBtn: HTMLButtonElement;
|
|
28
|
+
|
|
29
|
+
welcomeScreen: HTMLDivElement;
|
|
30
|
+
welcomeLogo: HTMLImageElement;
|
|
31
|
+
welcomeLogoFallback: HTMLDivElement;
|
|
32
|
+
welcomeName: HTMLDivElement;
|
|
33
|
+
welcomeDesc: HTMLDivElement;
|
|
34
|
+
langPicker: HTMLDivElement;
|
|
35
|
+
langTrigger: HTMLButtonElement;
|
|
36
|
+
langTriggerLabel: HTMLSpanElement;
|
|
37
|
+
langTriggerCode: HTMLSpanElement;
|
|
38
|
+
langMenu: HTMLDivElement;
|
|
39
|
+
startBtn: HTMLButtonElement;
|
|
40
|
+
startBtnLabel: HTMLSpanElement;
|
|
41
|
+
consent: HTMLDivElement;
|
|
42
|
+
consentText: HTMLSpanElement;
|
|
43
|
+
consentLink: HTMLAnchorElement;
|
|
44
|
+
poweredBy: HTMLAnchorElement;
|
|
45
|
+
/** Currently selected language code (BCP-47 short, e.g. "en"). */
|
|
46
|
+
selectedLanguage: string;
|
|
47
|
+
|
|
48
|
+
callScreen: HTMLDivElement;
|
|
49
|
+
callLogo: HTMLImageElement;
|
|
50
|
+
callLogoFallback: HTMLDivElement;
|
|
51
|
+
callName: HTMLDivElement;
|
|
52
|
+
callStatus: HTMLDivElement;
|
|
53
|
+
callTimer: HTMLDivElement;
|
|
54
|
+
callTimerText: HTMLSpanElement;
|
|
55
|
+
orb: HTMLDivElement;
|
|
56
|
+
orbLabel: HTMLDivElement;
|
|
57
|
+
/** Contextual processing line under the orb ("Searching the knowledge base…"). */
|
|
58
|
+
agentStatusLine: HTMLDivElement;
|
|
59
|
+
transcript: HTMLDivElement;
|
|
60
|
+
transcriptSegments: Map<string, HTMLDivElement>;
|
|
61
|
+
/**
|
|
62
|
+
* Per-role pointer to the bubble that is still in `interim` state. Used to
|
|
63
|
+
* coalesce streaming + final segments when the STT backend emits different
|
|
64
|
+
* `seg.id`s for interim vs final (e.g. sherpa), which would otherwise leave
|
|
65
|
+
* the half-finished interim bubble onscreen alongside a new final bubble.
|
|
66
|
+
*/
|
|
67
|
+
transcriptInterimBubble: Map<"user" | "agent", HTMLDivElement>;
|
|
68
|
+
textInputRow: HTMLDivElement;
|
|
69
|
+
textInput: HTMLTextAreaElement;
|
|
70
|
+
textSendBtn: HTMLButtonElement;
|
|
71
|
+
muteBtn: HTMLButtonElement;
|
|
72
|
+
endBtn: HTMLButtonElement;
|
|
73
|
+
settingsBtn: HTMLButtonElement;
|
|
74
|
+
|
|
75
|
+
audioDrawer: HTMLDivElement;
|
|
76
|
+
audioDrawerClose: HTMLButtonElement;
|
|
77
|
+
audioMeterBar: HTMLDivElement;
|
|
78
|
+
audioMicSelect: HTMLSelectElement;
|
|
79
|
+
audioSpeakerSelect: HTMLSelectElement;
|
|
80
|
+
audioSpeakerRow: HTMLLabelElement;
|
|
81
|
+
audioVolume: HTMLInputElement;
|
|
82
|
+
audioVolumeValue: HTMLSpanElement;
|
|
83
|
+
audioNcEngine: HTMLSelectElement;
|
|
84
|
+
audioDfStrengthRow: HTMLLabelElement;
|
|
85
|
+
audioDfStrength: HTMLInputElement;
|
|
86
|
+
audioDfStrengthValue: HTMLSpanElement;
|
|
87
|
+
audioTogAec: HTMLInputElement;
|
|
88
|
+
audioTogNs: HTMLInputElement;
|
|
89
|
+
audioTogAgc: HTMLInputElement;
|
|
90
|
+
audioTogVi: HTMLInputElement;
|
|
91
|
+
audioTogViRow: HTMLLabelElement;
|
|
92
|
+
audioTogHp: HTMLInputElement;
|
|
93
|
+
audioTogTranscription: HTMLInputElement;
|
|
94
|
+
audioTogTextInput: HTMLInputElement;
|
|
95
|
+
audioDiagEngine: HTMLElement;
|
|
96
|
+
audioDiagAec: HTMLElement;
|
|
97
|
+
audioDiagNs: HTMLElement;
|
|
98
|
+
audioDiagAgc: HTMLElement;
|
|
99
|
+
audioDiagVi: HTMLElement;
|
|
100
|
+
audioDiagSr: HTMLElement;
|
|
101
|
+
audioDiagLoss: HTMLElement;
|
|
102
|
+
audioDiagJitter: HTMLElement;
|
|
103
|
+
audioDiagRtt: HTMLElement;
|
|
104
|
+
|
|
105
|
+
formScreen: HTMLDivElement;
|
|
106
|
+
formTranscript: HTMLDivElement;
|
|
107
|
+
formTranscriptSegments: Map<string, HTMLDivElement>;
|
|
108
|
+
formTranscriptInterimBubble: Map<"user" | "agent", HTMLDivElement>;
|
|
109
|
+
formTitle: HTMLDivElement;
|
|
110
|
+
formEyebrow: HTMLDivElement;
|
|
111
|
+
formSubtitle: HTMLDivElement;
|
|
112
|
+
formStepper: HTMLDivElement;
|
|
113
|
+
formFields: HTMLDivElement;
|
|
114
|
+
formError: HTMLDivElement;
|
|
115
|
+
formSuccess: HTMLDivElement;
|
|
116
|
+
formSubmitBtn: HTMLButtonElement;
|
|
117
|
+
formBackBtn: HTMLButtonElement;
|
|
118
|
+
formCancelBtn: HTMLButtonElement;
|
|
119
|
+
formStepBackBtn: HTMLButtonElement;
|
|
120
|
+
formCallControls: HTMLDivElement;
|
|
121
|
+
formMuteBtn: HTMLButtonElement;
|
|
122
|
+
formEndBtn: HTMLButtonElement;
|
|
123
|
+
formInputs: Map<string, FormInputBinding>;
|
|
124
|
+
|
|
125
|
+
appearance: AppearanceConfig;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ICON_FAB = `
|
|
129
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
130
|
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
131
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
132
|
+
<line x1="12" y1="19" x2="12" y2="23"/>
|
|
133
|
+
<line x1="8" y1="23" x2="16" y2="23"/>
|
|
134
|
+
</svg>`;
|
|
135
|
+
|
|
136
|
+
const ICON_CLOSE = `
|
|
137
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
138
|
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
139
|
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
140
|
+
</svg>`;
|
|
141
|
+
|
|
142
|
+
const ICON_PHONE = `
|
|
143
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
144
|
+
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.37 1.9.72 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.35 1.85.59 2.81.72A2 2 0 0 1 22 16.92z"/>
|
|
145
|
+
</svg>`;
|
|
146
|
+
|
|
147
|
+
const ICON_MIC_ON = `
|
|
148
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
149
|
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
150
|
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
151
|
+
<line x1="12" y1="19" x2="12" y2="23"/>
|
|
152
|
+
<line x1="8" y1="23" x2="16" y2="23"/>
|
|
153
|
+
</svg>`;
|
|
154
|
+
|
|
155
|
+
const ICON_MIC_OFF = `
|
|
156
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
157
|
+
<line x1="1" y1="1" x2="23" y2="23"/>
|
|
158
|
+
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
|
|
159
|
+
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/>
|
|
160
|
+
<line x1="12" y1="19" x2="12" y2="23"/>
|
|
161
|
+
<line x1="8" y1="23" x2="16" y2="23"/>
|
|
162
|
+
</svg>`;
|
|
163
|
+
|
|
164
|
+
const ICON_HANGUP = `
|
|
165
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
166
|
+
<path d="M10.68 13.31a16 16 0 0 0 3.41 2.6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7 2 2 0 0 1 1.72 2v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.42 19.42 0 0 1-3.33-2.67m-2.67-3.34a19.79 19.79 0 0 1-3.07-8.63A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91"/>
|
|
167
|
+
<line x1="23" y1="1" x2="1" y2="23"/>
|
|
168
|
+
</svg>`;
|
|
169
|
+
|
|
170
|
+
const ICON_AGENT_FALLBACK = `
|
|
171
|
+
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
172
|
+
<path d="M12 2a4 4 0 0 0-4 4v2a4 4 0 0 0 8 0V6a4 4 0 0 0-4-4zm6 8a1 1 0 0 0-2 0 4 4 0 0 1-8 0 1 1 0 0 0-2 0 6 6 0 0 0 5 5.91V19H8a1 1 0 0 0 0 2h8a1 1 0 0 0 0-2h-3v-3.09A6 6 0 0 0 18 10z"/>
|
|
173
|
+
</svg>`;
|
|
174
|
+
|
|
175
|
+
const ICON_GLOBE = `
|
|
176
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
177
|
+
<circle cx="12" cy="12" r="9"/>
|
|
178
|
+
<path d="M3 12h18"/>
|
|
179
|
+
<path d="M12 3a13.5 13.5 0 0 1 0 18"/>
|
|
180
|
+
<path d="M12 3a13.5 13.5 0 0 0 0 18"/>
|
|
181
|
+
</svg>`;
|
|
182
|
+
|
|
183
|
+
const ICON_CHEVRON = `
|
|
184
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
185
|
+
<polyline points="6 9 12 15 18 9"/>
|
|
186
|
+
</svg>`;
|
|
187
|
+
|
|
188
|
+
const ICON_CHECK = `
|
|
189
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
|
190
|
+
<polyline points="20 6 9 17 4 12"/>
|
|
191
|
+
</svg>`;
|
|
192
|
+
|
|
193
|
+
const ICON_SEND = `
|
|
194
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
195
|
+
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
196
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
197
|
+
</svg>`;
|
|
198
|
+
|
|
199
|
+
const ICON_SETTINGS = `
|
|
200
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
201
|
+
<circle cx="12" cy="12" r="3"/>
|
|
202
|
+
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
|
203
|
+
</svg>`;
|
|
204
|
+
|
|
205
|
+
export function buildUI(
|
|
206
|
+
rootId: string,
|
|
207
|
+
opts: {
|
|
208
|
+
inline?: boolean;
|
|
209
|
+
parent?: HTMLElement;
|
|
210
|
+
closeButtonHide?: boolean;
|
|
211
|
+
} = {},
|
|
212
|
+
): UIRefs {
|
|
213
|
+
const host = document.createElement("div");
|
|
214
|
+
host.id = rootId;
|
|
215
|
+
if (opts.closeButtonHide) {
|
|
216
|
+
host.setAttribute("data-close-button-hide", "");
|
|
217
|
+
}
|
|
218
|
+
if (opts.inline) {
|
|
219
|
+
// Fill the embedding container (which must be a positioned ancestor)
|
|
220
|
+
// rather than floating in a viewport corner.
|
|
221
|
+
host.setAttribute("data-inline", "");
|
|
222
|
+
host.style.position = "absolute";
|
|
223
|
+
host.style.inset = "0";
|
|
224
|
+
host.style.zIndex = "1";
|
|
225
|
+
} else {
|
|
226
|
+
host.style.position = "fixed";
|
|
227
|
+
host.style.inset = "auto";
|
|
228
|
+
host.style.zIndex = "2147483647";
|
|
229
|
+
}
|
|
230
|
+
(opts.parent ?? document.body).appendChild(host);
|
|
231
|
+
|
|
232
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
233
|
+
|
|
234
|
+
const style = document.createElement("style");
|
|
235
|
+
style.textContent = stylesheet;
|
|
236
|
+
shadow.appendChild(style);
|
|
237
|
+
|
|
238
|
+
const fab = document.createElement("button");
|
|
239
|
+
fab.className = "fab";
|
|
240
|
+
fab.setAttribute("aria-label", "Open voice agent");
|
|
241
|
+
// Hidden until appearance has loaded — avoids a flash of defaults.
|
|
242
|
+
fab.style.visibility = "hidden";
|
|
243
|
+
fab.innerHTML = `
|
|
244
|
+
<span class="fab-icon">${ICON_FAB}</span>
|
|
245
|
+
<span class="fab-text">
|
|
246
|
+
<span class="fab-label"></span>
|
|
247
|
+
<span class="fab-sublabel"></span>
|
|
248
|
+
</span>
|
|
249
|
+
`;
|
|
250
|
+
shadow.appendChild(fab);
|
|
251
|
+
|
|
252
|
+
const panel = document.createElement("div");
|
|
253
|
+
panel.className = "panel";
|
|
254
|
+
panel.style.display = "none";
|
|
255
|
+
panel.innerHTML = `
|
|
256
|
+
<button class="close" aria-label="Close">${ICON_CLOSE}</button>
|
|
257
|
+
|
|
258
|
+
<section class="screen screen-welcome" data-screen="welcome">
|
|
259
|
+
<div class="welcome-inner">
|
|
260
|
+
<div class="welcome-logo">
|
|
261
|
+
<div class="welcome-logo-fallback">${ICON_AGENT_FALLBACK}</div>
|
|
262
|
+
<img class="welcome-logo-img" alt="" style="display:none" />
|
|
263
|
+
</div>
|
|
264
|
+
<div class="welcome-status"><span class="welcome-status-dot"></span><span class="welcome-status-text">Online</span></div>
|
|
265
|
+
<h1 class="welcome-name">Assistant</h1>
|
|
266
|
+
<p class="welcome-desc">Tap below to start a voice conversation.</p>
|
|
267
|
+
<div class="lang-picker" data-open="false">
|
|
268
|
+
<button class="lang-trigger" type="button" aria-haspopup="listbox" aria-expanded="false">
|
|
269
|
+
<span class="lang-trigger-icon">${ICON_GLOBE}</span>
|
|
270
|
+
<span class="lang-trigger-text">
|
|
271
|
+
<span class="lang-trigger-eyebrow">Language</span>
|
|
272
|
+
<span class="lang-trigger-label">English</span>
|
|
273
|
+
</span>
|
|
274
|
+
<span class="lang-trigger-code">EN</span>
|
|
275
|
+
<span class="lang-trigger-chev">${ICON_CHEVRON}</span>
|
|
276
|
+
</button>
|
|
277
|
+
<div class="lang-menu" role="listbox" hidden></div>
|
|
278
|
+
</div>
|
|
279
|
+
<button class="start-btn" aria-label="Start call">
|
|
280
|
+
<span class="start-btn-icon">${ICON_PHONE}</span>
|
|
281
|
+
<span class="start-btn-label">Start Call</span>
|
|
282
|
+
</button>
|
|
283
|
+
<div class="consent" hidden>
|
|
284
|
+
<span class="consent-text"></span>
|
|
285
|
+
<a class="consent-link" target="_blank" rel="noopener noreferrer"></a>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
<a class="welcome-foot" target="_blank" rel="noopener noreferrer"></a>
|
|
289
|
+
</section>
|
|
290
|
+
|
|
291
|
+
<section class="screen screen-form" data-screen="form" style="display:none">
|
|
292
|
+
<header class="form-header">
|
|
293
|
+
<button class="form-back" type="button" aria-label="Back">${ICON_CHEVRON}</button>
|
|
294
|
+
<div class="form-header-text">
|
|
295
|
+
<div class="form-eyebrow">Review before submit</div>
|
|
296
|
+
<div class="form-title">Form</div>
|
|
297
|
+
</div>
|
|
298
|
+
<div class="form-call-controls" hidden>
|
|
299
|
+
<button class="form-mute" type="button" aria-label="Mute">${ICON_MIC_ON}</button>
|
|
300
|
+
<button class="form-end" type="button" aria-label="End call">${ICON_HANGUP}</button>
|
|
301
|
+
</div>
|
|
302
|
+
</header>
|
|
303
|
+
<div class="form-transcript" hidden aria-live="polite"></div>
|
|
304
|
+
<div class="form-stepper" hidden></div>
|
|
305
|
+
<div class="form-body">
|
|
306
|
+
<p class="form-subtitle"></p>
|
|
307
|
+
<div class="form-fields"></div>
|
|
308
|
+
<div class="form-error" hidden></div>
|
|
309
|
+
<div class="form-success" hidden></div>
|
|
310
|
+
</div>
|
|
311
|
+
<footer class="form-actions">
|
|
312
|
+
<button class="form-step-back" type="button" hidden>Back</button>
|
|
313
|
+
<button class="form-cancel" type="button">Cancel</button>
|
|
314
|
+
<button class="form-submit" type="button">
|
|
315
|
+
<span class="form-submit-label">Confirm & send</span>
|
|
316
|
+
</button>
|
|
317
|
+
</footer>
|
|
318
|
+
</section>
|
|
319
|
+
|
|
320
|
+
<section class="screen screen-call" data-screen="call" style="display:none">
|
|
321
|
+
<header class="call-header">
|
|
322
|
+
<div class="call-id">
|
|
323
|
+
<div class="call-logo">
|
|
324
|
+
<div class="call-logo-fallback">${ICON_AGENT_FALLBACK}</div>
|
|
325
|
+
<img class="call-logo-img" alt="" style="display:none" />
|
|
326
|
+
</div>
|
|
327
|
+
<div class="call-id-text">
|
|
328
|
+
<div class="call-name">Assistant</div>
|
|
329
|
+
<div class="call-status">Connecting…</div>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="call-timer" hidden aria-live="polite">
|
|
333
|
+
<span class="call-timer-text">0:00</span>
|
|
334
|
+
</div>
|
|
335
|
+
</header>
|
|
336
|
+
|
|
337
|
+
<div class="call-stage">
|
|
338
|
+
<div class="orb">
|
|
339
|
+
<span class="orb-ring orb-ring-1"></span>
|
|
340
|
+
<span class="orb-ring orb-ring-2"></span>
|
|
341
|
+
<span class="orb-ring orb-ring-3"></span>
|
|
342
|
+
<span class="orb-core"></span>
|
|
343
|
+
</div>
|
|
344
|
+
<div class="orb-label">Idle</div>
|
|
345
|
+
<div class="agent-status-line" aria-live="polite" hidden></div>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div class="transcript" aria-live="polite" style="display: none;"></div>
|
|
349
|
+
|
|
350
|
+
<div class="text-input-row" hidden>
|
|
351
|
+
<textarea class="text-input" placeholder="Type a message…" rows="1" aria-label="Type a message"></textarea>
|
|
352
|
+
<button class="text-send-btn" type="button" aria-label="Send message">${ICON_SEND}</button>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
<div class="call-controls">
|
|
356
|
+
<button class="ctl-btn ctl-mute" aria-label="Mute" disabled>
|
|
357
|
+
${ICON_MIC_ON}
|
|
358
|
+
</button>
|
|
359
|
+
<button class="ctl-btn ctl-settings" aria-label="Audio settings" hidden>
|
|
360
|
+
${ICON_SETTINGS}
|
|
361
|
+
</button>
|
|
362
|
+
<button class="ctl-btn ctl-end" aria-label="End call" disabled>
|
|
363
|
+
${ICON_HANGUP}
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
|
|
367
|
+
<div class="audio-drawer" hidden aria-label="Audio settings">
|
|
368
|
+
<header class="audio-drawer-head">
|
|
369
|
+
<div class="audio-drawer-title">Audio settings</div>
|
|
370
|
+
<button class="audio-drawer-close" type="button" aria-label="Close audio settings">${ICON_CLOSE}</button>
|
|
371
|
+
</header>
|
|
372
|
+
<div class="audio-drawer-body">
|
|
373
|
+
<div class="audio-row audio-row-meter">
|
|
374
|
+
<span class="audio-row-label">Microphone level</span>
|
|
375
|
+
<div class="audio-meter"><div class="audio-meter-bar"></div></div>
|
|
376
|
+
</div>
|
|
377
|
+
<label class="audio-row audio-row-select">
|
|
378
|
+
<span class="audio-row-label">Microphone</span>
|
|
379
|
+
<select class="audio-mic-select"></select>
|
|
380
|
+
</label>
|
|
381
|
+
<label class="audio-row audio-row-select audio-row-speaker">
|
|
382
|
+
<span class="audio-row-label">Speaker</span>
|
|
383
|
+
<select class="audio-speaker-select"></select>
|
|
384
|
+
</label>
|
|
385
|
+
<label class="audio-row audio-row-slider">
|
|
386
|
+
<span class="audio-row-label">Speaker volume <span class="audio-volume-value">85%</span></span>
|
|
387
|
+
<input class="audio-volume" type="range" min="0" max="100" step="1" value="85" />
|
|
388
|
+
</label>
|
|
389
|
+
<label class="audio-row audio-row-select">
|
|
390
|
+
<span class="audio-row-label">Noise cancellation engine</span>
|
|
391
|
+
<select class="audio-nc-engine">
|
|
392
|
+
<option value="off">Off (browser only)</option>
|
|
393
|
+
<option value="krisp">Krisp (default)</option>
|
|
394
|
+
<option value="deepfilter">DeepFilterNet3</option>
|
|
395
|
+
</select>
|
|
396
|
+
</label>
|
|
397
|
+
<label class="audio-row audio-row-slider audio-row-df-strength" hidden>
|
|
398
|
+
<span class="audio-row-label">DeepFilter strength <span class="audio-df-strength-value">80</span></span>
|
|
399
|
+
<input class="audio-df-strength" type="range" min="0" max="100" step="1" value="80" />
|
|
400
|
+
</label>
|
|
401
|
+
<div class="audio-toggles">
|
|
402
|
+
<label class="audio-toggle">
|
|
403
|
+
<span class="audio-toggle-text">
|
|
404
|
+
<span class="audio-toggle-label">Echo cancellation</span>
|
|
405
|
+
<span class="audio-toggle-hint">Browser AEC</span>
|
|
406
|
+
</span>
|
|
407
|
+
<input class="audio-tog-aec" type="checkbox" />
|
|
408
|
+
</label>
|
|
409
|
+
<label class="audio-toggle">
|
|
410
|
+
<span class="audio-toggle-text">
|
|
411
|
+
<span class="audio-toggle-label">Noise suppression</span>
|
|
412
|
+
<span class="audio-toggle-hint">Browser noise filter</span>
|
|
413
|
+
</span>
|
|
414
|
+
<input class="audio-tog-ns" type="checkbox" />
|
|
415
|
+
</label>
|
|
416
|
+
<label class="audio-toggle">
|
|
417
|
+
<span class="audio-toggle-text">
|
|
418
|
+
<span class="audio-toggle-label">Auto gain</span>
|
|
419
|
+
<span class="audio-toggle-hint">Normalize my volume</span>
|
|
420
|
+
</span>
|
|
421
|
+
<input class="audio-tog-agc" type="checkbox" />
|
|
422
|
+
</label>
|
|
423
|
+
<label class="audio-toggle audio-toggle-vi">
|
|
424
|
+
<span class="audio-toggle-text">
|
|
425
|
+
<span class="audio-toggle-label">Voice isolation</span>
|
|
426
|
+
<span class="audio-toggle-hint">Edge/Chrome only</span>
|
|
427
|
+
</span>
|
|
428
|
+
<input class="audio-tog-vi" type="checkbox" />
|
|
429
|
+
</label>
|
|
430
|
+
<label class="audio-toggle">
|
|
431
|
+
<span class="audio-toggle-text">
|
|
432
|
+
<span class="audio-toggle-label">Headphones mode</span>
|
|
433
|
+
<span class="audio-toggle-hint">Disable mic ducking</span>
|
|
434
|
+
</span>
|
|
435
|
+
<input class="audio-tog-hp" type="checkbox" />
|
|
436
|
+
</label>
|
|
437
|
+
<label class="audio-toggle">
|
|
438
|
+
<span class="audio-toggle-text">
|
|
439
|
+
<span class="audio-toggle-label">Live Transcription</span>
|
|
440
|
+
<span class="audio-toggle-hint">Show conversation text</span>
|
|
441
|
+
</span>
|
|
442
|
+
<input class="audio-tog-transcription" type="checkbox" />
|
|
443
|
+
</label>
|
|
444
|
+
<label class="audio-toggle">
|
|
445
|
+
<span class="audio-toggle-text">
|
|
446
|
+
<span class="audio-toggle-label">Text Input</span>
|
|
447
|
+
<span class="audio-toggle-hint">Type messages to the agent</span>
|
|
448
|
+
</span>
|
|
449
|
+
<input class="audio-tog-text-input" type="checkbox" />
|
|
450
|
+
</label>
|
|
451
|
+
</div>
|
|
452
|
+
<details class="audio-diag">
|
|
453
|
+
<summary>Diagnostics</summary>
|
|
454
|
+
<dl class="audio-diag-list">
|
|
455
|
+
<dt>NC engine</dt><dd class="audio-diag-engine">—</dd>
|
|
456
|
+
<dt>Echo cancellation</dt><dd class="audio-diag-aec">—</dd>
|
|
457
|
+
<dt>Noise suppression</dt><dd class="audio-diag-ns">—</dd>
|
|
458
|
+
<dt>Auto gain</dt><dd class="audio-diag-agc">—</dd>
|
|
459
|
+
<dt>Voice isolation</dt><dd class="audio-diag-vi">—</dd>
|
|
460
|
+
<dt>Sample rate</dt><dd class="audio-diag-sr">—</dd>
|
|
461
|
+
<dt>Packet loss</dt><dd class="audio-diag-loss">—</dd>
|
|
462
|
+
<dt>Jitter</dt><dd class="audio-diag-jitter">—</dd>
|
|
463
|
+
<dt>RTT</dt><dd class="audio-diag-rtt">—</dd>
|
|
464
|
+
</dl>
|
|
465
|
+
</details>
|
|
466
|
+
</div>
|
|
467
|
+
</div>
|
|
468
|
+
</section>
|
|
469
|
+
`;
|
|
470
|
+
shadow.appendChild(panel);
|
|
471
|
+
|
|
472
|
+
const q = <T extends Element>(sel: string) => panel.querySelector(sel) as T;
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
host,
|
|
476
|
+
shadow,
|
|
477
|
+
fab,
|
|
478
|
+
panel,
|
|
479
|
+
closeBtn: q<HTMLButtonElement>(".close"),
|
|
480
|
+
|
|
481
|
+
welcomeScreen: q<HTMLDivElement>(".screen-welcome"),
|
|
482
|
+
welcomeLogo: q<HTMLImageElement>(".welcome-logo-img"),
|
|
483
|
+
welcomeLogoFallback: q<HTMLDivElement>(".welcome-logo-fallback"),
|
|
484
|
+
welcomeName: q<HTMLDivElement>(".welcome-name"),
|
|
485
|
+
welcomeDesc: q<HTMLDivElement>(".welcome-desc"),
|
|
486
|
+
langPicker: q<HTMLDivElement>(".lang-picker"),
|
|
487
|
+
langTrigger: q<HTMLButtonElement>(".lang-trigger"),
|
|
488
|
+
langTriggerLabel: q<HTMLSpanElement>(".lang-trigger-label"),
|
|
489
|
+
langTriggerCode: q<HTMLSpanElement>(".lang-trigger-code"),
|
|
490
|
+
langMenu: q<HTMLDivElement>(".lang-menu"),
|
|
491
|
+
startBtn: q<HTMLButtonElement>(".start-btn"),
|
|
492
|
+
startBtnLabel: q<HTMLSpanElement>(".start-btn-label"),
|
|
493
|
+
consent: q<HTMLDivElement>(".consent"),
|
|
494
|
+
consentText: q<HTMLSpanElement>(".consent-text"),
|
|
495
|
+
consentLink: q<HTMLAnchorElement>(".consent-link"),
|
|
496
|
+
poweredBy: q<HTMLAnchorElement>(".welcome-foot"),
|
|
497
|
+
|
|
498
|
+
callScreen: q<HTMLDivElement>(".screen-call"),
|
|
499
|
+
callLogo: q<HTMLImageElement>(".call-logo-img"),
|
|
500
|
+
callLogoFallback: q<HTMLDivElement>(".call-logo-fallback"),
|
|
501
|
+
callName: q<HTMLDivElement>(".call-name"),
|
|
502
|
+
callStatus: q<HTMLDivElement>(".call-status"),
|
|
503
|
+
callTimer: q<HTMLDivElement>(".call-timer"),
|
|
504
|
+
callTimerText: q<HTMLSpanElement>(".call-timer-text"),
|
|
505
|
+
orb: q<HTMLDivElement>(".orb"),
|
|
506
|
+
orbLabel: q<HTMLDivElement>(".orb-label"),
|
|
507
|
+
agentStatusLine: q<HTMLDivElement>(".agent-status-line"),
|
|
508
|
+
transcript: q<HTMLDivElement>(".transcript"),
|
|
509
|
+
transcriptSegments: new Map<string, HTMLDivElement>(),
|
|
510
|
+
transcriptInterimBubble: new Map<"user" | "agent", HTMLDivElement>(),
|
|
511
|
+
textInputRow: q<HTMLDivElement>(".text-input-row"),
|
|
512
|
+
textInput: q<HTMLTextAreaElement>(".text-input"),
|
|
513
|
+
textSendBtn: q<HTMLButtonElement>(".text-send-btn"),
|
|
514
|
+
muteBtn: q<HTMLButtonElement>(".ctl-mute"),
|
|
515
|
+
endBtn: q<HTMLButtonElement>(".ctl-end"),
|
|
516
|
+
settingsBtn: q<HTMLButtonElement>(".ctl-settings"),
|
|
517
|
+
|
|
518
|
+
audioDrawer: q<HTMLDivElement>(".audio-drawer"),
|
|
519
|
+
audioDrawerClose: q<HTMLButtonElement>(".audio-drawer-close"),
|
|
520
|
+
audioMeterBar: q<HTMLDivElement>(".audio-meter-bar"),
|
|
521
|
+
audioMicSelect: q<HTMLSelectElement>(".audio-mic-select"),
|
|
522
|
+
audioSpeakerSelect: q<HTMLSelectElement>(".audio-speaker-select"),
|
|
523
|
+
audioSpeakerRow: q<HTMLLabelElement>(".audio-row-speaker"),
|
|
524
|
+
audioVolume: q<HTMLInputElement>(".audio-volume"),
|
|
525
|
+
audioVolumeValue: q<HTMLSpanElement>(".audio-volume-value"),
|
|
526
|
+
audioNcEngine: q<HTMLSelectElement>(".audio-nc-engine"),
|
|
527
|
+
audioDfStrengthRow: q<HTMLLabelElement>(".audio-row-df-strength"),
|
|
528
|
+
audioDfStrength: q<HTMLInputElement>(".audio-df-strength"),
|
|
529
|
+
audioDfStrengthValue: q<HTMLSpanElement>(".audio-df-strength-value"),
|
|
530
|
+
audioTogAec: q<HTMLInputElement>(".audio-tog-aec"),
|
|
531
|
+
audioTogNs: q<HTMLInputElement>(".audio-tog-ns"),
|
|
532
|
+
audioTogAgc: q<HTMLInputElement>(".audio-tog-agc"),
|
|
533
|
+
audioTogVi: q<HTMLInputElement>(".audio-tog-vi"),
|
|
534
|
+
audioTogViRow: q<HTMLLabelElement>(".audio-toggle-vi"),
|
|
535
|
+
audioTogHp: q<HTMLInputElement>(".audio-tog-hp"),
|
|
536
|
+
audioTogTranscription: q<HTMLInputElement>(".audio-tog-transcription"),
|
|
537
|
+
audioTogTextInput: q<HTMLInputElement>(".audio-tog-text-input"),
|
|
538
|
+
audioDiagEngine: q<HTMLElement>(".audio-diag-engine"),
|
|
539
|
+
audioDiagAec: q<HTMLElement>(".audio-diag-aec"),
|
|
540
|
+
audioDiagNs: q<HTMLElement>(".audio-diag-ns"),
|
|
541
|
+
audioDiagAgc: q<HTMLElement>(".audio-diag-agc"),
|
|
542
|
+
audioDiagVi: q<HTMLElement>(".audio-diag-vi"),
|
|
543
|
+
audioDiagSr: q<HTMLElement>(".audio-diag-sr"),
|
|
544
|
+
audioDiagLoss: q<HTMLElement>(".audio-diag-loss"),
|
|
545
|
+
audioDiagJitter: q<HTMLElement>(".audio-diag-jitter"),
|
|
546
|
+
audioDiagRtt: q<HTMLElement>(".audio-diag-rtt"),
|
|
547
|
+
|
|
548
|
+
formScreen: q<HTMLDivElement>(".screen-form"),
|
|
549
|
+
formTranscript: q<HTMLDivElement>(".form-transcript"),
|
|
550
|
+
formTranscriptSegments: new Map<string, HTMLDivElement>(),
|
|
551
|
+
formTranscriptInterimBubble: new Map<"user" | "agent", HTMLDivElement>(),
|
|
552
|
+
formTitle: q<HTMLDivElement>(".form-title"),
|
|
553
|
+
formEyebrow: q<HTMLDivElement>(".form-eyebrow"),
|
|
554
|
+
formSubtitle: q<HTMLDivElement>(".form-subtitle"),
|
|
555
|
+
formStepper: q<HTMLDivElement>(".form-stepper"),
|
|
556
|
+
formFields: q<HTMLDivElement>(".form-fields"),
|
|
557
|
+
formError: q<HTMLDivElement>(".form-error"),
|
|
558
|
+
formSuccess: q<HTMLDivElement>(".form-success"),
|
|
559
|
+
formSubmitBtn: q<HTMLButtonElement>(".form-submit"),
|
|
560
|
+
formBackBtn: q<HTMLButtonElement>(".form-back"),
|
|
561
|
+
formCancelBtn: q<HTMLButtonElement>(".form-cancel"),
|
|
562
|
+
formStepBackBtn: q<HTMLButtonElement>(".form-step-back"),
|
|
563
|
+
formCallControls: q<HTMLDivElement>(".form-call-controls"),
|
|
564
|
+
formMuteBtn: q<HTMLButtonElement>(".form-mute"),
|
|
565
|
+
formEndBtn: q<HTMLButtonElement>(".form-end"),
|
|
566
|
+
formInputs: new Map(),
|
|
567
|
+
|
|
568
|
+
appearance: DEFAULT_APPEARANCE,
|
|
569
|
+
selectedLanguage: DEFAULT_APPEARANCE.default_language,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function hostStyleVar(name: string, value: string) {
|
|
574
|
+
return `${name}: ${value};`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function applyAppearance(refs: UIRefs, appearance: AppearanceConfig) {
|
|
578
|
+
refs.appearance = appearance;
|
|
579
|
+
|
|
580
|
+
const t = appearance.theme;
|
|
581
|
+
const d = appearance.dimensions;
|
|
582
|
+
const primaryText = contrastingText(t.primary_color);
|
|
583
|
+
const accentText = contrastingText(t.accent_color);
|
|
584
|
+
|
|
585
|
+
const vars = [
|
|
586
|
+
hostStyleVar("--va-primary", t.primary_color),
|
|
587
|
+
hostStyleVar("--va-primary-text", primaryText),
|
|
588
|
+
hostStyleVar("--va-accent", t.accent_color),
|
|
589
|
+
hostStyleVar("--va-accent-text", accentText),
|
|
590
|
+
hostStyleVar("--va-background", t.background_color),
|
|
591
|
+
hostStyleVar("--va-surface", surfaceFromBackground(t.background_color)),
|
|
592
|
+
hostStyleVar("--va-text", t.text_color),
|
|
593
|
+
hostStyleVar("--va-text-muted", muteText(t.text_color, t.background_color)),
|
|
594
|
+
hostStyleVar("--va-user-bubble", t.user_bubble_color),
|
|
595
|
+
hostStyleVar("--va-user-bubble-text", t.user_bubble_text_color),
|
|
596
|
+
hostStyleVar("--va-agent-bubble", t.agent_bubble_color),
|
|
597
|
+
hostStyleVar("--va-agent-bubble-text", t.agent_bubble_text_color),
|
|
598
|
+
hostStyleVar("--va-fab-size", `${d.fab_size}px`),
|
|
599
|
+
hostStyleVar("--va-panel-width", `${d.panel_width}px`),
|
|
600
|
+
hostStyleVar("--va-panel-height", `${d.panel_height}px`),
|
|
601
|
+
hostStyleVar("--va-border-radius", `${d.border_radius}px`),
|
|
602
|
+
hostStyleVar("--va-font", appearance.layout.font_family),
|
|
603
|
+
];
|
|
604
|
+
const inline = refs.host.hasAttribute("data-inline");
|
|
605
|
+
refs.host.style.cssText = inline
|
|
606
|
+
? `position: absolute; inset: 0; z-index: 1; ${vars.join(" ")}`
|
|
607
|
+
: `position: fixed; inset: auto; z-index: 2147483647; ${vars.join(" ")}`;
|
|
608
|
+
// Corner anchoring is meaningless inline (the panel fills the host).
|
|
609
|
+
if (!inline) refs.host.setAttribute("data-position", appearance.layout.position);
|
|
610
|
+
|
|
611
|
+
refs.transcript.setAttribute(
|
|
612
|
+
"data-placeholder",
|
|
613
|
+
appearance.labels.transcript_placeholder ||
|
|
614
|
+
DEFAULT_APPEARANCE.labels.transcript_placeholder,
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
const name = appearance.name || DEFAULT_APPEARANCE.name;
|
|
618
|
+
const desc = appearance.subtitle || DEFAULT_APPEARANCE.subtitle;
|
|
619
|
+
|
|
620
|
+
refs.welcomeName.textContent = name;
|
|
621
|
+
refs.welcomeDesc.textContent = desc;
|
|
622
|
+
refs.callName.textContent = name;
|
|
623
|
+
refs.fab.setAttribute("aria-label", `Open ${name}`);
|
|
624
|
+
|
|
625
|
+
refs.startBtnLabel.textContent =
|
|
626
|
+
appearance.start_button_text || DEFAULT_APPEARANCE.start_button_text;
|
|
627
|
+
refs.startBtn.setAttribute(
|
|
628
|
+
"aria-label",
|
|
629
|
+
appearance.start_button_text || DEFAULT_APPEARANCE.start_button_text,
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
const poweredByText = appearance.powered_by_text ?? "";
|
|
633
|
+
refs.poweredBy.textContent = poweredByText;
|
|
634
|
+
refs.poweredBy.style.display = poweredByText ? "" : "none";
|
|
635
|
+
const poweredByHref = safeHref(appearance.powered_by_url);
|
|
636
|
+
if (poweredByHref) {
|
|
637
|
+
refs.poweredBy.href = poweredByHref;
|
|
638
|
+
refs.poweredBy.classList.add("is-link");
|
|
639
|
+
} else {
|
|
640
|
+
refs.poweredBy.removeAttribute("href");
|
|
641
|
+
refs.poweredBy.classList.remove("is-link");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const logoSrc = safeImageSrc(appearance.logo_url);
|
|
645
|
+
const hasLogo = Boolean(
|
|
646
|
+
logoSrc && logoSrc !== "https://example.com/logo.png",
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
if (hasLogo) {
|
|
650
|
+
refs.welcomeLogo.src = logoSrc;
|
|
651
|
+
refs.welcomeLogo.alt = name;
|
|
652
|
+
refs.welcomeLogo.style.display = "";
|
|
653
|
+
refs.welcomeLogoFallback.style.display = "none";
|
|
654
|
+
|
|
655
|
+
refs.callLogo.src = logoSrc;
|
|
656
|
+
refs.callLogo.alt = name;
|
|
657
|
+
refs.callLogo.style.display = "";
|
|
658
|
+
refs.callLogoFallback.style.display = "none";
|
|
659
|
+
} else {
|
|
660
|
+
refs.welcomeLogo.removeAttribute("src");
|
|
661
|
+
refs.welcomeLogo.style.display = "none";
|
|
662
|
+
refs.welcomeLogoFallback.style.display = "";
|
|
663
|
+
|
|
664
|
+
refs.callLogo.removeAttribute("src");
|
|
665
|
+
refs.callLogo.style.display = "none";
|
|
666
|
+
refs.callLogoFallback.style.display = "";
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
applyFabContent(refs, appearance, hasLogo, name);
|
|
670
|
+
renderLanguagePicker(refs, appearance);
|
|
671
|
+
renderConsent(refs, appearance);
|
|
672
|
+
|
|
673
|
+
// Reveal the FAB now that real values are in place.
|
|
674
|
+
refs.fab.style.visibility = "";
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function renderConsent(refs: UIRefs, appearance: AppearanceConfig) {
|
|
678
|
+
const url = (appearance.terms_url || "").trim();
|
|
679
|
+
if (!url) {
|
|
680
|
+
refs.consent.hidden = true;
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
refs.consent.hidden = false;
|
|
684
|
+
|
|
685
|
+
const prefix =
|
|
686
|
+
appearance.consent_text || DEFAULT_APPEARANCE.consent_text;
|
|
687
|
+
const label =
|
|
688
|
+
appearance.terms_label || DEFAULT_APPEARANCE.terms_label;
|
|
689
|
+
|
|
690
|
+
refs.consentText.textContent = `${prefix} `;
|
|
691
|
+
refs.consentLink.textContent = label;
|
|
692
|
+
refs.consentLink.href = safeHref(url);
|
|
693
|
+
refs.consentLink.setAttribute("aria-label", label);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function renderLanguagePicker(refs: UIRefs, appearance: AppearanceConfig) {
|
|
697
|
+
const languages = appearance.languages.length
|
|
698
|
+
? appearance.languages
|
|
699
|
+
: DEFAULT_APPEARANCE.languages;
|
|
700
|
+
|
|
701
|
+
const previous = refs.selectedLanguage;
|
|
702
|
+
const initial =
|
|
703
|
+
languages.find((l) => l.code === previous) ??
|
|
704
|
+
languages.find((l) => l.code === appearance.default_language) ??
|
|
705
|
+
languages[0];
|
|
706
|
+
|
|
707
|
+
refs.selectedLanguage = initial.code;
|
|
708
|
+
|
|
709
|
+
if (languages.length <= 1) {
|
|
710
|
+
refs.langPicker.style.display = "none";
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
refs.langPicker.style.display = "";
|
|
714
|
+
|
|
715
|
+
const eyebrowEl = refs.langTrigger.querySelector(
|
|
716
|
+
".lang-trigger-eyebrow",
|
|
717
|
+
) as HTMLSpanElement | null;
|
|
718
|
+
if (eyebrowEl) {
|
|
719
|
+
eyebrowEl.textContent =
|
|
720
|
+
appearance.labels.language_label ||
|
|
721
|
+
DEFAULT_APPEARANCE.labels.language_label;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
refs.langMenu.innerHTML = "";
|
|
725
|
+
for (const lang of languages) {
|
|
726
|
+
const opt = document.createElement("button");
|
|
727
|
+
opt.type = "button";
|
|
728
|
+
opt.className = "lang-option";
|
|
729
|
+
opt.setAttribute("role", "option");
|
|
730
|
+
opt.dataset.code = lang.code;
|
|
731
|
+
opt.innerHTML = `
|
|
732
|
+
<span class="lang-option-text">
|
|
733
|
+
<span class="lang-option-native">${escapeHtml(lang.native_label)}</span>
|
|
734
|
+
<span class="lang-option-label">${escapeHtml(lang.label)}</span>
|
|
735
|
+
</span>
|
|
736
|
+
<span class="lang-option-code">${escapeHtml(lang.code.toUpperCase())}</span>
|
|
737
|
+
<span class="lang-option-check">${ICON_CHECK}</span>
|
|
738
|
+
`;
|
|
739
|
+
opt.addEventListener("click", (e) => {
|
|
740
|
+
e.stopPropagation();
|
|
741
|
+
selectLanguage(refs, languages, lang.code);
|
|
742
|
+
closeLanguageMenu(refs);
|
|
743
|
+
});
|
|
744
|
+
refs.langMenu.appendChild(opt);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
selectLanguage(refs, languages, initial.code);
|
|
748
|
+
wireLanguageTrigger(refs);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function wireLanguageTrigger(refs: UIRefs) {
|
|
752
|
+
const picker = refs.langPicker;
|
|
753
|
+
if (picker.dataset.wired === "1") return;
|
|
754
|
+
picker.dataset.wired = "1";
|
|
755
|
+
|
|
756
|
+
refs.langTrigger.addEventListener("click", (e) => {
|
|
757
|
+
e.stopPropagation();
|
|
758
|
+
const open = picker.dataset.open === "true";
|
|
759
|
+
if (open) closeLanguageMenu(refs);
|
|
760
|
+
else openLanguageMenu(refs);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
refs.shadow.addEventListener("click", (e) => {
|
|
764
|
+
if (picker.dataset.open !== "true") return;
|
|
765
|
+
const target = e.target as Node | null;
|
|
766
|
+
if (target && picker.contains(target)) return;
|
|
767
|
+
closeLanguageMenu(refs);
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
refs.shadow.addEventListener("keydown", (e) => {
|
|
771
|
+
if ((e as KeyboardEvent).key === "Escape" && picker.dataset.open === "true") {
|
|
772
|
+
closeLanguageMenu(refs);
|
|
773
|
+
refs.langTrigger.focus();
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function openLanguageMenu(refs: UIRefs) {
|
|
779
|
+
refs.langPicker.dataset.open = "true";
|
|
780
|
+
refs.langMenu.hidden = false;
|
|
781
|
+
refs.langTrigger.setAttribute("aria-expanded", "true");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function closeLanguageMenu(refs: UIRefs) {
|
|
785
|
+
refs.langPicker.dataset.open = "false";
|
|
786
|
+
refs.langMenu.hidden = true;
|
|
787
|
+
refs.langTrigger.setAttribute("aria-expanded", "false");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function selectLanguage(
|
|
791
|
+
refs: UIRefs,
|
|
792
|
+
languages: AppearanceLanguage[],
|
|
793
|
+
code: string,
|
|
794
|
+
) {
|
|
795
|
+
const lang =
|
|
796
|
+
languages.find((l) => l.code === code) ??
|
|
797
|
+
languages[0];
|
|
798
|
+
refs.selectedLanguage = lang.code;
|
|
799
|
+
|
|
800
|
+
refs.langTriggerLabel.textContent = lang.native_label || lang.label;
|
|
801
|
+
refs.langTriggerCode.textContent = lang.code.toUpperCase();
|
|
802
|
+
refs.langTrigger.setAttribute(
|
|
803
|
+
"aria-label",
|
|
804
|
+
`${refs.appearance.labels.language_label || "Language"}: ${lang.label}`,
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
const options = refs.langMenu.querySelectorAll<HTMLButtonElement>(".lang-option");
|
|
808
|
+
options.forEach((el) => {
|
|
809
|
+
const isActive = el.dataset.code === lang.code;
|
|
810
|
+
el.classList.toggle("is-active", isActive);
|
|
811
|
+
el.setAttribute("aria-selected", isActive ? "true" : "false");
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function escapeHtml(s: string): string {
|
|
816
|
+
return s
|
|
817
|
+
.replace(/&/g, "&")
|
|
818
|
+
.replace(/</g, "<")
|
|
819
|
+
.replace(/>/g, ">")
|
|
820
|
+
.replace(/"/g, """)
|
|
821
|
+
.replace(/'/g, "'");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Return `url` only if it uses a safe protocol, else "". The widget runs in the
|
|
826
|
+
* host page's origin, and appearance config (powered_by_url / terms_url /
|
|
827
|
+
* logo_url) is tenant-controlled — without this, a `javascript:` URL assigned
|
|
828
|
+
* to an anchor href or image src is stored XSS against every visitor.
|
|
829
|
+
*/
|
|
830
|
+
function safeHref(url: string | undefined): string {
|
|
831
|
+
if (!url) return "";
|
|
832
|
+
try {
|
|
833
|
+
const proto = new URL(url, location.href).protocol;
|
|
834
|
+
return ["https:", "http:", "mailto:"].includes(proto) ? url : "";
|
|
835
|
+
} catch {
|
|
836
|
+
return "";
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Like safeHref but for image sources — http/https only (no data:/javascript:). */
|
|
841
|
+
function safeImageSrc(url: string | undefined): string {
|
|
842
|
+
if (!url) return "";
|
|
843
|
+
try {
|
|
844
|
+
const proto = new URL(url, location.href).protocol;
|
|
845
|
+
return ["https:", "http:"].includes(proto) ? url : "";
|
|
846
|
+
} catch {
|
|
847
|
+
return "";
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function renderInline(text: string): string {
|
|
852
|
+
return escapeHtml(text)
|
|
853
|
+
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
854
|
+
.replace(/__(.+?)__/g, "<strong>$1</strong>")
|
|
855
|
+
.replace(/\*(.+?)\*/g, "<em>$1</em>")
|
|
856
|
+
.replace(/_(.+?)_/g, "<em>$1</em>")
|
|
857
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
858
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function renderMarkdown(text: string): string {
|
|
862
|
+
const lines = text.split("\n");
|
|
863
|
+
const blocks: string[] = [];
|
|
864
|
+
let i = 0;
|
|
865
|
+
|
|
866
|
+
while (i < lines.length) {
|
|
867
|
+
const line = lines[i];
|
|
868
|
+
|
|
869
|
+
const h3 = line.match(/^### (.+)/);
|
|
870
|
+
const h2 = line.match(/^## (.+)/);
|
|
871
|
+
const h1 = line.match(/^# (.+)/);
|
|
872
|
+
if (h3) { blocks.push(`<h3>${renderInline(h3[1])}</h3>`); i++; continue; }
|
|
873
|
+
if (h2) { blocks.push(`<h2>${renderInline(h2[1])}</h2>`); i++; continue; }
|
|
874
|
+
if (h1) { blocks.push(`<h1>${renderInline(h1[1])}</h1>`); i++; continue; }
|
|
875
|
+
|
|
876
|
+
if (line.match(/^[-*] /)) {
|
|
877
|
+
const items: string[] = [];
|
|
878
|
+
while (i < lines.length && lines[i].match(/^[-*] /)) {
|
|
879
|
+
items.push(`<li>${renderInline(lines[i].slice(2))}</li>`);
|
|
880
|
+
i++;
|
|
881
|
+
}
|
|
882
|
+
blocks.push(`<ul>${items.join("")}</ul>`);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (line.match(/^\d+\. /)) {
|
|
887
|
+
const items: string[] = [];
|
|
888
|
+
while (i < lines.length && lines[i].match(/^\d+\. /)) {
|
|
889
|
+
items.push(`<li>${renderInline(lines[i].replace(/^\d+\. /, ""))}</li>`);
|
|
890
|
+
i++;
|
|
891
|
+
}
|
|
892
|
+
blocks.push(`<ol>${items.join("")}</ol>`);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (line.startsWith("```")) {
|
|
897
|
+
const codeLines: string[] = [];
|
|
898
|
+
i++;
|
|
899
|
+
while (i < lines.length && !lines[i].startsWith("```")) {
|
|
900
|
+
codeLines.push(escapeHtml(lines[i]));
|
|
901
|
+
i++;
|
|
902
|
+
}
|
|
903
|
+
if (i < lines.length) i++;
|
|
904
|
+
blocks.push(`<pre><code>${codeLines.join("\n")}</code></pre>`);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (!line.trim()) { i++; continue; }
|
|
909
|
+
|
|
910
|
+
const paraLines: string[] = [];
|
|
911
|
+
while (i < lines.length && lines[i].trim() && !lines[i].match(/^(#{1,3} |[-*] |\d+\. |```)/)) {
|
|
912
|
+
paraLines.push(renderInline(lines[i]));
|
|
913
|
+
i++;
|
|
914
|
+
}
|
|
915
|
+
if (paraLines.length) blocks.push(`<p>${paraLines.join("<br>")}</p>`);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
return blocks.join("");
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function applyFabContent(
|
|
922
|
+
refs: UIRefs,
|
|
923
|
+
appearance: AppearanceConfig,
|
|
924
|
+
hasLogo: boolean,
|
|
925
|
+
name: string,
|
|
926
|
+
) {
|
|
927
|
+
const label = (appearance.fab_label || "").trim();
|
|
928
|
+
const sublabel = (appearance.fab_sublabel || "").trim();
|
|
929
|
+
const iconEl = refs.fab.querySelector(".fab-icon") as HTMLSpanElement | null;
|
|
930
|
+
const textEl = refs.fab.querySelector(".fab-text") as HTMLSpanElement | null;
|
|
931
|
+
const labelEl = refs.fab.querySelector(".fab-label") as HTMLSpanElement | null;
|
|
932
|
+
const sublabelEl = refs.fab.querySelector(".fab-sublabel") as HTMLSpanElement | null;
|
|
933
|
+
|
|
934
|
+
if (!iconEl || !textEl || !labelEl || !sublabelEl) return;
|
|
935
|
+
|
|
936
|
+
if (hasLogo) {
|
|
937
|
+
iconEl.innerHTML = `<img class="fab-logo" alt="${escapeAttr(name)}" src="${escapeAttr(safeImageSrc(appearance.logo_url))}" />`;
|
|
938
|
+
} else {
|
|
939
|
+
iconEl.innerHTML = ICON_FAB;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (label) {
|
|
943
|
+
labelEl.textContent = label;
|
|
944
|
+
refs.fab.classList.add("has-label");
|
|
945
|
+
refs.fab.setAttribute(
|
|
946
|
+
"aria-label",
|
|
947
|
+
sublabel ? `${label}, ${sublabel} — open ${name}` : `${label} — open ${name}`,
|
|
948
|
+
);
|
|
949
|
+
} else {
|
|
950
|
+
labelEl.textContent = "";
|
|
951
|
+
refs.fab.classList.remove("has-label");
|
|
952
|
+
refs.fab.setAttribute("aria-label", `Open ${name}`);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (label && sublabel) {
|
|
956
|
+
sublabelEl.textContent = sublabel;
|
|
957
|
+
refs.fab.classList.add("has-sublabel");
|
|
958
|
+
} else {
|
|
959
|
+
sublabelEl.textContent = "";
|
|
960
|
+
refs.fab.classList.remove("has-sublabel");
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
textEl.style.display = label ? "" : "none";
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export function setScreen(refs: UIRefs, screen: Screen) {
|
|
967
|
+
refs.panel.setAttribute("data-screen", screen);
|
|
968
|
+
refs.welcomeScreen.style.display = screen === "welcome" ? "flex" : "none";
|
|
969
|
+
refs.callScreen.style.display = screen === "call" ? "flex" : "none";
|
|
970
|
+
refs.formScreen.style.display = screen === "form" ? "flex" : "none";
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export interface RenderFormArgs {
|
|
974
|
+
definition: FormDefinition;
|
|
975
|
+
values: Record<string, string>;
|
|
976
|
+
/** Label for the cancel/back button — "Back to call" while connected, etc. */
|
|
977
|
+
cancelLabel?: string;
|
|
978
|
+
/** Which step (0-indexed) to render; ignored when the form has no `steps`. */
|
|
979
|
+
stepIndex?: number;
|
|
980
|
+
/** Fired whenever the user edits a field. Used to push state to the agent. */
|
|
981
|
+
onFieldChange?: () => void;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Render a form definition's fields into the form screen. Existing values are
|
|
986
|
+
* read from `values`; the on-screen inputs are kept in `refs.formInputs` so
|
|
987
|
+
* the controller can pull current values back out at submit time.
|
|
988
|
+
*/
|
|
989
|
+
export function renderForm(refs: UIRefs, args: RenderFormArgs) {
|
|
990
|
+
const { definition, values, cancelLabel } = args;
|
|
991
|
+
const steps = totalSteps(definition);
|
|
992
|
+
const stepIndex = Math.max(0, Math.min(args.stepIndex ?? 0, steps - 1));
|
|
993
|
+
const isStepper = steps > 1;
|
|
994
|
+
const isFinalStep = stepIndex === steps - 1;
|
|
995
|
+
const currentStep = definition.steps?.[stepIndex];
|
|
996
|
+
|
|
997
|
+
refs.formTitle.textContent = currentStep?.title ?? definition.title;
|
|
998
|
+
refs.formEyebrow.textContent = isStepper
|
|
999
|
+
? `Step ${stepIndex + 1} of ${steps}${definition.title ? ` · ${definition.title}` : ""}`
|
|
1000
|
+
: "Review before submit";
|
|
1001
|
+
|
|
1002
|
+
const subtitle = currentStep?.subtitle ?? (stepIndex === 0 ? definition.subtitle : "");
|
|
1003
|
+
refs.formSubtitle.textContent = subtitle ?? "";
|
|
1004
|
+
refs.formSubtitle.style.display = subtitle ? "" : "none";
|
|
1005
|
+
|
|
1006
|
+
renderStepper(refs, steps, stepIndex);
|
|
1007
|
+
|
|
1008
|
+
const submitLabel = refs.formSubmitBtn.querySelector(".form-submit-label");
|
|
1009
|
+
if (submitLabel) {
|
|
1010
|
+
submitLabel.textContent = isFinalStep
|
|
1011
|
+
? definition.submit_label ?? "Confirm & send"
|
|
1012
|
+
: currentStep?.next_label ?? "Continue";
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
refs.formCancelBtn.textContent = cancelLabel ?? "Cancel";
|
|
1016
|
+
|
|
1017
|
+
// Step-back button: visible only on stepper, hidden on first step.
|
|
1018
|
+
const showStepBack = isStepper && stepIndex > 0;
|
|
1019
|
+
refs.formStepBackBtn.hidden = !showStepBack;
|
|
1020
|
+
if (showStepBack) {
|
|
1021
|
+
refs.formStepBackBtn.textContent = currentStep?.back_label ?? "Back";
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Apply layout modifiers on the fields wrapper.
|
|
1025
|
+
const layout = definition.layout ?? {};
|
|
1026
|
+
refs.formFields.classList.toggle("is-grid", layout.field_layout === "grid");
|
|
1027
|
+
refs.formFields.classList.toggle("is-compact", layout.density === "compact");
|
|
1028
|
+
refs.formFields.classList.toggle("is-inline-labels", layout.label_position === "inline");
|
|
1029
|
+
|
|
1030
|
+
setFormError(refs, "");
|
|
1031
|
+
setFormSuccess(refs, "");
|
|
1032
|
+
|
|
1033
|
+
refs.formFields.innerHTML = "";
|
|
1034
|
+
refs.formInputs.clear();
|
|
1035
|
+
|
|
1036
|
+
for (const field of fieldsForStep(definition, stepIndex)) {
|
|
1037
|
+
refs.formFields.appendChild(
|
|
1038
|
+
buildFieldNode(
|
|
1039
|
+
refs,
|
|
1040
|
+
field,
|
|
1041
|
+
field.name ? values[field.name] ?? "" : "",
|
|
1042
|
+
args.onFieldChange,
|
|
1043
|
+
),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function renderStepper(refs: UIRefs, steps: number, current: number) {
|
|
1049
|
+
if (steps <= 1) {
|
|
1050
|
+
refs.formStepper.hidden = true;
|
|
1051
|
+
refs.formStepper.innerHTML = "";
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
refs.formStepper.hidden = false;
|
|
1055
|
+
refs.formStepper.innerHTML = "";
|
|
1056
|
+
for (let i = 0; i < steps; i++) {
|
|
1057
|
+
const dot = document.createElement("span");
|
|
1058
|
+
dot.className = "form-stepper-dot";
|
|
1059
|
+
if (i < current) dot.classList.add("is-done");
|
|
1060
|
+
if (i === current) dot.classList.add("is-active");
|
|
1061
|
+
refs.formStepper.appendChild(dot);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function optionEntries(options: FormFieldOption[] | undefined): Array<{ value: string; label: string }> {
|
|
1066
|
+
if (!options) return [];
|
|
1067
|
+
return options.map((opt) =>
|
|
1068
|
+
typeof opt === "string"
|
|
1069
|
+
? { value: opt, label: opt }
|
|
1070
|
+
: { value: opt.value, label: opt.label ?? opt.value },
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function buildFieldNode(
|
|
1075
|
+
refs: UIRefs,
|
|
1076
|
+
field: FormFieldDef,
|
|
1077
|
+
value: string,
|
|
1078
|
+
onChange?: () => void,
|
|
1079
|
+
): HTMLElement {
|
|
1080
|
+
// Reassigned once the error node exists; lets `notifyChange` clear a field's
|
|
1081
|
+
// inline error the moment the visitor starts fixing it.
|
|
1082
|
+
let clearError = () => {};
|
|
1083
|
+
const notifyChange = () => {
|
|
1084
|
+
clearError();
|
|
1085
|
+
if (onChange) onChange();
|
|
1086
|
+
};
|
|
1087
|
+
// Display blocks render their own wrapper with no input registered.
|
|
1088
|
+
if (field.type === "display") {
|
|
1089
|
+
const block = document.createElement("div");
|
|
1090
|
+
block.className = "form-display";
|
|
1091
|
+
if (field.label) {
|
|
1092
|
+
const heading = document.createElement("div");
|
|
1093
|
+
heading.className = "form-display-title";
|
|
1094
|
+
heading.textContent = field.label;
|
|
1095
|
+
block.appendChild(heading);
|
|
1096
|
+
}
|
|
1097
|
+
if (field.help_text) {
|
|
1098
|
+
const body = document.createElement("p");
|
|
1099
|
+
body.className = "form-display-body";
|
|
1100
|
+
body.textContent = field.help_text;
|
|
1101
|
+
block.appendChild(body);
|
|
1102
|
+
}
|
|
1103
|
+
return block;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const fieldName = field.name!;
|
|
1107
|
+
const wrapper = document.createElement("label");
|
|
1108
|
+
wrapper.className = "form-field";
|
|
1109
|
+
if (field.width === "half") wrapper.classList.add("is-half");
|
|
1110
|
+
|
|
1111
|
+
const labelEl = document.createElement("span");
|
|
1112
|
+
labelEl.className = "form-label";
|
|
1113
|
+
labelEl.textContent = field.required ? `${field.label} *` : field.label;
|
|
1114
|
+
wrapper.appendChild(labelEl);
|
|
1115
|
+
|
|
1116
|
+
// `setError` is grafted on after the error node is built below.
|
|
1117
|
+
let binding: Omit<FormInputBinding, "setError">;
|
|
1118
|
+
|
|
1119
|
+
if (field.type === "textarea") {
|
|
1120
|
+
const ta = document.createElement("textarea");
|
|
1121
|
+
ta.rows = field.rows ?? 4;
|
|
1122
|
+
ta.placeholder = field.placeholder ?? "";
|
|
1123
|
+
ta.value = value;
|
|
1124
|
+
ta.name = fieldName;
|
|
1125
|
+
ta.className = "form-input form-textarea";
|
|
1126
|
+
if (field.required) ta.required = true;
|
|
1127
|
+
ta.addEventListener("input", notifyChange);
|
|
1128
|
+
wrapper.appendChild(ta);
|
|
1129
|
+
binding = {
|
|
1130
|
+
read: () => ta.value.trim(),
|
|
1131
|
+
setDisabled: (busy) => { ta.disabled = busy; },
|
|
1132
|
+
};
|
|
1133
|
+
} else if (field.type === "select") {
|
|
1134
|
+
const sel = document.createElement("select");
|
|
1135
|
+
sel.className = "form-input form-select";
|
|
1136
|
+
sel.name = fieldName;
|
|
1137
|
+
if (field.required) sel.required = true;
|
|
1138
|
+
const opts = optionEntries(field.options);
|
|
1139
|
+
const seen = new Set(opts.map((o) => o.value));
|
|
1140
|
+
const all = !value || seen.has(value) ? opts : [...opts, { value, label: value }];
|
|
1141
|
+
for (const opt of all) {
|
|
1142
|
+
const o = document.createElement("option");
|
|
1143
|
+
o.value = opt.value;
|
|
1144
|
+
o.textContent = opt.label;
|
|
1145
|
+
if (opt.value === value) o.selected = true;
|
|
1146
|
+
sel.appendChild(o);
|
|
1147
|
+
}
|
|
1148
|
+
sel.addEventListener("change", notifyChange);
|
|
1149
|
+
wrapper.appendChild(sel);
|
|
1150
|
+
binding = {
|
|
1151
|
+
read: () => sel.value.trim(),
|
|
1152
|
+
setDisabled: (busy) => { sel.disabled = busy; },
|
|
1153
|
+
};
|
|
1154
|
+
} else if (field.type === "radio") {
|
|
1155
|
+
wrapper.classList.add("is-choice-group");
|
|
1156
|
+
const groupName = `${fieldName}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1157
|
+
const group = document.createElement("div");
|
|
1158
|
+
group.className = "form-choice-group";
|
|
1159
|
+
const inputs: HTMLInputElement[] = [];
|
|
1160
|
+
for (const opt of optionEntries(field.options)) {
|
|
1161
|
+
const choice = document.createElement("label");
|
|
1162
|
+
choice.className = "form-choice";
|
|
1163
|
+
const input = document.createElement("input");
|
|
1164
|
+
input.type = "radio";
|
|
1165
|
+
input.name = groupName;
|
|
1166
|
+
input.value = opt.value;
|
|
1167
|
+
if (opt.value === value) input.checked = true;
|
|
1168
|
+
if (field.required) input.required = true;
|
|
1169
|
+
input.addEventListener("change", notifyChange);
|
|
1170
|
+
const text = document.createElement("span");
|
|
1171
|
+
text.textContent = opt.label;
|
|
1172
|
+
choice.appendChild(input);
|
|
1173
|
+
choice.appendChild(text);
|
|
1174
|
+
group.appendChild(choice);
|
|
1175
|
+
inputs.push(input);
|
|
1176
|
+
}
|
|
1177
|
+
wrapper.appendChild(group);
|
|
1178
|
+
binding = {
|
|
1179
|
+
read: () => inputs.find((i) => i.checked)?.value.trim() ?? "",
|
|
1180
|
+
setDisabled: (busy) => inputs.forEach((i) => { i.disabled = busy; }),
|
|
1181
|
+
};
|
|
1182
|
+
} else if (field.type === "checkbox") {
|
|
1183
|
+
wrapper.classList.add("is-choice-group");
|
|
1184
|
+
if (field.options && field.options.length) {
|
|
1185
|
+
// Multi-checkbox group — value is comma-joined.
|
|
1186
|
+
const selected = new Set(
|
|
1187
|
+
value
|
|
1188
|
+
.split(",")
|
|
1189
|
+
.map((s) => s.trim())
|
|
1190
|
+
.filter(Boolean),
|
|
1191
|
+
);
|
|
1192
|
+
const group = document.createElement("div");
|
|
1193
|
+
group.className = "form-choice-group";
|
|
1194
|
+
const inputs: HTMLInputElement[] = [];
|
|
1195
|
+
for (const opt of optionEntries(field.options)) {
|
|
1196
|
+
const choice = document.createElement("label");
|
|
1197
|
+
choice.className = "form-choice";
|
|
1198
|
+
const input = document.createElement("input");
|
|
1199
|
+
input.type = "checkbox";
|
|
1200
|
+
input.value = opt.value;
|
|
1201
|
+
if (selected.has(opt.value)) input.checked = true;
|
|
1202
|
+
input.addEventListener("change", notifyChange);
|
|
1203
|
+
const text = document.createElement("span");
|
|
1204
|
+
text.textContent = opt.label;
|
|
1205
|
+
choice.appendChild(input);
|
|
1206
|
+
choice.appendChild(text);
|
|
1207
|
+
group.appendChild(choice);
|
|
1208
|
+
inputs.push(input);
|
|
1209
|
+
}
|
|
1210
|
+
wrapper.appendChild(group);
|
|
1211
|
+
binding = {
|
|
1212
|
+
read: () =>
|
|
1213
|
+
inputs
|
|
1214
|
+
.filter((i) => i.checked)
|
|
1215
|
+
.map((i) => i.value)
|
|
1216
|
+
.join(","),
|
|
1217
|
+
setDisabled: (busy) => inputs.forEach((i) => { i.disabled = busy; }),
|
|
1218
|
+
};
|
|
1219
|
+
} else {
|
|
1220
|
+
// Single boolean checkbox.
|
|
1221
|
+
const single = document.createElement("input");
|
|
1222
|
+
single.type = "checkbox";
|
|
1223
|
+
single.name = fieldName;
|
|
1224
|
+
single.className = "form-checkbox";
|
|
1225
|
+
if (value === "true" || value === "1" || value === "on") single.checked = true;
|
|
1226
|
+
if (field.required) single.required = true;
|
|
1227
|
+
single.addEventListener("change", notifyChange);
|
|
1228
|
+
wrapper.appendChild(single);
|
|
1229
|
+
// Move the label text next to the checkbox for natural "agree to terms" layout.
|
|
1230
|
+
wrapper.classList.add("is-inline-bool");
|
|
1231
|
+
binding = {
|
|
1232
|
+
read: () => (single.checked ? "true" : ""),
|
|
1233
|
+
setDisabled: (busy) => { single.disabled = busy; },
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
} else {
|
|
1237
|
+
const text = document.createElement("input");
|
|
1238
|
+
// text/email/tel/number/date/time map 1:1 to native input types.
|
|
1239
|
+
text.type = field.type;
|
|
1240
|
+
text.placeholder = field.placeholder ?? "";
|
|
1241
|
+
text.value = value;
|
|
1242
|
+
text.name = fieldName;
|
|
1243
|
+
text.className = "form-input";
|
|
1244
|
+
if (field.required) text.required = true;
|
|
1245
|
+
if (field.pattern) text.pattern = field.pattern;
|
|
1246
|
+
if (field.type === "number") {
|
|
1247
|
+
if (field.min !== undefined) text.min = String(field.min);
|
|
1248
|
+
if (field.max !== undefined) text.max = String(field.max);
|
|
1249
|
+
}
|
|
1250
|
+
text.addEventListener("input", notifyChange);
|
|
1251
|
+
wrapper.appendChild(text);
|
|
1252
|
+
binding = {
|
|
1253
|
+
read: () => text.value.trim(),
|
|
1254
|
+
setDisabled: (busy) => { text.disabled = busy; },
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
if (field.help_text) {
|
|
1259
|
+
const help = document.createElement("span");
|
|
1260
|
+
help.className = "form-help";
|
|
1261
|
+
help.textContent = field.help_text;
|
|
1262
|
+
wrapper.appendChild(help);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Inline validation error slot, hidden until `setError` fills it.
|
|
1266
|
+
const errorEl = document.createElement("span");
|
|
1267
|
+
errorEl.className = "form-field-error";
|
|
1268
|
+
errorEl.hidden = true;
|
|
1269
|
+
wrapper.appendChild(errorEl);
|
|
1270
|
+
|
|
1271
|
+
const setError = (message: string) => {
|
|
1272
|
+
errorEl.textContent = message;
|
|
1273
|
+
errorEl.hidden = !message;
|
|
1274
|
+
wrapper.classList.toggle("is-invalid", Boolean(message));
|
|
1275
|
+
};
|
|
1276
|
+
clearError = () => setError("");
|
|
1277
|
+
|
|
1278
|
+
refs.formInputs.set(fieldName, { ...binding, setError });
|
|
1279
|
+
return wrapper;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Apply inline validation errors, then clear any field not in the list. */
|
|
1283
|
+
export function setFieldErrors(
|
|
1284
|
+
refs: UIRefs,
|
|
1285
|
+
errors: { name: string; message: string }[],
|
|
1286
|
+
) {
|
|
1287
|
+
const byName = new Map(errors.map((e) => [e.name, e.message]));
|
|
1288
|
+
for (const [name, binding] of refs.formInputs.entries()) {
|
|
1289
|
+
binding.setError(byName.get(name) ?? "");
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/** Clear every inline field error. */
|
|
1294
|
+
export function clearFieldErrors(refs: UIRefs) {
|
|
1295
|
+
for (const binding of refs.formInputs.values()) binding.setError("");
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
export function readFormValues(refs: UIRefs): Record<string, string> {
|
|
1299
|
+
const out: Record<string, string> = {};
|
|
1300
|
+
for (const [name, binding] of refs.formInputs.entries()) {
|
|
1301
|
+
out[name] = binding.read();
|
|
1302
|
+
}
|
|
1303
|
+
return out;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
export function setFormBusy(refs: UIRefs, busy: boolean) {
|
|
1307
|
+
refs.formSubmitBtn.disabled = busy;
|
|
1308
|
+
refs.formCancelBtn.disabled = busy;
|
|
1309
|
+
refs.formBackBtn.disabled = busy;
|
|
1310
|
+
refs.formStepBackBtn.disabled = busy;
|
|
1311
|
+
for (const binding of refs.formInputs.values()) binding.setDisabled(busy);
|
|
1312
|
+
refs.formSubmitBtn.classList.toggle("is-busy", busy);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
export function setFormError(refs: UIRefs, message: string) {
|
|
1316
|
+
refs.formError.textContent = message;
|
|
1317
|
+
refs.formError.hidden = !message;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
export function setFormSuccess(refs: UIRefs, message: string) {
|
|
1321
|
+
refs.formSuccess.textContent = message;
|
|
1322
|
+
refs.formSuccess.hidden = !message;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
export function setCallStatus(refs: UIRefs, text: string) {
|
|
1326
|
+
refs.callStatus.textContent = text;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
export function setCallTimer(refs: UIRefs, remainingMs: number | null) {
|
|
1330
|
+
if (remainingMs === null) {
|
|
1331
|
+
refs.callTimer.hidden = true;
|
|
1332
|
+
refs.callTimer.classList.remove("warning");
|
|
1333
|
+
refs.callTimerText.textContent = "";
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
refs.callTimer.hidden = false;
|
|
1337
|
+
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
|
|
1338
|
+
refs.callTimerText.textContent = formatMmSs(totalSeconds);
|
|
1339
|
+
// Flash a warning tint in the final 30 seconds.
|
|
1340
|
+
refs.callTimer.classList.toggle("warning", totalSeconds <= 30);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function formatMmSs(totalSeconds: number): string {
|
|
1344
|
+
const m = Math.floor(totalSeconds / 60);
|
|
1345
|
+
const s = totalSeconds % 60;
|
|
1346
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
export type OrbState =
|
|
1350
|
+
| "idle"
|
|
1351
|
+
| "listening"
|
|
1352
|
+
| "speaking"
|
|
1353
|
+
| "connecting"
|
|
1354
|
+
| "thinking";
|
|
1355
|
+
|
|
1356
|
+
export function setOrbState(refs: UIRefs, state: OrbState) {
|
|
1357
|
+
refs.orb.classList.remove("listening", "speaking", "connecting", "thinking");
|
|
1358
|
+
if (state !== "idle") refs.orb.classList.add(state);
|
|
1359
|
+
const labels = refs.appearance.labels;
|
|
1360
|
+
refs.orbLabel.textContent =
|
|
1361
|
+
state === "listening"
|
|
1362
|
+
? labels.listening
|
|
1363
|
+
: state === "speaking"
|
|
1364
|
+
? labels.speaking
|
|
1365
|
+
: state === "connecting"
|
|
1366
|
+
? labels.connecting
|
|
1367
|
+
: state === "thinking"
|
|
1368
|
+
? labels.thinking
|
|
1369
|
+
: labels.idle;
|
|
1370
|
+
// The contextual status line only makes sense while thinking; any other
|
|
1371
|
+
// state clears it so a stale "Searching…" can't linger over a reply.
|
|
1372
|
+
if (state !== "thinking") setAgentStatusLine(refs, null);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Write the contextual processing text (e.g. "Searching the knowledge base…")
|
|
1377
|
+
* under the orb, or clear it when `text` is null/empty. This is the per-tool
|
|
1378
|
+
* label from a voice.agent_status event; the generic orb label still reads
|
|
1379
|
+
* "Thinking…".
|
|
1380
|
+
*/
|
|
1381
|
+
export function setAgentStatusLine(refs: UIRefs, text: string | null) {
|
|
1382
|
+
const trimmed = (text || "").trim();
|
|
1383
|
+
if (!trimmed) {
|
|
1384
|
+
refs.agentStatusLine.hidden = true;
|
|
1385
|
+
refs.agentStatusLine.textContent = "";
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
refs.agentStatusLine.textContent = trimmed;
|
|
1389
|
+
refs.agentStatusLine.hidden = false;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
export function setMuteVisual(refs: UIRefs, muted: boolean) {
|
|
1393
|
+
const label = muted ? "Unmute" : "Mute";
|
|
1394
|
+
const icon = muted ? ICON_MIC_OFF : ICON_MIC_ON;
|
|
1395
|
+
for (const btn of [refs.muteBtn, refs.formMuteBtn]) {
|
|
1396
|
+
btn.classList.toggle("is-active", muted);
|
|
1397
|
+
btn.setAttribute("aria-label", label);
|
|
1398
|
+
btn.innerHTML = icon;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
export function setFormCallControlsVisible(refs: UIRefs, visible: boolean) {
|
|
1403
|
+
refs.formCallControls.hidden = !visible;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
export function setAudioDrawerOpen(refs: UIRefs, open: boolean) {
|
|
1407
|
+
refs.audioDrawer.hidden = !open;
|
|
1408
|
+
refs.audioDrawer.classList.toggle("is-open", open);
|
|
1409
|
+
refs.settingsBtn.classList.toggle("is-active", open);
|
|
1410
|
+
refs.settingsBtn.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
export function setAudioMeterLevel(refs: UIRefs, level: number) {
|
|
1414
|
+
const clamped = Math.max(0, Math.min(1, level));
|
|
1415
|
+
refs.audioMeterBar.style.width = `${(clamped * 100).toFixed(1)}%`;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
export function populateAudioDeviceSelect(
|
|
1419
|
+
select: HTMLSelectElement,
|
|
1420
|
+
devices: MediaDeviceInfo[],
|
|
1421
|
+
selectedDeviceId: string,
|
|
1422
|
+
defaultLabel: string,
|
|
1423
|
+
) {
|
|
1424
|
+
const previousValue = selectedDeviceId || "";
|
|
1425
|
+
select.innerHTML = "";
|
|
1426
|
+
const defaultOpt = document.createElement("option");
|
|
1427
|
+
defaultOpt.value = "";
|
|
1428
|
+
defaultOpt.textContent = defaultLabel;
|
|
1429
|
+
select.appendChild(defaultOpt);
|
|
1430
|
+
for (const device of devices) {
|
|
1431
|
+
const opt = document.createElement("option");
|
|
1432
|
+
opt.value = device.deviceId;
|
|
1433
|
+
// Empty labels happen before mic permission is granted; show a stub.
|
|
1434
|
+
opt.textContent = device.label || `Device ${device.deviceId.slice(0, 6)}`;
|
|
1435
|
+
select.appendChild(opt);
|
|
1436
|
+
}
|
|
1437
|
+
// Re-select previous value if still present, otherwise fall back to default.
|
|
1438
|
+
if (Array.from(select.options).some((o) => o.value === previousValue)) {
|
|
1439
|
+
select.value = previousValue;
|
|
1440
|
+
} else {
|
|
1441
|
+
select.value = "";
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
const typewriterTimers = new WeakMap<HTMLDivElement, number>();
|
|
1446
|
+
const TYPEWRITER_CHARS_PER_TICK = 2;
|
|
1447
|
+
const TYPEWRITER_TICK_MS = 18;
|
|
1448
|
+
|
|
1449
|
+
function updateOneTranscript(
|
|
1450
|
+
container: HTMLDivElement,
|
|
1451
|
+
segments: Map<string, HTMLDivElement>,
|
|
1452
|
+
interimBubbles: Map<"user" | "agent", HTMLDivElement>,
|
|
1453
|
+
role: "user" | "agent",
|
|
1454
|
+
segmentId: string,
|
|
1455
|
+
trimmed: string,
|
|
1456
|
+
isFinal: boolean,
|
|
1457
|
+
) {
|
|
1458
|
+
const key = `${role}:${segmentId}`;
|
|
1459
|
+
let bubble = segments.get(key);
|
|
1460
|
+
let isNewBubble = !bubble;
|
|
1461
|
+
|
|
1462
|
+
if (!bubble) {
|
|
1463
|
+
const lingeringInterim = interimBubbles.get(role);
|
|
1464
|
+
if (lingeringInterim) {
|
|
1465
|
+
bubble = lingeringInterim;
|
|
1466
|
+
segments.set(key, bubble);
|
|
1467
|
+
isNewBubble = false;
|
|
1468
|
+
} else {
|
|
1469
|
+
bubble = document.createElement("div");
|
|
1470
|
+
bubble.className = `transcript-msg ${role} interim`;
|
|
1471
|
+
container.appendChild(bubble);
|
|
1472
|
+
segments.set(key, bubble);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const pendingTimer = typewriterTimers.get(bubble);
|
|
1477
|
+
if (pendingTimer !== undefined) {
|
|
1478
|
+
window.clearInterval(pendingTimer);
|
|
1479
|
+
typewriterTimers.delete(bubble);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if (isNewBubble && isFinal && trimmed.length > 3) {
|
|
1483
|
+
bubble.classList.add("interim");
|
|
1484
|
+
bubble.classList.remove("final");
|
|
1485
|
+
bubble.textContent = "";
|
|
1486
|
+
let pos = 0;
|
|
1487
|
+
const tick = () => {
|
|
1488
|
+
pos = Math.min(trimmed.length, pos + TYPEWRITER_CHARS_PER_TICK);
|
|
1489
|
+
bubble!.textContent = trimmed.slice(0, pos);
|
|
1490
|
+
container.scrollTop = container.scrollHeight;
|
|
1491
|
+
if (pos >= trimmed.length) {
|
|
1492
|
+
const t = typewriterTimers.get(bubble!);
|
|
1493
|
+
if (t !== undefined) window.clearInterval(t);
|
|
1494
|
+
typewriterTimers.delete(bubble!);
|
|
1495
|
+
if (role === "agent") {
|
|
1496
|
+
bubble!.innerHTML = renderMarkdown(trimmed);
|
|
1497
|
+
} else {
|
|
1498
|
+
bubble!.textContent = trimmed;
|
|
1499
|
+
}
|
|
1500
|
+
bubble!.classList.remove("interim");
|
|
1501
|
+
bubble!.classList.add("final");
|
|
1502
|
+
}
|
|
1503
|
+
};
|
|
1504
|
+
const timer = window.setInterval(tick, TYPEWRITER_TICK_MS);
|
|
1505
|
+
typewriterTimers.set(bubble, timer);
|
|
1506
|
+
tick();
|
|
1507
|
+
interimBubbles.delete(role);
|
|
1508
|
+
return;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if (role === "agent") {
|
|
1512
|
+
bubble.innerHTML = renderMarkdown(trimmed);
|
|
1513
|
+
} else {
|
|
1514
|
+
bubble.textContent = trimmed;
|
|
1515
|
+
}
|
|
1516
|
+
bubble.classList.toggle("interim", !isFinal);
|
|
1517
|
+
bubble.classList.toggle("final", isFinal);
|
|
1518
|
+
container.scrollTop = container.scrollHeight;
|
|
1519
|
+
|
|
1520
|
+
if (isFinal) {
|
|
1521
|
+
interimBubbles.delete(role);
|
|
1522
|
+
} else {
|
|
1523
|
+
interimBubbles.set(role, bubble);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
export function appendOrUpdateTranscriptSegment(
|
|
1528
|
+
refs: UIRefs,
|
|
1529
|
+
role: "user" | "agent",
|
|
1530
|
+
segmentId: string,
|
|
1531
|
+
text: string,
|
|
1532
|
+
isFinal: boolean,
|
|
1533
|
+
) {
|
|
1534
|
+
const trimmed = (text || "").trim();
|
|
1535
|
+
if (!trimmed) return;
|
|
1536
|
+
|
|
1537
|
+
updateOneTranscript(
|
|
1538
|
+
refs.transcript,
|
|
1539
|
+
refs.transcriptSegments,
|
|
1540
|
+
refs.transcriptInterimBubble,
|
|
1541
|
+
role,
|
|
1542
|
+
segmentId,
|
|
1543
|
+
trimmed,
|
|
1544
|
+
isFinal,
|
|
1545
|
+
);
|
|
1546
|
+
|
|
1547
|
+
if (!refs.formTranscript.hidden) {
|
|
1548
|
+
updateOneTranscript(
|
|
1549
|
+
refs.formTranscript,
|
|
1550
|
+
refs.formTranscriptSegments,
|
|
1551
|
+
refs.formTranscriptInterimBubble,
|
|
1552
|
+
role,
|
|
1553
|
+
segmentId,
|
|
1554
|
+
trimmed,
|
|
1555
|
+
isFinal,
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function clearOneTranscript(
|
|
1561
|
+
container: HTMLDivElement,
|
|
1562
|
+
segments: Map<string, HTMLDivElement>,
|
|
1563
|
+
interimBubbles: Map<"user" | "agent", HTMLDivElement>,
|
|
1564
|
+
) {
|
|
1565
|
+
for (const bubble of segments.values()) {
|
|
1566
|
+
const t = typewriterTimers.get(bubble);
|
|
1567
|
+
if (t !== undefined) {
|
|
1568
|
+
window.clearInterval(t);
|
|
1569
|
+
typewriterTimers.delete(bubble);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
container.innerHTML = "";
|
|
1573
|
+
segments.clear();
|
|
1574
|
+
interimBubbles.clear();
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
export function clearTranscript(refs: UIRefs) {
|
|
1578
|
+
clearOneTranscript(refs.transcript, refs.transcriptSegments, refs.transcriptInterimBubble);
|
|
1579
|
+
clearOneTranscript(refs.formTranscript, refs.formTranscriptSegments, refs.formTranscriptInterimBubble);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
export function showAgentTransition(refs: UIRefs, agentName: string) {
|
|
1583
|
+
const name = (agentName || "").trim() || "the next agent";
|
|
1584
|
+
const text = `Connecting you with ${name}...`;
|
|
1585
|
+
for (const container of [refs.transcript, refs.formTranscript]) {
|
|
1586
|
+
if (!container) continue;
|
|
1587
|
+
const bubble = document.createElement("div");
|
|
1588
|
+
bubble.className = "transcript-msg system agent-transition";
|
|
1589
|
+
bubble.textContent = text;
|
|
1590
|
+
container.appendChild(bubble);
|
|
1591
|
+
container.scrollTop = container.scrollHeight;
|
|
1592
|
+
}
|
|
1593
|
+
setCallStatus(refs, text);
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
export function setFormTranscriptVisible(refs: UIRefs, visible: boolean) {
|
|
1597
|
+
refs.formTranscript.hidden = !visible;
|
|
1598
|
+
if (visible) {
|
|
1599
|
+
// Sync existing transcript messages into the form transcript
|
|
1600
|
+
clearOneTranscript(refs.formTranscript, refs.formTranscriptSegments, refs.formTranscriptInterimBubble);
|
|
1601
|
+
for (const [key, bubble] of refs.transcriptSegments) {
|
|
1602
|
+
const clone = bubble.cloneNode(true) as HTMLDivElement;
|
|
1603
|
+
refs.formTranscript.appendChild(clone);
|
|
1604
|
+
refs.formTranscriptSegments.set(key, clone);
|
|
1605
|
+
}
|
|
1606
|
+
for (const [role, bubble] of refs.transcriptInterimBubble) {
|
|
1607
|
+
for (const [key, original] of refs.transcriptSegments) {
|
|
1608
|
+
if (original === bubble) {
|
|
1609
|
+
const clone = refs.formTranscriptSegments.get(key);
|
|
1610
|
+
if (clone) refs.formTranscriptInterimBubble.set(role, clone);
|
|
1611
|
+
break;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
refs.formTranscript.scrollTop = refs.formTranscript.scrollHeight;
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
function contrastingText(color: string): string {
|
|
1620
|
+
const rgb = parseColor(color);
|
|
1621
|
+
if (!rgb) return "#ffffff";
|
|
1622
|
+
const [r, g, b] = rgb;
|
|
1623
|
+
const toLin = (c: number) => {
|
|
1624
|
+
const s = c / 255;
|
|
1625
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
1626
|
+
};
|
|
1627
|
+
const lum = 0.2126 * toLin(r) + 0.7152 * toLin(g) + 0.0722 * toLin(b);
|
|
1628
|
+
return lum > 0.5 ? "#111111" : "#ffffff";
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function surfaceFromBackground(bg: string): string {
|
|
1632
|
+
const rgb = parseColor(bg);
|
|
1633
|
+
if (!rgb) return "#f6f7f9";
|
|
1634
|
+
const [r, g, b] = rgb;
|
|
1635
|
+
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
|
1636
|
+
const shift = lum > 0.5 ? -10 : 18;
|
|
1637
|
+
const c = (v: number) => Math.max(0, Math.min(255, v + shift));
|
|
1638
|
+
return `rgb(${c(r)}, ${c(g)}, ${c(b)})`;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function muteText(text: string, bg: string): string {
|
|
1642
|
+
const trgb = parseColor(text);
|
|
1643
|
+
const brgb = parseColor(bg);
|
|
1644
|
+
if (!trgb || !brgb) return "rgba(0,0,0,0.55)";
|
|
1645
|
+
const mix = (i: 0 | 1 | 2) => Math.round(trgb[i] * 0.55 + brgb[i] * 0.45);
|
|
1646
|
+
return `rgb(${mix(0)}, ${mix(1)}, ${mix(2)})`;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function parseColor(input: string): [number, number, number] | null {
|
|
1650
|
+
const s = input.trim();
|
|
1651
|
+
const hex = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
1652
|
+
if (hex) {
|
|
1653
|
+
let h = hex[1];
|
|
1654
|
+
if (h.length === 3) h = h.split("").map((c) => c + c).join("");
|
|
1655
|
+
return [
|
|
1656
|
+
parseInt(h.slice(0, 2), 16),
|
|
1657
|
+
parseInt(h.slice(2, 4), 16),
|
|
1658
|
+
parseInt(h.slice(4, 6), 16),
|
|
1659
|
+
];
|
|
1660
|
+
}
|
|
1661
|
+
const rgb = s.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/i);
|
|
1662
|
+
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
|
1663
|
+
return null;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function escapeAttr(s: string): string {
|
|
1667
|
+
return s
|
|
1668
|
+
.replace(/&/g, "&")
|
|
1669
|
+
.replace(/"/g, """)
|
|
1670
|
+
.replace(/</g, "<")
|
|
1671
|
+
.replace(/>/g, ">");
|
|
1672
|
+
}
|