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
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { createElement } from "../utils/dom";
|
|
2
|
+
import { renderLucideIcon } from "../utils/icons";
|
|
3
|
+
import { ChatWidgetConfig } from "../types";
|
|
4
|
+
import { positionMap } from "../utils/positioning";
|
|
5
|
+
|
|
6
|
+
export interface PanelWrapper {
|
|
7
|
+
wrapper: HTMLElement;
|
|
8
|
+
panel: HTMLElement;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const createWrapper = (config?: ChatWidgetConfig): PanelWrapper => {
|
|
12
|
+
const launcherEnabled = config?.launcher?.enabled ?? true;
|
|
13
|
+
|
|
14
|
+
if (!launcherEnabled) {
|
|
15
|
+
const wrapper = createElement(
|
|
16
|
+
"div",
|
|
17
|
+
"tvw-relative tvw-w-full tvw-h-full"
|
|
18
|
+
);
|
|
19
|
+
const panel = createElement(
|
|
20
|
+
"div",
|
|
21
|
+
"tvw-relative tvw-w-full tvw-h-full tvw-min-h-[360px]"
|
|
22
|
+
);
|
|
23
|
+
wrapper.appendChild(panel);
|
|
24
|
+
return { wrapper, panel };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const launcher = config?.launcher ?? {};
|
|
28
|
+
const position =
|
|
29
|
+
launcher.position && positionMap[launcher.position]
|
|
30
|
+
? positionMap[launcher.position]
|
|
31
|
+
: positionMap["bottom-right"];
|
|
32
|
+
|
|
33
|
+
const wrapper = createElement(
|
|
34
|
+
"div",
|
|
35
|
+
`tvw-fixed ${position} tvw-z-50 tvw-transition`
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const panel = createElement(
|
|
39
|
+
"div",
|
|
40
|
+
"tvw-relative tvw-min-h-[320px]"
|
|
41
|
+
);
|
|
42
|
+
const launcherWidth = config?.launcher?.width ?? config?.launcherWidth;
|
|
43
|
+
const width = launcherWidth ?? "min(360px, calc(100vw - 24px))";
|
|
44
|
+
panel.style.width = width;
|
|
45
|
+
panel.style.maxWidth = width;
|
|
46
|
+
|
|
47
|
+
wrapper.appendChild(panel);
|
|
48
|
+
return { wrapper, panel };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export interface PanelElements {
|
|
52
|
+
container: HTMLElement;
|
|
53
|
+
body: HTMLElement;
|
|
54
|
+
messagesWrapper: HTMLElement;
|
|
55
|
+
suggestions: HTMLElement;
|
|
56
|
+
textarea: HTMLTextAreaElement;
|
|
57
|
+
sendButton: HTMLButtonElement;
|
|
58
|
+
sendButtonWrapper: HTMLElement;
|
|
59
|
+
micButton: HTMLButtonElement | null;
|
|
60
|
+
micButtonWrapper: HTMLElement | null;
|
|
61
|
+
composerForm: HTMLFormElement;
|
|
62
|
+
statusText: HTMLElement;
|
|
63
|
+
introTitle: HTMLElement;
|
|
64
|
+
introSubtitle: HTMLElement;
|
|
65
|
+
closeButton: HTMLButtonElement;
|
|
66
|
+
iconHolder: HTMLElement;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const buildPanel = (config?: ChatWidgetConfig, showClose = true): PanelElements => {
|
|
70
|
+
const container = createElement(
|
|
71
|
+
"div",
|
|
72
|
+
"tvw-flex tvw-h-full tvw-w-full tvw-flex-col tvw-bg-cw-surface tvw-text-cw-primary tvw-rounded-2xl tvw-overflow-hidden tvw-shadow-2xl tvw-border tvw-border-cw-border"
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const header = createElement(
|
|
76
|
+
"div",
|
|
77
|
+
"tvw-flex tvw-items-center tvw-gap-3 tvw-bg-cw-surface tvw-px-6 tvw-py-5 tvw-border-b-cw-divider"
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const launcher = config?.launcher ?? {};
|
|
81
|
+
const headerIconSize = launcher.headerIconSize ?? "48px";
|
|
82
|
+
const closeButtonSize = launcher.closeButtonSize ?? "32px";
|
|
83
|
+
const closeButtonPlacement = launcher.closeButtonPlacement ?? "inline";
|
|
84
|
+
const headerIconHidden = launcher.headerIconHidden ?? false;
|
|
85
|
+
const headerIconName = launcher.headerIconName;
|
|
86
|
+
|
|
87
|
+
const iconHolder = createElement(
|
|
88
|
+
"div",
|
|
89
|
+
"tvw-flex tvw-items-center tvw-justify-center tvw-rounded-xl tvw-bg-cw-primary tvw-text-white tvw-text-xl"
|
|
90
|
+
);
|
|
91
|
+
iconHolder.style.height = headerIconSize;
|
|
92
|
+
iconHolder.style.width = headerIconSize;
|
|
93
|
+
|
|
94
|
+
// Render icon based on priority: Lucide icon > iconUrl > agentIconText
|
|
95
|
+
if (!headerIconHidden) {
|
|
96
|
+
if (headerIconName) {
|
|
97
|
+
// Use Lucide icon
|
|
98
|
+
const iconSize = parseFloat(headerIconSize) || 24;
|
|
99
|
+
const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.6, "#ffffff", 2);
|
|
100
|
+
if (iconSvg) {
|
|
101
|
+
iconHolder.replaceChildren(iconSvg);
|
|
102
|
+
} else {
|
|
103
|
+
// Fallback to agentIconText if Lucide icon fails
|
|
104
|
+
iconHolder.textContent = config?.launcher?.agentIconText ?? "đź’¬";
|
|
105
|
+
}
|
|
106
|
+
} else if (config?.launcher?.iconUrl) {
|
|
107
|
+
// Use image URL
|
|
108
|
+
const img = createElement("img") as HTMLImageElement;
|
|
109
|
+
img.src = config.launcher.iconUrl;
|
|
110
|
+
img.alt = "";
|
|
111
|
+
img.className = "tvw-rounded-xl tvw-object-cover";
|
|
112
|
+
img.style.height = headerIconSize;
|
|
113
|
+
img.style.width = headerIconSize;
|
|
114
|
+
iconHolder.replaceChildren(img);
|
|
115
|
+
} else {
|
|
116
|
+
// Use text/emoji
|
|
117
|
+
iconHolder.textContent = config?.launcher?.agentIconText ?? "đź’¬";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const headerCopy = createElement("div", "tvw-flex tvw-flex-col");
|
|
122
|
+
const title = createElement(
|
|
123
|
+
"span",
|
|
124
|
+
"tvw-text-base tvw-font-semibold"
|
|
125
|
+
);
|
|
126
|
+
title.textContent =
|
|
127
|
+
config?.launcher?.title ?? "Chat Assistant";
|
|
128
|
+
const subtitle = createElement(
|
|
129
|
+
"span",
|
|
130
|
+
"tvw-text-xs tvw-text-cw-muted"
|
|
131
|
+
);
|
|
132
|
+
subtitle.textContent =
|
|
133
|
+
config?.launcher?.subtitle ?? "Here to help you get answers fast";
|
|
134
|
+
|
|
135
|
+
headerCopy.append(title, subtitle);
|
|
136
|
+
|
|
137
|
+
// Only append iconHolder if not hidden
|
|
138
|
+
if (!headerIconHidden) {
|
|
139
|
+
header.append(iconHolder, headerCopy);
|
|
140
|
+
} else {
|
|
141
|
+
header.append(headerCopy);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Create close button with base classes
|
|
145
|
+
const closeButton = createElement(
|
|
146
|
+
"button",
|
|
147
|
+
closeButtonPlacement === "top-right"
|
|
148
|
+
? "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"
|
|
149
|
+
: "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"
|
|
150
|
+
) as HTMLButtonElement;
|
|
151
|
+
closeButton.style.height = closeButtonSize;
|
|
152
|
+
closeButton.style.width = closeButtonSize;
|
|
153
|
+
closeButton.type = "button";
|
|
154
|
+
closeButton.setAttribute("aria-label", "Close chat");
|
|
155
|
+
closeButton.textContent = "Ă—";
|
|
156
|
+
closeButton.style.display = showClose ? "" : "none";
|
|
157
|
+
|
|
158
|
+
// Apply close button styling from config
|
|
159
|
+
if (launcher.closeButtonColor) {
|
|
160
|
+
closeButton.style.color = launcher.closeButtonColor;
|
|
161
|
+
closeButton.classList.remove("tvw-text-cw-muted");
|
|
162
|
+
} else {
|
|
163
|
+
closeButton.style.color = "";
|
|
164
|
+
closeButton.classList.add("tvw-text-cw-muted");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (launcher.closeButtonBackgroundColor) {
|
|
168
|
+
closeButton.style.backgroundColor = launcher.closeButtonBackgroundColor;
|
|
169
|
+
closeButton.classList.remove("hover:tvw-bg-gray-100");
|
|
170
|
+
} else {
|
|
171
|
+
closeButton.style.backgroundColor = "";
|
|
172
|
+
closeButton.classList.add("hover:tvw-bg-gray-100");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Apply border if width and/or color are provided
|
|
176
|
+
if (launcher.closeButtonBorderWidth || launcher.closeButtonBorderColor) {
|
|
177
|
+
const borderWidth = launcher.closeButtonBorderWidth || "0px";
|
|
178
|
+
const borderColor = launcher.closeButtonBorderColor || "transparent";
|
|
179
|
+
closeButton.style.border = `${borderWidth} solid ${borderColor}`;
|
|
180
|
+
closeButton.classList.remove("tvw-border-none");
|
|
181
|
+
} else {
|
|
182
|
+
closeButton.style.border = "";
|
|
183
|
+
closeButton.classList.add("tvw-border-none");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (launcher.closeButtonBorderRadius) {
|
|
187
|
+
closeButton.style.borderRadius = launcher.closeButtonBorderRadius;
|
|
188
|
+
closeButton.classList.remove("tvw-rounded-full");
|
|
189
|
+
} else {
|
|
190
|
+
closeButton.style.borderRadius = "";
|
|
191
|
+
closeButton.classList.add("tvw-rounded-full");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Position close button based on placement
|
|
195
|
+
if (closeButtonPlacement === "top-right") {
|
|
196
|
+
// Make container position relative for absolute positioning
|
|
197
|
+
container.style.position = "relative";
|
|
198
|
+
container.appendChild(closeButton);
|
|
199
|
+
} else {
|
|
200
|
+
// Inline placement: append to header
|
|
201
|
+
header.appendChild(closeButton);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const body = createElement(
|
|
205
|
+
"div",
|
|
206
|
+
"tvw-flex tvw-flex-1 tvw-min-h-0 tvw-flex-col tvw-gap-6 tvw-overflow-y-auto tvw-bg-cw-container tvw-px-6 tvw-py-6"
|
|
207
|
+
);
|
|
208
|
+
const introCard = createElement(
|
|
209
|
+
"div",
|
|
210
|
+
"tvw-rounded-2xl tvw-bg-cw-surface tvw-p-6 tvw-shadow-sm"
|
|
211
|
+
);
|
|
212
|
+
const introTitle = createElement(
|
|
213
|
+
"h2",
|
|
214
|
+
"tvw-text-lg tvw-font-semibold tvw-text-cw-primary"
|
|
215
|
+
);
|
|
216
|
+
introTitle.textContent = config?.copy?.welcomeTitle ?? "Hello đź‘‹";
|
|
217
|
+
const introSubtitle = createElement(
|
|
218
|
+
"p",
|
|
219
|
+
"tvw-mt-2 tvw-text-sm tvw-text-cw-muted"
|
|
220
|
+
);
|
|
221
|
+
introSubtitle.textContent =
|
|
222
|
+
config?.copy?.welcomeSubtitle ??
|
|
223
|
+
"Ask anything about your account or products.";
|
|
224
|
+
introCard.append(introTitle, introSubtitle);
|
|
225
|
+
|
|
226
|
+
const messagesWrapper = createElement(
|
|
227
|
+
"div",
|
|
228
|
+
"tvw-flex tvw-flex-col tvw-gap-3"
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
body.append(introCard, messagesWrapper);
|
|
232
|
+
|
|
233
|
+
const footer = createElement(
|
|
234
|
+
"div",
|
|
235
|
+
"tvw-border-t-cw-divider tvw-bg-cw-surface tvw-px-6 tvw-py-4"
|
|
236
|
+
);
|
|
237
|
+
const suggestions = createElement(
|
|
238
|
+
"div",
|
|
239
|
+
"tvw-mb-3 tvw-flex tvw-flex-wrap tvw-gap-2"
|
|
240
|
+
);
|
|
241
|
+
// Determine gap based on voice recognition
|
|
242
|
+
const voiceRecognitionEnabledForGap = config?.voiceRecognition?.enabled === true;
|
|
243
|
+
const hasSpeechRecognitionForGap =
|
|
244
|
+
typeof window !== 'undefined' &&
|
|
245
|
+
(typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
|
|
246
|
+
typeof (window as any).SpeechRecognition !== 'undefined');
|
|
247
|
+
const shouldUseSmallGap = voiceRecognitionEnabledForGap && hasSpeechRecognitionForGap;
|
|
248
|
+
const gapClass = shouldUseSmallGap ? "tvw-gap-1" : "tvw-gap-3";
|
|
249
|
+
|
|
250
|
+
const composerForm = createElement(
|
|
251
|
+
"form",
|
|
252
|
+
`tvw-flex tvw-items-end ${gapClass} tvw-rounded-2xl tvw-border tvw-border-gray-200 tvw-bg-cw-input-background tvw-px-4 tvw-py-3`
|
|
253
|
+
) as HTMLFormElement;
|
|
254
|
+
// Prevent form from getting focus styles
|
|
255
|
+
composerForm.style.outline = "none";
|
|
256
|
+
|
|
257
|
+
const textarea = createElement("textarea") as HTMLTextAreaElement;
|
|
258
|
+
textarea.placeholder = config?.copy?.inputPlaceholder ?? "Type your message…";
|
|
259
|
+
textarea.className =
|
|
260
|
+
"tvw-min-h-[48px] tvw-flex-1 tvw-resize-none tvw-border-none tvw-bg-transparent tvw-text-sm tvw-text-cw-primary focus:tvw-outline-none focus:tvw-border-none";
|
|
261
|
+
textarea.rows = 1;
|
|
262
|
+
|
|
263
|
+
// Apply font family and weight from config
|
|
264
|
+
const fontFamily = config?.theme?.inputFontFamily ?? "sans-serif";
|
|
265
|
+
const fontWeight = config?.theme?.inputFontWeight ?? "400";
|
|
266
|
+
|
|
267
|
+
const getFontFamilyValue = (family: "sans-serif" | "serif" | "mono"): string => {
|
|
268
|
+
switch (family) {
|
|
269
|
+
case "serif":
|
|
270
|
+
return 'Georgia, "Times New Roman", Times, serif';
|
|
271
|
+
case "mono":
|
|
272
|
+
return '"Courier New", Courier, "Lucida Console", Monaco, monospace';
|
|
273
|
+
case "sans-serif":
|
|
274
|
+
default:
|
|
275
|
+
return '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif';
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
textarea.style.fontFamily = getFontFamilyValue(fontFamily);
|
|
280
|
+
textarea.style.fontWeight = fontWeight;
|
|
281
|
+
|
|
282
|
+
// Explicitly remove border and outline on focus to prevent browser defaults
|
|
283
|
+
textarea.style.border = "none";
|
|
284
|
+
textarea.style.outline = "none";
|
|
285
|
+
textarea.style.borderWidth = "0";
|
|
286
|
+
textarea.style.borderStyle = "none";
|
|
287
|
+
textarea.style.borderColor = "transparent";
|
|
288
|
+
textarea.addEventListener("focus", () => {
|
|
289
|
+
textarea.style.border = "none";
|
|
290
|
+
textarea.style.outline = "none";
|
|
291
|
+
textarea.style.borderWidth = "0";
|
|
292
|
+
textarea.style.borderStyle = "none";
|
|
293
|
+
textarea.style.borderColor = "transparent";
|
|
294
|
+
textarea.style.boxShadow = "none";
|
|
295
|
+
});
|
|
296
|
+
textarea.addEventListener("blur", () => {
|
|
297
|
+
textarea.style.border = "none";
|
|
298
|
+
textarea.style.outline = "none";
|
|
299
|
+
});
|
|
300
|
+
// Send button configuration
|
|
301
|
+
const sendButtonConfig = config?.sendButton ?? {};
|
|
302
|
+
const useIcon = sendButtonConfig.useIcon ?? false;
|
|
303
|
+
const iconText = sendButtonConfig.iconText ?? "↑";
|
|
304
|
+
const iconName = sendButtonConfig.iconName;
|
|
305
|
+
const tooltipText = sendButtonConfig.tooltipText ?? "Send message";
|
|
306
|
+
const showTooltip = sendButtonConfig.showTooltip ?? false;
|
|
307
|
+
const buttonSize = sendButtonConfig.size ?? "40px";
|
|
308
|
+
const backgroundColor = sendButtonConfig.backgroundColor;
|
|
309
|
+
const textColor = sendButtonConfig.textColor;
|
|
310
|
+
|
|
311
|
+
// Create wrapper for tooltip positioning
|
|
312
|
+
const sendButtonWrapper = createElement("div", "tvw-send-button-wrapper");
|
|
313
|
+
|
|
314
|
+
const sendButton = createElement(
|
|
315
|
+
"button",
|
|
316
|
+
useIcon
|
|
317
|
+
? "tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
|
|
318
|
+
: "tvw-rounded-button tvw-bg-cw-accent tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold disabled:tvw-opacity-50 tvw-cursor-pointer"
|
|
319
|
+
) as HTMLButtonElement;
|
|
320
|
+
|
|
321
|
+
sendButton.type = "submit";
|
|
322
|
+
|
|
323
|
+
if (useIcon) {
|
|
324
|
+
// Icon mode: circular button
|
|
325
|
+
sendButton.style.width = buttonSize;
|
|
326
|
+
sendButton.style.height = buttonSize;
|
|
327
|
+
sendButton.style.minWidth = buttonSize;
|
|
328
|
+
sendButton.style.minHeight = buttonSize;
|
|
329
|
+
sendButton.style.fontSize = "18px";
|
|
330
|
+
sendButton.style.lineHeight = "1";
|
|
331
|
+
|
|
332
|
+
// Clear any existing content
|
|
333
|
+
sendButton.innerHTML = "";
|
|
334
|
+
|
|
335
|
+
// Use Lucide icon if iconName is provided, otherwise fall back to iconText
|
|
336
|
+
if (iconName) {
|
|
337
|
+
const iconSize = parseFloat(buttonSize) || 24;
|
|
338
|
+
const iconColor = textColor && typeof textColor === 'string' && textColor.trim() ? textColor.trim() : "currentColor";
|
|
339
|
+
const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, 2);
|
|
340
|
+
if (iconSvg) {
|
|
341
|
+
sendButton.appendChild(iconSvg);
|
|
342
|
+
sendButton.style.color = iconColor;
|
|
343
|
+
} else {
|
|
344
|
+
// Fallback to text if icon fails to render
|
|
345
|
+
sendButton.textContent = iconText;
|
|
346
|
+
if (textColor) {
|
|
347
|
+
sendButton.style.color = textColor;
|
|
348
|
+
} else {
|
|
349
|
+
sendButton.classList.add("tvw-text-white");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
sendButton.textContent = iconText;
|
|
354
|
+
if (textColor) {
|
|
355
|
+
sendButton.style.color = textColor;
|
|
356
|
+
} else {
|
|
357
|
+
sendButton.classList.add("tvw-text-white");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (backgroundColor) {
|
|
362
|
+
sendButton.style.backgroundColor = backgroundColor;
|
|
363
|
+
} else {
|
|
364
|
+
sendButton.classList.add("tvw-bg-cw-primary");
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
// Text mode: existing behavior
|
|
368
|
+
sendButton.textContent = config?.copy?.sendButtonLabel ?? "Send";
|
|
369
|
+
if (textColor) {
|
|
370
|
+
sendButton.style.color = textColor;
|
|
371
|
+
} else {
|
|
372
|
+
sendButton.classList.add("tvw-text-white");
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Apply existing styling from config
|
|
377
|
+
if (sendButtonConfig.borderWidth) {
|
|
378
|
+
sendButton.style.borderWidth = sendButtonConfig.borderWidth;
|
|
379
|
+
sendButton.style.borderStyle = "solid";
|
|
380
|
+
}
|
|
381
|
+
if (sendButtonConfig.borderColor) {
|
|
382
|
+
sendButton.style.borderColor = sendButtonConfig.borderColor;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Apply padding styling (works in both icon and text mode)
|
|
386
|
+
if (sendButtonConfig.paddingX) {
|
|
387
|
+
sendButton.style.paddingLeft = sendButtonConfig.paddingX;
|
|
388
|
+
sendButton.style.paddingRight = sendButtonConfig.paddingX;
|
|
389
|
+
} else {
|
|
390
|
+
sendButton.style.paddingLeft = "";
|
|
391
|
+
sendButton.style.paddingRight = "";
|
|
392
|
+
}
|
|
393
|
+
if (sendButtonConfig.paddingY) {
|
|
394
|
+
sendButton.style.paddingTop = sendButtonConfig.paddingY;
|
|
395
|
+
sendButton.style.paddingBottom = sendButtonConfig.paddingY;
|
|
396
|
+
} else {
|
|
397
|
+
sendButton.style.paddingTop = "";
|
|
398
|
+
sendButton.style.paddingBottom = "";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Add tooltip if enabled
|
|
402
|
+
if (showTooltip && tooltipText) {
|
|
403
|
+
const tooltip = createElement("div", "tvw-send-button-tooltip");
|
|
404
|
+
tooltip.textContent = tooltipText;
|
|
405
|
+
sendButtonWrapper.appendChild(tooltip);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
sendButtonWrapper.appendChild(sendButton);
|
|
409
|
+
|
|
410
|
+
// Voice recognition mic button
|
|
411
|
+
const voiceRecognitionConfig = config?.voiceRecognition ?? {};
|
|
412
|
+
const voiceRecognitionEnabled = voiceRecognitionConfig.enabled === true;
|
|
413
|
+
let micButton: HTMLButtonElement | null = null;
|
|
414
|
+
let micButtonWrapper: HTMLElement | null = null;
|
|
415
|
+
|
|
416
|
+
// Check browser support for speech recognition
|
|
417
|
+
const hasSpeechRecognition =
|
|
418
|
+
typeof window !== 'undefined' &&
|
|
419
|
+
(typeof (window as any).webkitSpeechRecognition !== 'undefined' ||
|
|
420
|
+
typeof (window as any).SpeechRecognition !== 'undefined');
|
|
421
|
+
|
|
422
|
+
if (voiceRecognitionEnabled && hasSpeechRecognition) {
|
|
423
|
+
micButtonWrapper = createElement("div", "tvw-send-button-wrapper");
|
|
424
|
+
micButton = createElement(
|
|
425
|
+
"button",
|
|
426
|
+
"tvw-rounded-button tvw-flex tvw-items-center tvw-justify-center disabled:tvw-opacity-50 tvw-cursor-pointer"
|
|
427
|
+
) as HTMLButtonElement;
|
|
428
|
+
|
|
429
|
+
micButton.type = "button";
|
|
430
|
+
micButton.setAttribute("aria-label", "Start voice recognition");
|
|
431
|
+
|
|
432
|
+
const micIconName = voiceRecognitionConfig.iconName ?? "mic";
|
|
433
|
+
const micIconSize = voiceRecognitionConfig.iconSize ?? buttonSize;
|
|
434
|
+
const micIconSizeNum = parseFloat(micIconSize) || 24;
|
|
435
|
+
|
|
436
|
+
// Use dedicated colors from voice recognition config, fallback to send button colors
|
|
437
|
+
const micBackgroundColor = voiceRecognitionConfig.backgroundColor ?? backgroundColor;
|
|
438
|
+
const micIconColor = voiceRecognitionConfig.iconColor ?? textColor;
|
|
439
|
+
|
|
440
|
+
micButton.style.width = micIconSize;
|
|
441
|
+
micButton.style.height = micIconSize;
|
|
442
|
+
micButton.style.minWidth = micIconSize;
|
|
443
|
+
micButton.style.minHeight = micIconSize;
|
|
444
|
+
micButton.style.fontSize = "18px";
|
|
445
|
+
micButton.style.lineHeight = "1";
|
|
446
|
+
|
|
447
|
+
// Use Lucide mic icon with configured color (stroke width 1.5 for minimalist outline style)
|
|
448
|
+
const iconColorValue = micIconColor || "currentColor";
|
|
449
|
+
const micIconSvg = renderLucideIcon(micIconName, micIconSizeNum, iconColorValue, 1.5);
|
|
450
|
+
if (micIconSvg) {
|
|
451
|
+
micButton.appendChild(micIconSvg);
|
|
452
|
+
micButton.style.color = iconColorValue;
|
|
453
|
+
} else {
|
|
454
|
+
// Fallback to text if icon fails
|
|
455
|
+
micButton.textContent = "🎤";
|
|
456
|
+
micButton.style.color = iconColorValue;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Apply background color
|
|
460
|
+
if (micBackgroundColor) {
|
|
461
|
+
micButton.style.backgroundColor = micBackgroundColor;
|
|
462
|
+
} else {
|
|
463
|
+
micButton.classList.add("tvw-bg-cw-primary");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Apply icon/text color
|
|
467
|
+
if (micIconColor) {
|
|
468
|
+
micButton.style.color = micIconColor;
|
|
469
|
+
} else if (!micIconColor && !textColor) {
|
|
470
|
+
micButton.classList.add("tvw-text-white");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Apply border styling
|
|
474
|
+
if (voiceRecognitionConfig.borderWidth) {
|
|
475
|
+
micButton.style.borderWidth = voiceRecognitionConfig.borderWidth;
|
|
476
|
+
micButton.style.borderStyle = "solid";
|
|
477
|
+
}
|
|
478
|
+
if (voiceRecognitionConfig.borderColor) {
|
|
479
|
+
micButton.style.borderColor = voiceRecognitionConfig.borderColor;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Apply padding styling
|
|
483
|
+
if (voiceRecognitionConfig.paddingX) {
|
|
484
|
+
micButton.style.paddingLeft = voiceRecognitionConfig.paddingX;
|
|
485
|
+
micButton.style.paddingRight = voiceRecognitionConfig.paddingX;
|
|
486
|
+
}
|
|
487
|
+
if (voiceRecognitionConfig.paddingY) {
|
|
488
|
+
micButton.style.paddingTop = voiceRecognitionConfig.paddingY;
|
|
489
|
+
micButton.style.paddingBottom = voiceRecognitionConfig.paddingY;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
micButtonWrapper.appendChild(micButton);
|
|
493
|
+
|
|
494
|
+
// Add tooltip if enabled
|
|
495
|
+
const tooltipText = voiceRecognitionConfig.tooltipText ?? "Start voice recognition";
|
|
496
|
+
const showTooltip = voiceRecognitionConfig.showTooltip ?? false;
|
|
497
|
+
if (showTooltip && tooltipText) {
|
|
498
|
+
const tooltip = createElement("div", "tvw-send-button-tooltip");
|
|
499
|
+
tooltip.textContent = tooltipText;
|
|
500
|
+
micButtonWrapper.appendChild(tooltip);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Focus textarea when composer form container is clicked
|
|
505
|
+
composerForm.addEventListener("click", (e) => {
|
|
506
|
+
// Don't focus if clicking on the send button, mic button, or their wrappers
|
|
507
|
+
if (e.target !== sendButton && e.target !== sendButtonWrapper &&
|
|
508
|
+
e.target !== micButton && e.target !== micButtonWrapper) {
|
|
509
|
+
textarea.focus();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// Append elements: textarea, mic button (if exists), send button
|
|
514
|
+
composerForm.append(textarea);
|
|
515
|
+
if (micButtonWrapper) {
|
|
516
|
+
composerForm.append(micButtonWrapper);
|
|
517
|
+
}
|
|
518
|
+
composerForm.append(sendButtonWrapper);
|
|
519
|
+
|
|
520
|
+
const statusText = createElement(
|
|
521
|
+
"div",
|
|
522
|
+
"tvw-mt-2 tvw-text-right tvw-text-xs tvw-text-cw-muted"
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
// Apply status indicator config
|
|
526
|
+
const statusConfig = config?.statusIndicator ?? {};
|
|
527
|
+
const isVisible = statusConfig.visible ?? true;
|
|
528
|
+
statusText.style.display = isVisible ? "" : "none";
|
|
529
|
+
statusText.textContent = statusConfig.idleText ?? "Online";
|
|
530
|
+
|
|
531
|
+
footer.append(suggestions, composerForm, statusText);
|
|
532
|
+
|
|
533
|
+
container.append(header, body, footer);
|
|
534
|
+
|
|
535
|
+
return {
|
|
536
|
+
container,
|
|
537
|
+
body,
|
|
538
|
+
messagesWrapper,
|
|
539
|
+
suggestions,
|
|
540
|
+
textarea,
|
|
541
|
+
sendButton,
|
|
542
|
+
sendButtonWrapper,
|
|
543
|
+
micButton,
|
|
544
|
+
micButtonWrapper,
|
|
545
|
+
composerForm,
|
|
546
|
+
statusText,
|
|
547
|
+
introTitle,
|
|
548
|
+
introSubtitle,
|
|
549
|
+
closeButton,
|
|
550
|
+
iconHolder
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createElement } from "../utils/dom";
|
|
2
|
+
import { ChatWidgetMessage } from "../types";
|
|
3
|
+
import { describeReasonStatus } from "../utils/formatting";
|
|
4
|
+
|
|
5
|
+
// Expansion state per widget instance
|
|
6
|
+
const reasoningExpansionState = new Set<string>();
|
|
7
|
+
|
|
8
|
+
export const createReasoningBubble = (message: ChatWidgetMessage): HTMLElement => {
|
|
9
|
+
const reasoning = message.reasoning;
|
|
10
|
+
const bubble = createElement(
|
|
11
|
+
"div",
|
|
12
|
+
[
|
|
13
|
+
"tvw-max-w-[85%]",
|
|
14
|
+
"tvw-rounded-2xl",
|
|
15
|
+
"tvw-bg-cw-surface",
|
|
16
|
+
"tvw-border",
|
|
17
|
+
"tvw-border-cw-message-border",
|
|
18
|
+
"tvw-text-cw-primary",
|
|
19
|
+
"tvw-shadow-sm",
|
|
20
|
+
"tvw-overflow-hidden",
|
|
21
|
+
"tvw-px-0",
|
|
22
|
+
"tvw-py-0"
|
|
23
|
+
].join(" ")
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (!reasoning) {
|
|
27
|
+
return bubble;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let expanded = reasoningExpansionState.has(message.id);
|
|
31
|
+
const header = createElement(
|
|
32
|
+
"button",
|
|
33
|
+
"tvw-flex tvw-w-full tvw-items-center tvw-justify-between tvw-gap-3 tvw-bg-transparent tvw-px-4 tvw-py-3 tvw-text-left tvw-cursor-pointer tvw-border-none"
|
|
34
|
+
) as HTMLButtonElement;
|
|
35
|
+
header.type = "button";
|
|
36
|
+
header.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
37
|
+
|
|
38
|
+
const headerContent = createElement("div", "tvw-flex tvw-flex-col tvw-text-left");
|
|
39
|
+
const title = createElement("span", "tvw-text-xs tvw-font-semibold tvw-text-cw-primary");
|
|
40
|
+
title.textContent = "Thinking...";
|
|
41
|
+
headerContent.appendChild(title);
|
|
42
|
+
|
|
43
|
+
const status = createElement("span", "tvw-text-xs tvw-text-cw-primary");
|
|
44
|
+
status.textContent = describeReasonStatus(reasoning);
|
|
45
|
+
headerContent.appendChild(status);
|
|
46
|
+
|
|
47
|
+
if (reasoning.status === "complete") {
|
|
48
|
+
title.style.display = "none";
|
|
49
|
+
} else {
|
|
50
|
+
title.style.display = "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const toggleLabel = createElement(
|
|
54
|
+
"span",
|
|
55
|
+
"tvw-text-xs tvw-text-cw-primary"
|
|
56
|
+
);
|
|
57
|
+
toggleLabel.textContent = expanded ? "Hide" : "Show";
|
|
58
|
+
|
|
59
|
+
header.append(headerContent, toggleLabel);
|
|
60
|
+
|
|
61
|
+
const content = createElement(
|
|
62
|
+
"div",
|
|
63
|
+
"tvw-border-t tvw-border-gray-200 tvw-bg-gray-50 tvw-px-4 tvw-py-3"
|
|
64
|
+
);
|
|
65
|
+
content.style.display = expanded ? "" : "none";
|
|
66
|
+
|
|
67
|
+
const text = reasoning.chunks.join("");
|
|
68
|
+
const body = createElement(
|
|
69
|
+
"div",
|
|
70
|
+
"tvw-whitespace-pre-wrap tvw-text-xs tvw-leading-snug tvw-text-cw-muted"
|
|
71
|
+
);
|
|
72
|
+
body.textContent =
|
|
73
|
+
text ||
|
|
74
|
+
(reasoning.status === "complete"
|
|
75
|
+
? "No additional context was shared."
|
|
76
|
+
: "Waiting for details…");
|
|
77
|
+
content.appendChild(body);
|
|
78
|
+
|
|
79
|
+
const applyExpansionState = () => {
|
|
80
|
+
header.setAttribute("aria-expanded", expanded ? "true" : "false");
|
|
81
|
+
toggleLabel.textContent = expanded ? "Hide" : "Show";
|
|
82
|
+
content.style.display = expanded ? "" : "none";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const toggleExpansion = () => {
|
|
86
|
+
expanded = !expanded;
|
|
87
|
+
if (expanded) {
|
|
88
|
+
reasoningExpansionState.add(message.id);
|
|
89
|
+
} else {
|
|
90
|
+
reasoningExpansionState.delete(message.id);
|
|
91
|
+
}
|
|
92
|
+
applyExpansionState();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
header.addEventListener("pointerdown", (event) => {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
toggleExpansion();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
header.addEventListener("keydown", (event) => {
|
|
101
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
toggleExpansion();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
applyExpansionState();
|
|
108
|
+
|
|
109
|
+
bubble.append(header, content);
|
|
110
|
+
return bubble;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|