vanilla-agent 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 +310 -0
- package/dist/index.cjs +14 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +370 -0
- package/dist/index.d.ts +370 -0
- package/dist/index.global.js +1710 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/install.global.js +2 -0
- package/dist/install.global.js.map +1 -0
- package/dist/widget.css +752 -0
- package/package.json +61 -0
- package/src/client.ts +577 -0
- package/src/components/forms.ts +165 -0
- package/src/components/launcher.ts +184 -0
- package/src/components/message-bubble.ts +52 -0
- package/src/components/messages.ts +43 -0
- package/src/components/panel.ts +555 -0
- package/src/components/reasoning-bubble.ts +114 -0
- package/src/components/suggestions.ts +52 -0
- package/src/components/tool-bubble.ts +158 -0
- package/src/index.ts +37 -0
- package/src/install.ts +159 -0
- package/src/plugins/registry.ts +72 -0
- package/src/plugins/types.ts +90 -0
- package/src/postprocessors.ts +76 -0
- package/src/runtime/init.ts +116 -0
- package/src/session.ts +206 -0
- package/src/styles/tailwind.css +19 -0
- package/src/styles/widget.css +752 -0
- package/src/types.ts +194 -0
- package/src/ui.ts +1325 -0
- package/src/utils/constants.ts +11 -0
- package/src/utils/dom.ts +20 -0
- package/src/utils/formatting.ts +77 -0
- package/src/utils/icons.ts +92 -0
- package/src/utils/positioning.ts +12 -0
- package/src/utils/theme.ts +20 -0
- package/src/widget.css +1 -0
- package/widget.css +1 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
import { escapeHtml } from "./postprocessors";
|
|
2
|
+
import { ChatWidgetSession, ChatWidgetSessionStatus } from "./session";
|
|
3
|
+
import { ChatWidgetConfig, ChatWidgetMessage } from "./types";
|
|
4
|
+
import { applyThemeVariables } from "./utils/theme";
|
|
5
|
+
import { renderLucideIcon } from "./utils/icons";
|
|
6
|
+
import { createElement } from "./utils/dom";
|
|
7
|
+
import { statusCopy } from "./utils/constants";
|
|
8
|
+
import { createLauncherButton } from "./components/launcher";
|
|
9
|
+
import { createWrapper, buildPanel } from "./components/panel";
|
|
10
|
+
import { MessageTransform } from "./components/message-bubble";
|
|
11
|
+
import { createStandardBubble } from "./components/message-bubble";
|
|
12
|
+
import { createReasoningBubble } from "./components/reasoning-bubble";
|
|
13
|
+
import { createToolBubble } from "./components/tool-bubble";
|
|
14
|
+
import { createSuggestions } from "./components/suggestions";
|
|
15
|
+
import { enhanceWithForms } from "./components/forms";
|
|
16
|
+
import { pluginRegistry } from "./plugins/registry";
|
|
17
|
+
|
|
18
|
+
type Controller = {
|
|
19
|
+
update: (config: ChatWidgetConfig) => void;
|
|
20
|
+
destroy: () => void;
|
|
21
|
+
open: () => void;
|
|
22
|
+
close: () => void;
|
|
23
|
+
toggle: () => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const buildPostprocessor = (cfg?: ChatWidgetConfig): MessageTransform => {
|
|
27
|
+
if (cfg?.postprocessMessage) {
|
|
28
|
+
return (context) =>
|
|
29
|
+
cfg.postprocessMessage!({
|
|
30
|
+
text: context.text,
|
|
31
|
+
message: context.message,
|
|
32
|
+
streaming: context.streaming
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return ({ text }) => escapeHtml(text);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const createChatExperience = (
|
|
39
|
+
mount: HTMLElement,
|
|
40
|
+
initialConfig?: ChatWidgetConfig
|
|
41
|
+
): Controller => {
|
|
42
|
+
// Tailwind config uses important: "#vanilla-agent-root", so ensure mount has this ID
|
|
43
|
+
if (!mount.id || mount.id !== "vanilla-agent-root") {
|
|
44
|
+
mount.id = "vanilla-agent-root";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let config = { ...initialConfig };
|
|
48
|
+
applyThemeVariables(mount, config);
|
|
49
|
+
|
|
50
|
+
// Get plugins for this instance
|
|
51
|
+
const plugins = pluginRegistry.getForInstance(config.plugins);
|
|
52
|
+
|
|
53
|
+
let launcherEnabled = config.launcher?.enabled ?? true;
|
|
54
|
+
let autoExpand = config.launcher?.autoExpand ?? false;
|
|
55
|
+
let prevAutoExpand = autoExpand;
|
|
56
|
+
let prevLauncherEnabled = launcherEnabled;
|
|
57
|
+
let open = launcherEnabled ? autoExpand : true;
|
|
58
|
+
let postprocess = buildPostprocessor(config);
|
|
59
|
+
let showReasoning = config.features?.showReasoning ?? true;
|
|
60
|
+
let showToolCalls = config.features?.showToolCalls ?? true;
|
|
61
|
+
|
|
62
|
+
// Get status indicator config
|
|
63
|
+
const statusConfig = config.statusIndicator ?? {};
|
|
64
|
+
const getStatusText = (status: ChatWidgetSessionStatus): string => {
|
|
65
|
+
if (status === "idle") return statusConfig.idleText ?? statusCopy.idle;
|
|
66
|
+
if (status === "connecting") return statusConfig.connectingText ?? statusCopy.connecting;
|
|
67
|
+
if (status === "connected") return statusConfig.connectedText ?? statusCopy.connected;
|
|
68
|
+
if (status === "error") return statusConfig.errorText ?? statusCopy.error;
|
|
69
|
+
return statusCopy[status];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const { wrapper, panel } = createWrapper(config);
|
|
73
|
+
const panelElements = buildPanel(config, launcherEnabled);
|
|
74
|
+
const {
|
|
75
|
+
container,
|
|
76
|
+
body,
|
|
77
|
+
messagesWrapper,
|
|
78
|
+
suggestions,
|
|
79
|
+
textarea,
|
|
80
|
+
sendButton,
|
|
81
|
+
sendButtonWrapper,
|
|
82
|
+
composerForm,
|
|
83
|
+
statusText,
|
|
84
|
+
introTitle,
|
|
85
|
+
introSubtitle,
|
|
86
|
+
closeButton,
|
|
87
|
+
iconHolder
|
|
88
|
+
} = panelElements;
|
|
89
|
+
|
|
90
|
+
// Use mutable references for mic button so we can update them dynamically
|
|
91
|
+
let micButton: HTMLButtonElement | null = panelElements.micButton;
|
|
92
|
+
let micButtonWrapper: HTMLElement | null = panelElements.micButtonWrapper;
|
|
93
|
+
|
|
94
|
+
panel.appendChild(container);
|
|
95
|
+
mount.appendChild(wrapper);
|
|
96
|
+
|
|
97
|
+
const destroyCallbacks: Array<() => void> = [];
|
|
98
|
+
const suggestionsManager = createSuggestions(suggestions);
|
|
99
|
+
let closeHandler: (() => void) | null = null;
|
|
100
|
+
let session: ChatWidgetSession;
|
|
101
|
+
let isStreaming = false;
|
|
102
|
+
let shouldAutoScroll = true;
|
|
103
|
+
let lastScrollTop = 0;
|
|
104
|
+
let lastAutoScrollTime = 0;
|
|
105
|
+
let scrollRAF: number | null = null;
|
|
106
|
+
let isAutoScrollBlocked = false;
|
|
107
|
+
let blockUntilTime = 0;
|
|
108
|
+
let isAutoScrolling = false;
|
|
109
|
+
|
|
110
|
+
const AUTO_SCROLL_THROTTLE = 125;
|
|
111
|
+
const AUTO_SCROLL_BLOCK_TIME = 2000;
|
|
112
|
+
const USER_SCROLL_THRESHOLD = 5;
|
|
113
|
+
const BOTTOM_THRESHOLD = 50;
|
|
114
|
+
|
|
115
|
+
const scheduleAutoScroll = (force = false) => {
|
|
116
|
+
if (!shouldAutoScroll) return;
|
|
117
|
+
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
|
|
120
|
+
if (isAutoScrollBlocked && now < blockUntilTime) {
|
|
121
|
+
if (!force) return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isAutoScrollBlocked && now >= blockUntilTime) {
|
|
125
|
+
isAutoScrollBlocked = false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!force && !isStreaming) return;
|
|
129
|
+
|
|
130
|
+
if (now - lastAutoScrollTime < AUTO_SCROLL_THROTTLE) return;
|
|
131
|
+
lastAutoScrollTime = now;
|
|
132
|
+
|
|
133
|
+
if (scrollRAF) {
|
|
134
|
+
cancelAnimationFrame(scrollRAF);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
scrollRAF = requestAnimationFrame(() => {
|
|
138
|
+
if (isAutoScrollBlocked || !shouldAutoScroll) return;
|
|
139
|
+
isAutoScrolling = true;
|
|
140
|
+
body.scrollTop = body.scrollHeight;
|
|
141
|
+
lastScrollTop = body.scrollTop;
|
|
142
|
+
requestAnimationFrame(() => {
|
|
143
|
+
isAutoScrolling = false;
|
|
144
|
+
});
|
|
145
|
+
scrollRAF = null;
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// Create typing indicator element
|
|
150
|
+
const createTypingIndicator = (): HTMLElement => {
|
|
151
|
+
const container = document.createElement("div");
|
|
152
|
+
container.className = "tvw-flex tvw-items-center tvw-space-x-1 tvw-h-5";
|
|
153
|
+
|
|
154
|
+
const dot1 = document.createElement("div");
|
|
155
|
+
dot1.className = "tvw-bg-cw-primary tvw-animate-typing tvw-rounded-full tvw-h-1.5 tvw-w-1.5";
|
|
156
|
+
dot1.style.animationDelay = "0ms";
|
|
157
|
+
|
|
158
|
+
const dot2 = document.createElement("div");
|
|
159
|
+
dot2.className = "tvw-bg-cw-primary tvw-animate-typing tvw-rounded-full tvw-h-1.5 tvw-w-1.5";
|
|
160
|
+
dot2.style.animationDelay = "250ms";
|
|
161
|
+
|
|
162
|
+
const dot3 = document.createElement("div");
|
|
163
|
+
dot3.className = "tvw-bg-cw-primary tvw-animate-typing tvw-rounded-full tvw-h-1.5 tvw-w-1.5";
|
|
164
|
+
dot3.style.animationDelay = "500ms";
|
|
165
|
+
|
|
166
|
+
const srOnly = document.createElement("span");
|
|
167
|
+
srOnly.className = "tvw-sr-only";
|
|
168
|
+
srOnly.textContent = "Loading";
|
|
169
|
+
|
|
170
|
+
container.appendChild(dot1);
|
|
171
|
+
container.appendChild(dot2);
|
|
172
|
+
container.appendChild(dot3);
|
|
173
|
+
container.appendChild(srOnly);
|
|
174
|
+
|
|
175
|
+
return container;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Message rendering with plugin support
|
|
179
|
+
const renderMessagesWithPlugins = (
|
|
180
|
+
container: HTMLElement,
|
|
181
|
+
messages: ChatWidgetMessage[],
|
|
182
|
+
transform: MessageTransform
|
|
183
|
+
) => {
|
|
184
|
+
container.innerHTML = "";
|
|
185
|
+
const fragment = document.createDocumentFragment();
|
|
186
|
+
|
|
187
|
+
messages.forEach((message) => {
|
|
188
|
+
let bubble: HTMLElement | null = null;
|
|
189
|
+
|
|
190
|
+
// Try plugins first
|
|
191
|
+
const matchingPlugin = plugins.find((p) => {
|
|
192
|
+
if (message.variant === "reasoning" && p.renderReasoning) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (message.variant === "tool" && p.renderToolCall) {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
if (!message.variant && p.renderMessage) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return false;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (matchingPlugin) {
|
|
205
|
+
if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
|
|
206
|
+
if (!showReasoning) return;
|
|
207
|
+
bubble = matchingPlugin.renderReasoning({
|
|
208
|
+
message,
|
|
209
|
+
defaultRenderer: () => createReasoningBubble(message),
|
|
210
|
+
config
|
|
211
|
+
});
|
|
212
|
+
} else if (message.variant === "tool" && message.toolCall && matchingPlugin.renderToolCall) {
|
|
213
|
+
if (!showToolCalls) return;
|
|
214
|
+
bubble = matchingPlugin.renderToolCall({
|
|
215
|
+
message,
|
|
216
|
+
defaultRenderer: () => createToolBubble(message),
|
|
217
|
+
config
|
|
218
|
+
});
|
|
219
|
+
} else if (matchingPlugin.renderMessage) {
|
|
220
|
+
bubble = matchingPlugin.renderMessage({
|
|
221
|
+
message,
|
|
222
|
+
defaultRenderer: () => {
|
|
223
|
+
const b = createStandardBubble(message, transform);
|
|
224
|
+
if (message.role !== "user") {
|
|
225
|
+
enhanceWithForms(b, message, config, session);
|
|
226
|
+
}
|
|
227
|
+
return b;
|
|
228
|
+
},
|
|
229
|
+
config
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Fallback to default rendering if plugin returned null or no plugin matched
|
|
235
|
+
if (!bubble) {
|
|
236
|
+
if (message.variant === "reasoning" && message.reasoning) {
|
|
237
|
+
if (!showReasoning) return;
|
|
238
|
+
bubble = createReasoningBubble(message);
|
|
239
|
+
} else if (message.variant === "tool" && message.toolCall) {
|
|
240
|
+
if (!showToolCalls) return;
|
|
241
|
+
bubble = createToolBubble(message);
|
|
242
|
+
} else {
|
|
243
|
+
bubble = createStandardBubble(message, transform);
|
|
244
|
+
if (message.role !== "user") {
|
|
245
|
+
enhanceWithForms(bubble, message, config, session);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const wrapper = document.createElement("div");
|
|
251
|
+
wrapper.className = "tvw-flex";
|
|
252
|
+
if (message.role === "user") {
|
|
253
|
+
wrapper.classList.add("tvw-justify-end");
|
|
254
|
+
}
|
|
255
|
+
wrapper.appendChild(bubble);
|
|
256
|
+
fragment.appendChild(wrapper);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Add typing indicator if streaming and there's at least one user message
|
|
260
|
+
if (isStreaming && messages.some((msg) => msg.role === "user")) {
|
|
261
|
+
const typingIndicator = createTypingIndicator();
|
|
262
|
+
const typingWrapper = document.createElement("div");
|
|
263
|
+
typingWrapper.className = "tvw-flex tvw-justify-end";
|
|
264
|
+
typingWrapper.appendChild(typingIndicator);
|
|
265
|
+
fragment.appendChild(typingWrapper);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
container.appendChild(fragment);
|
|
269
|
+
container.scrollTop = container.scrollHeight;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const updateOpenState = () => {
|
|
273
|
+
if (!launcherEnabled) return;
|
|
274
|
+
if (open) {
|
|
275
|
+
wrapper.classList.remove("tvw-pointer-events-none", "tvw-opacity-0");
|
|
276
|
+
panel.classList.remove("tvw-scale-95", "tvw-opacity-0");
|
|
277
|
+
panel.classList.add("tvw-scale-100", "tvw-opacity-100");
|
|
278
|
+
// Hide launcher button when widget is open
|
|
279
|
+
if (launcherButtonInstance) {
|
|
280
|
+
launcherButtonInstance.element.style.display = "none";
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
wrapper.classList.add("tvw-pointer-events-none", "tvw-opacity-0");
|
|
284
|
+
panel.classList.remove("tvw-scale-100", "tvw-opacity-100");
|
|
285
|
+
panel.classList.add("tvw-scale-95", "tvw-opacity-0");
|
|
286
|
+
// Show launcher button when widget is closed
|
|
287
|
+
if (launcherButtonInstance) {
|
|
288
|
+
launcherButtonInstance.element.style.display = "";
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const setOpenState = (nextOpen: boolean) => {
|
|
294
|
+
if (!launcherEnabled) return;
|
|
295
|
+
if (open === nextOpen) return;
|
|
296
|
+
open = nextOpen;
|
|
297
|
+
updateOpenState();
|
|
298
|
+
if (open) {
|
|
299
|
+
recalcPanelHeight();
|
|
300
|
+
scheduleAutoScroll(true);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const setComposerDisabled = (disabled: boolean) => {
|
|
305
|
+
textarea.disabled = disabled;
|
|
306
|
+
sendButton.disabled = disabled;
|
|
307
|
+
if (micButton) {
|
|
308
|
+
micButton.disabled = disabled;
|
|
309
|
+
}
|
|
310
|
+
suggestionsManager.buttons.forEach((btn) => {
|
|
311
|
+
btn.disabled = disabled;
|
|
312
|
+
});
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const updateCopy = () => {
|
|
316
|
+
introTitle.textContent = config.copy?.welcomeTitle ?? "Hello 👋";
|
|
317
|
+
introSubtitle.textContent =
|
|
318
|
+
config.copy?.welcomeSubtitle ??
|
|
319
|
+
"Ask anything about your account or products.";
|
|
320
|
+
textarea.placeholder = config.copy?.inputPlaceholder ?? "Type your message…";
|
|
321
|
+
sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
|
|
322
|
+
|
|
323
|
+
// Update textarea font family and weight
|
|
324
|
+
const fontFamily = config.theme?.inputFontFamily ?? "sans-serif";
|
|
325
|
+
const fontWeight = config.theme?.inputFontWeight ?? "400";
|
|
326
|
+
|
|
327
|
+
const getFontFamilyValue = (family: "sans-serif" | "serif" | "mono"): string => {
|
|
328
|
+
switch (family) {
|
|
329
|
+
case "serif":
|
|
330
|
+
return 'Georgia, "Times New Roman", Times, serif';
|
|
331
|
+
case "mono":
|
|
332
|
+
return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
|
|
333
|
+
case "sans-serif":
|
|
334
|
+
default:
|
|
335
|
+
return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
textarea.style.fontFamily = getFontFamilyValue(fontFamily);
|
|
340
|
+
textarea.style.fontWeight = fontWeight;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
session = new ChatWidgetSession(config, {
|
|
344
|
+
onMessagesChanged(messages) {
|
|
345
|
+
renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
|
|
346
|
+
// Re-render suggestions to hide them after first user message
|
|
347
|
+
// Pass messages directly to avoid calling session.getMessages() during construction
|
|
348
|
+
if (session) {
|
|
349
|
+
const hasUserMessage = messages.some((msg) => msg.role === "user");
|
|
350
|
+
if (hasUserMessage) {
|
|
351
|
+
// Hide suggestions if user message exists
|
|
352
|
+
suggestionsManager.render([], session, textarea, messages);
|
|
353
|
+
} else {
|
|
354
|
+
// Show suggestions if no user message yet
|
|
355
|
+
suggestionsManager.render(config.suggestionChips, session, textarea, messages);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
scheduleAutoScroll(!isStreaming);
|
|
359
|
+
},
|
|
360
|
+
onStatusChanged(status) {
|
|
361
|
+
const currentStatusConfig = config.statusIndicator ?? {};
|
|
362
|
+
const getCurrentStatusText = (status: ChatWidgetSessionStatus): string => {
|
|
363
|
+
if (status === "idle") return currentStatusConfig.idleText ?? statusCopy.idle;
|
|
364
|
+
if (status === "connecting") return currentStatusConfig.connectingText ?? statusCopy.connecting;
|
|
365
|
+
if (status === "connected") return currentStatusConfig.connectedText ?? statusCopy.connected;
|
|
366
|
+
if (status === "error") return currentStatusConfig.errorText ?? statusCopy.error;
|
|
367
|
+
return statusCopy[status];
|
|
368
|
+
};
|
|
369
|
+
statusText.textContent = getCurrentStatusText(status);
|
|
370
|
+
},
|
|
371
|
+
onStreamingChanged(streaming) {
|
|
372
|
+
isStreaming = streaming;
|
|
373
|
+
setComposerDisabled(streaming);
|
|
374
|
+
// Re-render messages to show/hide typing indicator
|
|
375
|
+
if (session) {
|
|
376
|
+
renderMessagesWithPlugins(messagesWrapper, session.getMessages(), postprocess);
|
|
377
|
+
}
|
|
378
|
+
if (!streaming) {
|
|
379
|
+
scheduleAutoScroll(true);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const handleSubmit = (event: Event) => {
|
|
385
|
+
event.preventDefault();
|
|
386
|
+
const value = textarea.value.trim();
|
|
387
|
+
if (!value) return;
|
|
388
|
+
textarea.value = "";
|
|
389
|
+
session.sendMessage(value);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const handleInputEnter = (event: KeyboardEvent) => {
|
|
393
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
394
|
+
event.preventDefault();
|
|
395
|
+
sendButton.click();
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Voice recognition state and logic
|
|
400
|
+
let speechRecognition: any = null;
|
|
401
|
+
let isRecording = false;
|
|
402
|
+
let pauseTimer: number | null = null;
|
|
403
|
+
let originalMicStyles: {
|
|
404
|
+
backgroundColor: string;
|
|
405
|
+
color: string;
|
|
406
|
+
borderColor: string;
|
|
407
|
+
} | null = null;
|
|
408
|
+
|
|
409
|
+
const getSpeechRecognitionClass = (): any => {
|
|
410
|
+
if (typeof window === 'undefined') return null;
|
|
411
|
+
return (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition || null;
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
const startVoiceRecognition = () => {
|
|
415
|
+
if (isRecording || session.isStreaming()) return;
|
|
416
|
+
|
|
417
|
+
const SpeechRecognitionClass = getSpeechRecognitionClass();
|
|
418
|
+
if (!SpeechRecognitionClass) return;
|
|
419
|
+
|
|
420
|
+
speechRecognition = new SpeechRecognitionClass();
|
|
421
|
+
const voiceConfig = config.voiceRecognition ?? {};
|
|
422
|
+
const pauseDuration = voiceConfig.pauseDuration ?? 2000;
|
|
423
|
+
|
|
424
|
+
speechRecognition.continuous = true;
|
|
425
|
+
speechRecognition.interimResults = true;
|
|
426
|
+
speechRecognition.lang = 'en-US';
|
|
427
|
+
|
|
428
|
+
// Store the initial text that was in the textarea
|
|
429
|
+
const initialText = textarea.value;
|
|
430
|
+
|
|
431
|
+
speechRecognition.onresult = (event: any) => {
|
|
432
|
+
// Build the complete transcript from all results
|
|
433
|
+
let fullTranscript = "";
|
|
434
|
+
let interimTranscript = "";
|
|
435
|
+
|
|
436
|
+
// Process all results from the beginning
|
|
437
|
+
for (let i = 0; i < event.results.length; i++) {
|
|
438
|
+
const result = event.results[i];
|
|
439
|
+
const transcript = result[0].transcript;
|
|
440
|
+
|
|
441
|
+
if (result.isFinal) {
|
|
442
|
+
fullTranscript += transcript + " ";
|
|
443
|
+
} else {
|
|
444
|
+
// Only take the last interim result
|
|
445
|
+
interimTranscript = transcript;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Update textarea with initial text + full transcript + interim
|
|
450
|
+
const newValue = initialText + fullTranscript + interimTranscript;
|
|
451
|
+
textarea.value = newValue;
|
|
452
|
+
|
|
453
|
+
// Reset pause timer on each result
|
|
454
|
+
if (pauseTimer) {
|
|
455
|
+
clearTimeout(pauseTimer);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Set timer to auto-submit after pause when we have any speech
|
|
459
|
+
if (fullTranscript || interimTranscript) {
|
|
460
|
+
pauseTimer = window.setTimeout(() => {
|
|
461
|
+
const finalValue = textarea.value.trim();
|
|
462
|
+
if (finalValue && speechRecognition && isRecording) {
|
|
463
|
+
stopVoiceRecognition();
|
|
464
|
+
textarea.value = "";
|
|
465
|
+
session.sendMessage(finalValue);
|
|
466
|
+
}
|
|
467
|
+
}, pauseDuration);
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
speechRecognition.onerror = (event: any) => {
|
|
472
|
+
// Don't stop on "no-speech" error, just ignore it
|
|
473
|
+
if (event.error !== 'no-speech') {
|
|
474
|
+
stopVoiceRecognition();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
speechRecognition.onend = () => {
|
|
479
|
+
// If recognition ended naturally (not manually stopped), submit if there's text
|
|
480
|
+
if (isRecording) {
|
|
481
|
+
const finalValue = textarea.value.trim();
|
|
482
|
+
if (finalValue && finalValue !== initialText.trim()) {
|
|
483
|
+
textarea.value = "";
|
|
484
|
+
session.sendMessage(finalValue);
|
|
485
|
+
}
|
|
486
|
+
stopVoiceRecognition();
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
speechRecognition.start();
|
|
492
|
+
isRecording = true;
|
|
493
|
+
if (micButton) {
|
|
494
|
+
// Store original styles
|
|
495
|
+
originalMicStyles = {
|
|
496
|
+
backgroundColor: micButton.style.backgroundColor,
|
|
497
|
+
color: micButton.style.color,
|
|
498
|
+
borderColor: micButton.style.borderColor
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// Apply recording state styles from config
|
|
502
|
+
const voiceConfig = config.voiceRecognition ?? {};
|
|
503
|
+
const recordingBackgroundColor = voiceConfig.recordingBackgroundColor ?? "#ef4444";
|
|
504
|
+
const recordingIconColor = voiceConfig.recordingIconColor;
|
|
505
|
+
const recordingBorderColor = voiceConfig.recordingBorderColor;
|
|
506
|
+
|
|
507
|
+
micButton.classList.add("tvw-voice-recording");
|
|
508
|
+
micButton.style.backgroundColor = recordingBackgroundColor;
|
|
509
|
+
|
|
510
|
+
if (recordingIconColor) {
|
|
511
|
+
micButton.style.color = recordingIconColor;
|
|
512
|
+
// Update SVG stroke color if present
|
|
513
|
+
const svg = micButton.querySelector("svg");
|
|
514
|
+
if (svg) {
|
|
515
|
+
svg.setAttribute("stroke", recordingIconColor);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (recordingBorderColor) {
|
|
520
|
+
micButton.style.borderColor = recordingBorderColor;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
micButton.setAttribute("aria-label", "Stop voice recognition");
|
|
524
|
+
}
|
|
525
|
+
} catch (error) {
|
|
526
|
+
stopVoiceRecognition();
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const stopVoiceRecognition = () => {
|
|
531
|
+
if (!isRecording) return;
|
|
532
|
+
|
|
533
|
+
isRecording = false;
|
|
534
|
+
if (pauseTimer) {
|
|
535
|
+
clearTimeout(pauseTimer);
|
|
536
|
+
pauseTimer = null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (speechRecognition) {
|
|
540
|
+
try {
|
|
541
|
+
speechRecognition.stop();
|
|
542
|
+
} catch (error) {
|
|
543
|
+
// Ignore errors when stopping
|
|
544
|
+
}
|
|
545
|
+
speechRecognition = null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (micButton) {
|
|
549
|
+
micButton.classList.remove("tvw-voice-recording");
|
|
550
|
+
|
|
551
|
+
// Restore original styles
|
|
552
|
+
if (originalMicStyles) {
|
|
553
|
+
micButton.style.backgroundColor = originalMicStyles.backgroundColor;
|
|
554
|
+
micButton.style.color = originalMicStyles.color;
|
|
555
|
+
micButton.style.borderColor = originalMicStyles.borderColor;
|
|
556
|
+
|
|
557
|
+
// Restore SVG stroke color if present
|
|
558
|
+
const svg = micButton.querySelector("svg");
|
|
559
|
+
if (svg) {
|
|
560
|
+
svg.setAttribute("stroke", originalMicStyles.color || "currentColor");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
originalMicStyles = null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
micButton.setAttribute("aria-label", "Start voice recognition");
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// Function to create mic button dynamically
|
|
571
|
+
const createMicButton = (voiceConfig: ChatWidgetConfig['voiceRecognition'], sendButtonConfig: ChatWidgetConfig['sendButton']): { micButton: HTMLButtonElement; micButtonWrapper: HTMLElement } | null => {
|
|
572
|
+
const hasSpeechRecognition =
|
|
573
|
+
typeof window !== 'undefined' &&
|
|
574
|
+
(typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
|
|
575
|
+
typeof (window as any).SpeechRecognition !== 'undefined');
|
|
576
|
+
|
|
577
|
+
if (!hasSpeechRecognition) return null;
|
|
578
|
+
|
|
579
|
+
const micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
|
|
580
|
+
const micButton = createElement(
|
|
581
|
+
"button",
|
|
582
|
+
"tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
|
|
583
|
+
) as HTMLButtonElement;
|
|
584
|
+
|
|
585
|
+
micButton.type = "button";
|
|
586
|
+
micButton.setAttribute("aria-label", "Start voice recognition");
|
|
587
|
+
|
|
588
|
+
const micIconName = voiceConfig?.iconName ?? "mic";
|
|
589
|
+
const buttonSize = sendButtonConfig?.size ?? "40px";
|
|
590
|
+
const micIconSize = voiceConfig?.iconSize ?? buttonSize;
|
|
591
|
+
const micIconSizeNum = parseFloat(micIconSize) || 24;
|
|
592
|
+
|
|
593
|
+
// Use dedicated colors from voice recognition config, fallback to send button colors
|
|
594
|
+
const backgroundColor = voiceConfig?.backgroundColor ?? sendButtonConfig?.backgroundColor;
|
|
595
|
+
const iconColor = voiceConfig?.iconColor ?? sendButtonConfig?.textColor;
|
|
596
|
+
|
|
597
|
+
micButton.style.width = micIconSize;
|
|
598
|
+
micButton.style.height = micIconSize;
|
|
599
|
+
micButton.style.minWidth = micIconSize;
|
|
600
|
+
micButton.style.minHeight = micIconSize;
|
|
601
|
+
micButton.style.fontSize = "18px";
|
|
602
|
+
micButton.style.lineHeight = "1";
|
|
603
|
+
|
|
604
|
+
// Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
|
|
605
|
+
const iconColorValue = iconColor || "currentColor";
|
|
606
|
+
const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
|
|
607
|
+
if (micIconSvg) {
|
|
608
|
+
micButton.appendChild(micIconSvg);
|
|
609
|
+
micButton.style.color = iconColorValue;
|
|
610
|
+
} else {
|
|
611
|
+
// Fallback to text if icon fails
|
|
612
|
+
micButton.textContent = "🎤";
|
|
613
|
+
micButton.style.color = iconColorValue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Apply background color
|
|
617
|
+
if (backgroundColor) {
|
|
618
|
+
micButton.style.backgroundColor = backgroundColor;
|
|
619
|
+
} else {
|
|
620
|
+
micButton.classList.add("tvw-bg-cw-primary");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Apply icon/text color
|
|
624
|
+
if (iconColor) {
|
|
625
|
+
micButton.style.color = iconColor;
|
|
626
|
+
} else if (!iconColor && !sendButtonConfig?.textColor) {
|
|
627
|
+
micButton.classList.add("tvw-text-white");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Apply border styling
|
|
631
|
+
if (voiceConfig?.borderWidth) {
|
|
632
|
+
micButton.style.borderWidth = voiceConfig.borderWidth;
|
|
633
|
+
micButton.style.borderStyle = "solid";
|
|
634
|
+
}
|
|
635
|
+
if (voiceConfig?.borderColor) {
|
|
636
|
+
micButton.style.borderColor = voiceConfig.borderColor;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Apply padding styling
|
|
640
|
+
if (voiceConfig?.paddingX) {
|
|
641
|
+
micButton.style.paddingLeft = voiceConfig.paddingX;
|
|
642
|
+
micButton.style.paddingRight = voiceConfig.paddingX;
|
|
643
|
+
}
|
|
644
|
+
if (voiceConfig?.paddingY) {
|
|
645
|
+
micButton.style.paddingTop = voiceConfig.paddingY;
|
|
646
|
+
micButton.style.paddingBottom = voiceConfig.paddingY;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
micButtonWrapper.appendChild(micButton);
|
|
650
|
+
|
|
651
|
+
// Add tooltip if enabled
|
|
652
|
+
const tooltipText = voiceConfig?.tooltipText ?? "Start voice recognition";
|
|
653
|
+
const showTooltip = voiceConfig?.showTooltip ?? false;
|
|
654
|
+
if (showTooltip && tooltipText) {
|
|
655
|
+
const tooltip = createElement("div", "tvw-send-button-tooltip");
|
|
656
|
+
tooltip.textContent = tooltipText;
|
|
657
|
+
micButtonWrapper.appendChild(tooltip);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return { micButton, micButtonWrapper };
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
// Wire up mic button click handler
|
|
664
|
+
const handleMicButtonClick = () => {
|
|
665
|
+
if (isRecording) {
|
|
666
|
+
// Stop recording and submit
|
|
667
|
+
const finalValue = textarea.value.trim();
|
|
668
|
+
stopVoiceRecognition();
|
|
669
|
+
if (finalValue) {
|
|
670
|
+
textarea.value = "";
|
|
671
|
+
session.sendMessage(finalValue);
|
|
672
|
+
}
|
|
673
|
+
} else {
|
|
674
|
+
// Start recording
|
|
675
|
+
startVoiceRecognition();
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
if (micButton) {
|
|
680
|
+
micButton.addEventListener("click", handleMicButtonClick);
|
|
681
|
+
|
|
682
|
+
destroyCallbacks.push(() => {
|
|
683
|
+
stopVoiceRecognition();
|
|
684
|
+
if (micButton) {
|
|
685
|
+
micButton.removeEventListener("click", handleMicButtonClick);
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const toggleOpen = () => {
|
|
691
|
+
setOpenState(!open);
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
let launcherButtonInstance = launcherEnabled
|
|
695
|
+
? createLauncherButton(config, toggleOpen)
|
|
696
|
+
: null;
|
|
697
|
+
|
|
698
|
+
if (launcherButtonInstance) {
|
|
699
|
+
mount.appendChild(launcherButtonInstance.element);
|
|
700
|
+
}
|
|
701
|
+
updateOpenState();
|
|
702
|
+
suggestionsManager.render(config.suggestionChips, session, textarea);
|
|
703
|
+
updateCopy();
|
|
704
|
+
setComposerDisabled(session.isStreaming());
|
|
705
|
+
scheduleAutoScroll(true);
|
|
706
|
+
|
|
707
|
+
const recalcPanelHeight = () => {
|
|
708
|
+
if (!launcherEnabled) {
|
|
709
|
+
panel.style.height = "";
|
|
710
|
+
panel.style.width = "";
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
|
|
714
|
+
const width = launcherWidth ?? "min(360px, calc(100vw - 24px))";
|
|
715
|
+
panel.style.width = width;
|
|
716
|
+
panel.style.maxWidth = width;
|
|
717
|
+
const viewportHeight = window.innerHeight;
|
|
718
|
+
const verticalMargin = 64; // leave space for launcher's offset
|
|
719
|
+
const available = Math.max(200, viewportHeight - verticalMargin);
|
|
720
|
+
const clamped = Math.min(640, available);
|
|
721
|
+
panel.style.height = `${clamped}px`;
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
recalcPanelHeight();
|
|
725
|
+
window.addEventListener("resize", recalcPanelHeight);
|
|
726
|
+
destroyCallbacks.push(() => window.removeEventListener("resize", recalcPanelHeight));
|
|
727
|
+
|
|
728
|
+
lastScrollTop = body.scrollTop;
|
|
729
|
+
|
|
730
|
+
const handleScroll = () => {
|
|
731
|
+
const scrollTop = body.scrollTop;
|
|
732
|
+
const scrollHeight = body.scrollHeight;
|
|
733
|
+
const clientHeight = body.clientHeight;
|
|
734
|
+
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
735
|
+
const delta = Math.abs(scrollTop - lastScrollTop);
|
|
736
|
+
lastScrollTop = scrollTop;
|
|
737
|
+
|
|
738
|
+
if (isAutoScrolling) return;
|
|
739
|
+
if (delta <= USER_SCROLL_THRESHOLD) return;
|
|
740
|
+
|
|
741
|
+
if (!shouldAutoScroll && distanceFromBottom < BOTTOM_THRESHOLD) {
|
|
742
|
+
isAutoScrollBlocked = false;
|
|
743
|
+
shouldAutoScroll = true;
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (shouldAutoScroll && distanceFromBottom > BOTTOM_THRESHOLD) {
|
|
748
|
+
isAutoScrollBlocked = true;
|
|
749
|
+
blockUntilTime = Date.now() + AUTO_SCROLL_BLOCK_TIME;
|
|
750
|
+
shouldAutoScroll = false;
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
body.addEventListener("scroll", handleScroll, { passive: true });
|
|
755
|
+
destroyCallbacks.push(() => body.removeEventListener("scroll", handleScroll));
|
|
756
|
+
destroyCallbacks.push(() => {
|
|
757
|
+
if (scrollRAF) cancelAnimationFrame(scrollRAF);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const refreshCloseButton = () => {
|
|
761
|
+
if (!closeButton) return;
|
|
762
|
+
if (closeHandler) {
|
|
763
|
+
closeButton.removeEventListener("click", closeHandler);
|
|
764
|
+
closeHandler = null;
|
|
765
|
+
}
|
|
766
|
+
if (launcherEnabled) {
|
|
767
|
+
closeButton.style.display = "";
|
|
768
|
+
closeHandler = () => {
|
|
769
|
+
open = false;
|
|
770
|
+
updateOpenState();
|
|
771
|
+
};
|
|
772
|
+
closeButton.addEventListener("click", closeHandler);
|
|
773
|
+
} else {
|
|
774
|
+
closeButton.style.display = "none";
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
refreshCloseButton();
|
|
779
|
+
|
|
780
|
+
composerForm.addEventListener("submit", handleSubmit);
|
|
781
|
+
textarea.addEventListener("keydown", handleInputEnter);
|
|
782
|
+
|
|
783
|
+
destroyCallbacks.push(() => {
|
|
784
|
+
composerForm.removeEventListener("submit", handleSubmit);
|
|
785
|
+
textarea.removeEventListener("keydown", handleInputEnter);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
destroyCallbacks.push(() => {
|
|
789
|
+
session.cancel();
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
if (launcherButtonInstance) {
|
|
793
|
+
destroyCallbacks.push(() => {
|
|
794
|
+
launcherButtonInstance?.destroy();
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return {
|
|
799
|
+
update(nextConfig: ChatWidgetConfig) {
|
|
800
|
+
config = { ...config, ...nextConfig };
|
|
801
|
+
applyThemeVariables(mount, config);
|
|
802
|
+
|
|
803
|
+
// Update plugins
|
|
804
|
+
const newPlugins = pluginRegistry.getForInstance(config.plugins);
|
|
805
|
+
plugins.length = 0;
|
|
806
|
+
plugins.push(...newPlugins);
|
|
807
|
+
|
|
808
|
+
launcherEnabled = config.launcher?.enabled ?? true;
|
|
809
|
+
autoExpand = config.launcher?.autoExpand ?? false;
|
|
810
|
+
showReasoning = config.features?.showReasoning ?? true;
|
|
811
|
+
showToolCalls = config.features?.showToolCalls ?? true;
|
|
812
|
+
|
|
813
|
+
if (config.launcher?.enabled === false && launcherButtonInstance) {
|
|
814
|
+
launcherButtonInstance.destroy();
|
|
815
|
+
launcherButtonInstance = null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (config.launcher?.enabled !== false && !launcherButtonInstance) {
|
|
819
|
+
launcherButtonInstance = createLauncherButton(config, toggleOpen);
|
|
820
|
+
mount.appendChild(launcherButtonInstance.element);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (launcherButtonInstance) {
|
|
824
|
+
launcherButtonInstance.update(config);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// Only update open state if launcher enabled state changed or autoExpand value changed
|
|
828
|
+
const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
|
|
829
|
+
const autoExpandChanged = autoExpand !== prevAutoExpand;
|
|
830
|
+
|
|
831
|
+
if (launcherEnabledChanged) {
|
|
832
|
+
// Launcher was enabled/disabled - update state accordingly
|
|
833
|
+
if (!launcherEnabled) {
|
|
834
|
+
// When launcher is disabled, always keep panel open
|
|
835
|
+
open = true;
|
|
836
|
+
updateOpenState();
|
|
837
|
+
} else {
|
|
838
|
+
// Launcher was just enabled - respect autoExpand setting
|
|
839
|
+
setOpenState(autoExpand);
|
|
840
|
+
}
|
|
841
|
+
} else if (autoExpandChanged) {
|
|
842
|
+
// autoExpand value changed - update state to match
|
|
843
|
+
setOpenState(autoExpand);
|
|
844
|
+
}
|
|
845
|
+
// Otherwise, preserve current open state (user may have manually opened/closed)
|
|
846
|
+
|
|
847
|
+
// Update previous values for next comparison
|
|
848
|
+
prevAutoExpand = autoExpand;
|
|
849
|
+
prevLauncherEnabled = launcherEnabled;
|
|
850
|
+
recalcPanelHeight();
|
|
851
|
+
refreshCloseButton();
|
|
852
|
+
|
|
853
|
+
// Update panel icon sizes
|
|
854
|
+
const launcher = config.launcher ?? {};
|
|
855
|
+
const headerIconHidden = launcher.headerIconHidden ?? false;
|
|
856
|
+
const headerIconName = launcher.headerIconName;
|
|
857
|
+
const headerIconSize = launcher.headerIconSize ?? "48px";
|
|
858
|
+
|
|
859
|
+
if (iconHolder) {
|
|
860
|
+
const header = container.querySelector(".tvw-border-b-cw-divider");
|
|
861
|
+
const headerCopy = header?.querySelector(".tvw-flex-col");
|
|
862
|
+
|
|
863
|
+
// Handle hide/show
|
|
864
|
+
if (headerIconHidden) {
|
|
865
|
+
// Hide iconHolder
|
|
866
|
+
iconHolder.style.display = "none";
|
|
867
|
+
// Ensure headerCopy is still in header
|
|
868
|
+
if (header && headerCopy && !header.contains(headerCopy)) {
|
|
869
|
+
header.insertBefore(headerCopy, header.firstChild);
|
|
870
|
+
}
|
|
871
|
+
} else {
|
|
872
|
+
// Show iconHolder
|
|
873
|
+
iconHolder.style.display = "";
|
|
874
|
+
iconHolder.style.height = headerIconSize;
|
|
875
|
+
iconHolder.style.width = headerIconSize;
|
|
876
|
+
|
|
877
|
+
// Ensure iconHolder is before headerCopy in header
|
|
878
|
+
if (header && headerCopy) {
|
|
879
|
+
if (!header.contains(iconHolder)) {
|
|
880
|
+
header.insertBefore(iconHolder, headerCopy);
|
|
881
|
+
} else if (iconHolder.nextSibling !== headerCopy) {
|
|
882
|
+
// Reorder if needed
|
|
883
|
+
iconHolder.remove();
|
|
884
|
+
header.insertBefore(iconHolder, headerCopy);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Update icon content based on priority: Lucide icon > iconUrl > agentIconText
|
|
889
|
+
if (headerIconName) {
|
|
890
|
+
// Use Lucide icon
|
|
891
|
+
const iconSize = parseFloat(headerIconSize) || 24;
|
|
892
|
+
const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "#ffffff", 2);
|
|
893
|
+
if (iconSvg) {
|
|
894
|
+
iconHolder.replaceChildren(iconSvg);
|
|
895
|
+
} else {
|
|
896
|
+
// Fallback to agentIconText if Lucide icon fails
|
|
897
|
+
iconHolder.textContent = launcher.agentIconText ?? "💬";
|
|
898
|
+
}
|
|
899
|
+
} else if (launcher.iconUrl) {
|
|
900
|
+
// Use image URL
|
|
901
|
+
const img = iconHolder.querySelector("img");
|
|
902
|
+
if (img) {
|
|
903
|
+
img.src = launcher.iconUrl;
|
|
904
|
+
img.style.height = headerIconSize;
|
|
905
|
+
img.style.width = headerIconSize;
|
|
906
|
+
} else {
|
|
907
|
+
// Create new img if it doesn't exist
|
|
908
|
+
const newImg = document.createElement("img");
|
|
909
|
+
newImg.src = launcher.iconUrl;
|
|
910
|
+
newImg.alt = "";
|
|
911
|
+
newImg.className = "tvw-rounded-xl tvw-object-cover";
|
|
912
|
+
newImg.style.height = headerIconSize;
|
|
913
|
+
newImg.style.width = headerIconSize;
|
|
914
|
+
iconHolder.replaceChildren(newImg);
|
|
915
|
+
}
|
|
916
|
+
} else {
|
|
917
|
+
// Use text/emoji - clear any SVG or img first
|
|
918
|
+
const existingSvg = iconHolder.querySelector("svg");
|
|
919
|
+
const existingImg = iconHolder.querySelector("img");
|
|
920
|
+
if (existingSvg || existingImg) {
|
|
921
|
+
iconHolder.replaceChildren();
|
|
922
|
+
}
|
|
923
|
+
iconHolder.textContent = launcher.agentIconText ?? "💬";
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Update image size if present
|
|
927
|
+
const img = iconHolder.querySelector("img");
|
|
928
|
+
if (img) {
|
|
929
|
+
img.style.height = headerIconSize;
|
|
930
|
+
img.style.width = headerIconSize;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (closeButton) {
|
|
935
|
+
const closeButtonSize = launcher.closeButtonSize ?? "32px";
|
|
936
|
+
const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
|
|
937
|
+
closeButton.style.height = closeButtonSize;
|
|
938
|
+
closeButton.style.width = closeButtonSize;
|
|
939
|
+
|
|
940
|
+
// Update placement if changed
|
|
941
|
+
const isTopRight = closeButtonPlacement === "top-right";
|
|
942
|
+
const hasTopRightClasses = closeButton.classList.contains("tvw-absolute");
|
|
943
|
+
|
|
944
|
+
if (isTopRight !== hasTopRightClasses) {
|
|
945
|
+
// Placement changed - need to move button and update classes
|
|
946
|
+
closeButton.remove();
|
|
947
|
+
|
|
948
|
+
// Update classes
|
|
949
|
+
if (isTopRight) {
|
|
950
|
+
closeButton.className = "tvw-absolute tvw-top-4 tvw-right-4 tvw-z-50 tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-text-cw-muted hover:tvw-bg-gray-100 tvw-cursor-pointer tvw-border-none";
|
|
951
|
+
container.style.position = "relative";
|
|
952
|
+
container.appendChild(closeButton);
|
|
953
|
+
} else {
|
|
954
|
+
closeButton.className = "tvw-ml-auto tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-text-cw-muted hover:tvw-bg-gray-100 tvw-cursor-pointer tvw-border-none";
|
|
955
|
+
// Find header element (first child of container)
|
|
956
|
+
const header = container.querySelector(".tvw-border-b-cw-divider");
|
|
957
|
+
if (header) {
|
|
958
|
+
header.appendChild(closeButton);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Apply close button styling from config
|
|
964
|
+
if (launcher.closeButtonColor) {
|
|
965
|
+
closeButton.style.color = launcher.closeButtonColor;
|
|
966
|
+
closeButton.classList.remove("tvw-text-cw-muted");
|
|
967
|
+
} else {
|
|
968
|
+
closeButton.style.color = "";
|
|
969
|
+
closeButton.classList.add("tvw-text-cw-muted");
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (launcher.closeButtonBackgroundColor) {
|
|
973
|
+
closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
|
|
974
|
+
closeButton.classList.remove("hover:tvw-bg-gray-100");
|
|
975
|
+
} else {
|
|
976
|
+
closeButton.style.backgroundColor = "";
|
|
977
|
+
closeButton.classList.add("hover:tvw-bg-gray-100");
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// Apply border if width and/or color are provided
|
|
981
|
+
if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
|
|
982
|
+
const borderWidth = launcher.closeButtonBorderWidth || "0px";
|
|
983
|
+
const borderColor = launcher.closeButtonBorderColor || "transparent";
|
|
984
|
+
closeButton.style.border = `${borderWidth} solid ${borderColor}`;
|
|
985
|
+
closeButton.classList.remove("tvw-border-none");
|
|
986
|
+
} else {
|
|
987
|
+
closeButton.style.border = "";
|
|
988
|
+
closeButton.classList.add("tvw-border-none");
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (launcher.closeButtonBorderRadius) {
|
|
992
|
+
closeButton.style.borderRadius = launcher.closeButtonBorderRadius;
|
|
993
|
+
closeButton.classList.remove("tvw-rounded-full");
|
|
994
|
+
} else {
|
|
995
|
+
closeButton.style.borderRadius = "";
|
|
996
|
+
closeButton.classList.add("tvw-rounded-full");
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
postprocess = buildPostprocessor(config);
|
|
1001
|
+
session.updateConfig(config);
|
|
1002
|
+
renderMessagesWithPlugins(
|
|
1003
|
+
messagesWrapper,
|
|
1004
|
+
session.getMessages(),
|
|
1005
|
+
postprocess
|
|
1006
|
+
);
|
|
1007
|
+
suggestionsManager.render(config.suggestionChips, session, textarea);
|
|
1008
|
+
updateCopy();
|
|
1009
|
+
setComposerDisabled(session.isStreaming());
|
|
1010
|
+
|
|
1011
|
+
// Update voice recognition mic button visibility
|
|
1012
|
+
const voiceRecognitionEnabled = config.voiceRecognition?.enabled === true;
|
|
1013
|
+
const hasSpeechRecognition =
|
|
1014
|
+
typeof window !== 'undefined' &&
|
|
1015
|
+
(typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
|
|
1016
|
+
typeof (window as any).SpeechRecognition !== 'undefined');
|
|
1017
|
+
|
|
1018
|
+
// Update composer form gap based on voice recognition
|
|
1019
|
+
const shouldUseSmallGap = voiceRecognitionEnabled && hasSpeechRecognition;
|
|
1020
|
+
composerForm.classList.remove("tvw-gap-1", "tvw-gap-3");
|
|
1021
|
+
composerForm.classList.add(shouldUseSmallGap ? "tvw-gap-1" : "tvw-gap-3");
|
|
1022
|
+
|
|
1023
|
+
if (voiceRecognitionEnabled && hasSpeechRecognition) {
|
|
1024
|
+
// Create or update mic button
|
|
1025
|
+
if (!micButton || !micButtonWrapper) {
|
|
1026
|
+
// Create new mic button
|
|
1027
|
+
const micButtonResult = createMicButton(config.voiceRecognition, config.sendButton);
|
|
1028
|
+
if (micButtonResult) {
|
|
1029
|
+
// Update the mutable references
|
|
1030
|
+
micButton = micButtonResult.micButton;
|
|
1031
|
+
micButtonWrapper = micButtonResult.micButtonWrapper;
|
|
1032
|
+
|
|
1033
|
+
// Insert before send button wrapper
|
|
1034
|
+
composerForm.insertBefore(micButtonWrapper, sendButtonWrapper);
|
|
1035
|
+
|
|
1036
|
+
// Wire up click handler
|
|
1037
|
+
micButton.addEventListener("click", handleMicButtonClick);
|
|
1038
|
+
|
|
1039
|
+
// Set disabled state
|
|
1040
|
+
micButton.disabled = session.isStreaming();
|
|
1041
|
+
}
|
|
1042
|
+
} else {
|
|
1043
|
+
// Update existing mic button with new config
|
|
1044
|
+
const voiceConfig = config.voiceRecognition ?? {};
|
|
1045
|
+
const sendButtonConfig = config.sendButton ?? {};
|
|
1046
|
+
|
|
1047
|
+
// Update icon name and size
|
|
1048
|
+
const micIconName = voiceConfig.iconName ?? "mic";
|
|
1049
|
+
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
1050
|
+
const micIconSize = voiceConfig.iconSize ?? buttonSize;
|
|
1051
|
+
const micIconSizeNum = parseFloat(micIconSize) || 24;
|
|
1052
|
+
|
|
1053
|
+
micButton.style.width = micIconSize;
|
|
1054
|
+
micButton.style.height = micIconSize;
|
|
1055
|
+
micButton.style.minWidth = micIconSize;
|
|
1056
|
+
micButton.style.minHeight = micIconSize;
|
|
1057
|
+
|
|
1058
|
+
// Update icon
|
|
1059
|
+
const iconColor = voiceConfig.iconColor ?? sendButtonConfig.textColor ?? "currentColor";
|
|
1060
|
+
micButton.innerHTML = "";
|
|
1061
|
+
const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColor, 2);
|
|
1062
|
+
if (micIconSvg) {
|
|
1063
|
+
micButton.appendChild(micIconSvg);
|
|
1064
|
+
} else {
|
|
1065
|
+
micButton.textContent = "🎤";
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Update colors
|
|
1069
|
+
const backgroundColor = voiceConfig.backgroundColor ?? sendButtonConfig.backgroundColor;
|
|
1070
|
+
if (backgroundColor) {
|
|
1071
|
+
micButton.style.backgroundColor = backgroundColor;
|
|
1072
|
+
micButton.classList.remove("tvw-bg-cw-primary");
|
|
1073
|
+
} else {
|
|
1074
|
+
micButton.style.backgroundColor = "";
|
|
1075
|
+
micButton.classList.add("tvw-bg-cw-primary");
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
if (iconColor) {
|
|
1079
|
+
micButton.style.color = iconColor;
|
|
1080
|
+
micButton.classList.remove("tvw-text-white");
|
|
1081
|
+
} else if (!iconColor && !sendButtonConfig.textColor) {
|
|
1082
|
+
micButton.style.color = "";
|
|
1083
|
+
micButton.classList.add("tvw-text-white");
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Update border styling
|
|
1087
|
+
if (voiceConfig.borderWidth) {
|
|
1088
|
+
micButton.style.borderWidth = voiceConfig.borderWidth;
|
|
1089
|
+
micButton.style.borderStyle = "solid";
|
|
1090
|
+
} else {
|
|
1091
|
+
micButton.style.borderWidth = "";
|
|
1092
|
+
micButton.style.borderStyle = "";
|
|
1093
|
+
}
|
|
1094
|
+
if (voiceConfig.borderColor) {
|
|
1095
|
+
micButton.style.borderColor = voiceConfig.borderColor;
|
|
1096
|
+
} else {
|
|
1097
|
+
micButton.style.borderColor = "";
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Update padding styling
|
|
1101
|
+
if (voiceConfig.paddingX) {
|
|
1102
|
+
micButton.style.paddingLeft = voiceConfig.paddingX;
|
|
1103
|
+
micButton.style.paddingRight = voiceConfig.paddingX;
|
|
1104
|
+
} else {
|
|
1105
|
+
micButton.style.paddingLeft = "";
|
|
1106
|
+
micButton.style.paddingRight = "";
|
|
1107
|
+
}
|
|
1108
|
+
if (voiceConfig.paddingY) {
|
|
1109
|
+
micButton.style.paddingTop = voiceConfig.paddingY;
|
|
1110
|
+
micButton.style.paddingBottom = voiceConfig.paddingY;
|
|
1111
|
+
} else {
|
|
1112
|
+
micButton.style.paddingTop = "";
|
|
1113
|
+
micButton.style.paddingBottom = "";
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Update tooltip
|
|
1117
|
+
const tooltip = micButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
|
|
1118
|
+
const tooltipText = voiceConfig.tooltipText ?? "Start voice recognition";
|
|
1119
|
+
const showTooltip = voiceConfig.showTooltip ?? false;
|
|
1120
|
+
if (showTooltip && tooltipText) {
|
|
1121
|
+
if (!tooltip) {
|
|
1122
|
+
// Create tooltip if it doesn't exist
|
|
1123
|
+
const newTooltip = document.createElement("div");
|
|
1124
|
+
newTooltip.className = "tvw-send-button-tooltip";
|
|
1125
|
+
newTooltip.textContent = tooltipText;
|
|
1126
|
+
micButtonWrapper?.insertBefore(newTooltip, micButton);
|
|
1127
|
+
} else {
|
|
1128
|
+
tooltip.textContent = tooltipText;
|
|
1129
|
+
tooltip.style.display = "";
|
|
1130
|
+
}
|
|
1131
|
+
} else if (tooltip) {
|
|
1132
|
+
// Hide tooltip if disabled
|
|
1133
|
+
tooltip.style.display = "none";
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Show and update disabled state
|
|
1137
|
+
micButtonWrapper.style.display = "";
|
|
1138
|
+
micButton.disabled = session.isStreaming();
|
|
1139
|
+
}
|
|
1140
|
+
} else {
|
|
1141
|
+
// Hide mic button
|
|
1142
|
+
if (micButton && micButtonWrapper) {
|
|
1143
|
+
micButtonWrapper.style.display = "none";
|
|
1144
|
+
// Stop any active recording if disabling
|
|
1145
|
+
if (isRecording) {
|
|
1146
|
+
stopVoiceRecognition();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// Update send button styling
|
|
1152
|
+
const sendButtonConfig = config.sendButton ?? {};
|
|
1153
|
+
const useIcon = sendButtonConfig.useIcon ?? false;
|
|
1154
|
+
const iconText = sendButtonConfig.iconText ?? "↑";
|
|
1155
|
+
const iconName = sendButtonConfig.iconName;
|
|
1156
|
+
const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
|
|
1157
|
+
const showTooltip = sendButtonConfig.showTooltip ?? false;
|
|
1158
|
+
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
1159
|
+
const backgroundColor = sendButtonConfig.backgroundColor;
|
|
1160
|
+
const textColor = sendButtonConfig.textColor;
|
|
1161
|
+
|
|
1162
|
+
// Update button content and styling based on mode
|
|
1163
|
+
if (useIcon) {
|
|
1164
|
+
// Icon mode: circular button
|
|
1165
|
+
sendButton.style.width = buttonSize;
|
|
1166
|
+
sendButton.style.height = buttonSize;
|
|
1167
|
+
sendButton.style.minWidth = buttonSize;
|
|
1168
|
+
sendButton.style.minHeight = buttonSize;
|
|
1169
|
+
sendButton.style.fontSize = "18px";
|
|
1170
|
+
sendButton.style.lineHeight = "1";
|
|
1171
|
+
|
|
1172
|
+
// Clear existing content
|
|
1173
|
+
sendButton.innerHTML = "";
|
|
1174
|
+
|
|
1175
|
+
// Use Lucide icon if iconName is provided, otherwise fall back to iconText
|
|
1176
|
+
if (iconName) {
|
|
1177
|
+
const iconSize = parseFloat(buttonSize) || 24;
|
|
1178
|
+
const iconColor = textColor && typeof textColor === 'string' && textColor.trim() ? textColor.trim() : "currentColor";
|
|
1179
|
+
const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
|
|
1180
|
+
if (iconSvg) {
|
|
1181
|
+
sendButton.appendChild(iconSvg);
|
|
1182
|
+
sendButton.style.color = iconColor;
|
|
1183
|
+
} else {
|
|
1184
|
+
// Fallback to text if icon fails to render
|
|
1185
|
+
sendButton.textContent = iconText;
|
|
1186
|
+
if (textColor) {
|
|
1187
|
+
sendButton.style.color = textColor;
|
|
1188
|
+
} else {
|
|
1189
|
+
sendButton.classList.add("tvw-text-white");
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
} else {
|
|
1193
|
+
sendButton.textContent = iconText;
|
|
1194
|
+
if (textColor) {
|
|
1195
|
+
sendButton.style.color = textColor;
|
|
1196
|
+
} else {
|
|
1197
|
+
sendButton.classList.add("tvw-text-white");
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Update classes
|
|
1202
|
+
sendButton.className = "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer";
|
|
1203
|
+
|
|
1204
|
+
if (backgroundColor) {
|
|
1205
|
+
sendButton.style.backgroundColor = backgroundColor;
|
|
1206
|
+
sendButton.classList.remove("tvw-bg-cw-primary");
|
|
1207
|
+
} else {
|
|
1208
|
+
sendButton.classList.add("tvw-bg-cw-primary");
|
|
1209
|
+
}
|
|
1210
|
+
} else {
|
|
1211
|
+
// Text mode: existing behavior
|
|
1212
|
+
sendButton.textContent = config.copy?.sendButtonLabel ?? "Send";
|
|
1213
|
+
sendButton.style.width = "";
|
|
1214
|
+
sendButton.style.height = "";
|
|
1215
|
+
sendButton.style.minWidth = "";
|
|
1216
|
+
sendButton.style.minHeight = "";
|
|
1217
|
+
sendButton.style.fontSize = "";
|
|
1218
|
+
sendButton.style.lineHeight = "";
|
|
1219
|
+
|
|
1220
|
+
// Update classes
|
|
1221
|
+
sendButton.className = "tvw-rounded-button tvw-bg-cw-accent tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold tvw-text-white disabled:tvw-opacity-50 tvw-cursor-pointer";
|
|
1222
|
+
|
|
1223
|
+
if (backgroundColor) {
|
|
1224
|
+
sendButton.style.backgroundColor = backgroundColor;
|
|
1225
|
+
sendButton.classList.remove("tvw-bg-cw-accent");
|
|
1226
|
+
} else {
|
|
1227
|
+
sendButton.classList.add("tvw-bg-cw-accent");
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (textColor) {
|
|
1231
|
+
sendButton.style.color = textColor;
|
|
1232
|
+
} else {
|
|
1233
|
+
sendButton.classList.add("tvw-text-white");
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Apply border styling
|
|
1238
|
+
if (sendButtonConfig.borderWidth) {
|
|
1239
|
+
sendButton.style.borderWidth = sendButtonConfig.borderWidth;
|
|
1240
|
+
sendButton.style.borderStyle = "solid";
|
|
1241
|
+
} else {
|
|
1242
|
+
sendButton.style.borderWidth = "";
|
|
1243
|
+
sendButton.style.borderStyle = "";
|
|
1244
|
+
}
|
|
1245
|
+
if (sendButtonConfig.borderColor) {
|
|
1246
|
+
sendButton.style.borderColor = sendButtonConfig.borderColor;
|
|
1247
|
+
} else {
|
|
1248
|
+
sendButton.style.borderColor = "";
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Apply padding styling (works in both icon and text mode)
|
|
1252
|
+
if (sendButtonConfig.paddingX) {
|
|
1253
|
+
sendButton.style.paddingLeft = sendButtonConfig.paddingX;
|
|
1254
|
+
sendButton.style.paddingRight = sendButtonConfig.paddingX;
|
|
1255
|
+
} else {
|
|
1256
|
+
sendButton.style.paddingLeft = "";
|
|
1257
|
+
sendButton.style.paddingRight = "";
|
|
1258
|
+
}
|
|
1259
|
+
if (sendButtonConfig.paddingY) {
|
|
1260
|
+
sendButton.style.paddingTop = sendButtonConfig.paddingY;
|
|
1261
|
+
sendButton.style.paddingBottom = sendButtonConfig.paddingY;
|
|
1262
|
+
} else {
|
|
1263
|
+
sendButton.style.paddingTop = "";
|
|
1264
|
+
sendButton.style.paddingBottom = "";
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Update tooltip
|
|
1268
|
+
const tooltip = sendButtonWrapper?.querySelector(".tvw-send-button-tooltip") as HTMLElement | null;
|
|
1269
|
+
if (showTooltip && tooltipText) {
|
|
1270
|
+
if (!tooltip) {
|
|
1271
|
+
// Create tooltip if it doesn't exist
|
|
1272
|
+
const newTooltip = document.createElement("div");
|
|
1273
|
+
newTooltip.className = "tvw-send-button-tooltip";
|
|
1274
|
+
newTooltip.textContent = tooltipText;
|
|
1275
|
+
sendButtonWrapper?.insertBefore(newTooltip, sendButton);
|
|
1276
|
+
} else {
|
|
1277
|
+
tooltip.textContent = tooltipText;
|
|
1278
|
+
tooltip.style.display = "";
|
|
1279
|
+
}
|
|
1280
|
+
} else if (tooltip) {
|
|
1281
|
+
tooltip.style.display = "none";
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Update status indicator visibility and text
|
|
1285
|
+
const statusIndicatorConfig = config.statusIndicator ?? {};
|
|
1286
|
+
const isVisible = statusIndicatorConfig.visible ?? true;
|
|
1287
|
+
statusText.style.display = isVisible ? "" : "none";
|
|
1288
|
+
|
|
1289
|
+
// Update status text if status is currently set
|
|
1290
|
+
if (session) {
|
|
1291
|
+
const currentStatus = session.getStatus();
|
|
1292
|
+
const getCurrentStatusText = (status: ChatWidgetSessionStatus): string => {
|
|
1293
|
+
if (status === "idle") return statusIndicatorConfig.idleText ?? statusCopy.idle;
|
|
1294
|
+
if (status === "connecting") return statusIndicatorConfig.connectingText ?? statusCopy.connecting;
|
|
1295
|
+
if (status === "connected") return statusIndicatorConfig.connectedText ?? statusCopy.connected;
|
|
1296
|
+
if (status === "error") return statusIndicatorConfig.errorText ?? statusCopy.error;
|
|
1297
|
+
return statusCopy[status];
|
|
1298
|
+
};
|
|
1299
|
+
statusText.textContent = getCurrentStatusText(currentStatus);
|
|
1300
|
+
}
|
|
1301
|
+
},
|
|
1302
|
+
open() {
|
|
1303
|
+
if (!launcherEnabled) return;
|
|
1304
|
+
setOpenState(true);
|
|
1305
|
+
},
|
|
1306
|
+
close() {
|
|
1307
|
+
if (!launcherEnabled) return;
|
|
1308
|
+
setOpenState(false);
|
|
1309
|
+
},
|
|
1310
|
+
toggle() {
|
|
1311
|
+
if (!launcherEnabled) return;
|
|
1312
|
+
setOpenState(!open);
|
|
1313
|
+
},
|
|
1314
|
+
destroy() {
|
|
1315
|
+
destroyCallbacks.forEach((cb) => cb());
|
|
1316
|
+
wrapper.remove();
|
|
1317
|
+
launcherButtonInstance?.destroy();
|
|
1318
|
+
if (closeHandler) {
|
|
1319
|
+
closeButton.removeEventListener("click", closeHandler);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
};
|
|
1324
|
+
|
|
1325
|
+
export type ChatWidgetController = Controller;
|