vanilla-agent 1.9.0 → 1.11.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.
@@ -0,0 +1,303 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { renderLucideIcon } from "../utils/icons";
3
+ import { AgentWidgetConfig, AgentWidgetHeaderLayoutConfig } from "../types";
4
+ import { buildHeader, HeaderElements, attachHeaderToContainer } from "./header-builder";
5
+
6
+ export interface HeaderLayoutContext {
7
+ config: AgentWidgetConfig;
8
+ showClose?: boolean;
9
+ onClose?: () => void;
10
+ onClearChat?: () => void;
11
+ }
12
+
13
+ export type HeaderLayoutRenderer = (context: HeaderLayoutContext) => HeaderElements;
14
+
15
+ /**
16
+ * Build default header layout
17
+ * Full header with icon, title, subtitle, clear chat, and close button
18
+ */
19
+ export const buildDefaultHeader: HeaderLayoutRenderer = (context) => {
20
+ return buildHeader({
21
+ config: context.config,
22
+ showClose: context.showClose,
23
+ onClose: context.onClose,
24
+ onClearChat: context.onClearChat
25
+ });
26
+ };
27
+
28
+ /**
29
+ * Build minimal header layout
30
+ * Simplified layout with just title and close button
31
+ */
32
+ export const buildMinimalHeader: HeaderLayoutRenderer = (context) => {
33
+ const { config, showClose = true, onClose } = context;
34
+ const launcher = config?.launcher ?? {};
35
+
36
+ const header = createElement(
37
+ "div",
38
+ "tvw-flex tvw-items-center tvw-justify-between tvw-bg-cw-surface tvw-px-6 tvw-py-4 tvw-border-b-cw-divider"
39
+ );
40
+
41
+ // Title only (no icon, no subtitle)
42
+ const title = createElement("span", "tvw-text-base tvw-font-semibold");
43
+ title.textContent = launcher.title ?? "Chat Assistant";
44
+
45
+ header.appendChild(title);
46
+
47
+ // Close button
48
+ const closeButtonSize = launcher.closeButtonSize ?? "32px";
49
+ const closeButtonWrapper = createElement("div", "");
50
+
51
+ const closeButton = createElement(
52
+ "button",
53
+ "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"
54
+ ) as HTMLButtonElement;
55
+ closeButton.style.height = closeButtonSize;
56
+ closeButton.style.width = closeButtonSize;
57
+ closeButton.type = "button";
58
+ closeButton.setAttribute("aria-label", "Close chat");
59
+ closeButton.style.display = showClose ? "" : "none";
60
+
61
+ const closeButtonIconName = launcher.closeButtonIconName ?? "x";
62
+ const closeIconSvg = renderLucideIcon(
63
+ closeButtonIconName,
64
+ "20px",
65
+ launcher.closeButtonColor || "",
66
+ 2
67
+ );
68
+ if (closeIconSvg) {
69
+ closeButton.appendChild(closeIconSvg);
70
+ } else {
71
+ closeButton.textContent = "×";
72
+ }
73
+
74
+ if (onClose) {
75
+ closeButton.addEventListener("click", onClose);
76
+ }
77
+
78
+ closeButtonWrapper.appendChild(closeButton);
79
+ header.appendChild(closeButtonWrapper);
80
+
81
+ // Create placeholder elements for compatibility
82
+ const iconHolder = createElement("div");
83
+ iconHolder.style.display = "none";
84
+ const headerSubtitle = createElement("span");
85
+ headerSubtitle.style.display = "none";
86
+
87
+ return {
88
+ header,
89
+ iconHolder,
90
+ headerTitle: title,
91
+ headerSubtitle,
92
+ closeButton,
93
+ closeButtonWrapper,
94
+ clearChatButton: null,
95
+ clearChatButtonWrapper: null
96
+ };
97
+ };
98
+
99
+ /**
100
+ * Build expanded header layout
101
+ * Full branding area with additional space for custom content
102
+ */
103
+ export const buildExpandedHeader: HeaderLayoutRenderer = (context) => {
104
+ const { config, showClose = true, onClose, onClearChat } = context;
105
+ const launcher = config?.launcher ?? {};
106
+
107
+ const header = createElement(
108
+ "div",
109
+ "tvw-flex tvw-flex-col tvw-bg-cw-surface tvw-px-6 tvw-py-5 tvw-border-b-cw-divider"
110
+ );
111
+
112
+ // Top row: icon + text + buttons
113
+ const topRow = createElement(
114
+ "div",
115
+ "tvw-flex tvw-items-center tvw-gap-3"
116
+ );
117
+
118
+ // Icon
119
+ const headerIconSize = launcher.headerIconSize ?? "56px";
120
+ const iconHolder = createElement(
121
+ "div",
122
+ "tvw-flex tvw-items-center tvw-justify-center tvw-rounded-xl tvw-bg-cw-primary tvw-text-white tvw-text-2xl"
123
+ );
124
+ iconHolder.style.height = headerIconSize;
125
+ iconHolder.style.width = headerIconSize;
126
+
127
+ const headerIconName = launcher.headerIconName;
128
+ if (headerIconName) {
129
+ const iconSize = parseFloat(headerIconSize) || 24;
130
+ const iconSvg = renderLucideIcon(headerIconName, iconSize * 0.5, "#ffffff", 2);
131
+ if (iconSvg) {
132
+ iconHolder.replaceChildren(iconSvg);
133
+ } else {
134
+ iconHolder.textContent = launcher.agentIconText ?? "💬";
135
+ }
136
+ } else if (launcher.iconUrl) {
137
+ const img = createElement("img") as HTMLImageElement;
138
+ img.src = launcher.iconUrl;
139
+ img.alt = "";
140
+ img.className = "tvw-rounded-xl tvw-object-cover";
141
+ img.style.height = headerIconSize;
142
+ img.style.width = headerIconSize;
143
+ iconHolder.replaceChildren(img);
144
+ } else {
145
+ iconHolder.textContent = launcher.agentIconText ?? "💬";
146
+ }
147
+
148
+ // Title and subtitle
149
+ const headerCopy = createElement("div", "tvw-flex tvw-flex-col tvw-flex-1");
150
+ const title = createElement("span", "tvw-text-lg tvw-font-semibold");
151
+ title.textContent = launcher.title ?? "Chat Assistant";
152
+ const subtitle = createElement("span", "tvw-text-sm tvw-text-cw-muted");
153
+ subtitle.textContent = launcher.subtitle ?? "Here to help you get answers fast";
154
+ headerCopy.append(title, subtitle);
155
+
156
+ topRow.append(iconHolder, headerCopy);
157
+
158
+ // Close button
159
+ const closeButtonSize = launcher.closeButtonSize ?? "32px";
160
+ const closeButtonWrapper = createElement("div", "");
161
+
162
+ const closeButton = createElement(
163
+ "button",
164
+ "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"
165
+ ) as HTMLButtonElement;
166
+ closeButton.style.height = closeButtonSize;
167
+ closeButton.style.width = closeButtonSize;
168
+ closeButton.type = "button";
169
+ closeButton.setAttribute("aria-label", "Close chat");
170
+ closeButton.style.display = showClose ? "" : "none";
171
+
172
+ const closeButtonIconName = launcher.closeButtonIconName ?? "x";
173
+ const closeIconSvg = renderLucideIcon(
174
+ closeButtonIconName,
175
+ "20px",
176
+ launcher.closeButtonColor || "",
177
+ 2
178
+ );
179
+ if (closeIconSvg) {
180
+ closeButton.appendChild(closeIconSvg);
181
+ } else {
182
+ closeButton.textContent = "×";
183
+ }
184
+
185
+ if (onClose) {
186
+ closeButton.addEventListener("click", onClose);
187
+ }
188
+
189
+ closeButtonWrapper.appendChild(closeButton);
190
+ topRow.appendChild(closeButtonWrapper);
191
+
192
+ header.appendChild(topRow);
193
+
194
+ // Bottom row: additional space for status or branding
195
+ const bottomRow = createElement(
196
+ "div",
197
+ "tvw-mt-3 tvw-pt-3 tvw-border-t tvw-border-gray-100 tvw-text-xs tvw-text-cw-muted"
198
+ );
199
+ bottomRow.textContent = "Online and ready to help";
200
+ header.appendChild(bottomRow);
201
+
202
+ return {
203
+ header,
204
+ iconHolder,
205
+ headerTitle: title,
206
+ headerSubtitle: subtitle,
207
+ closeButton,
208
+ closeButtonWrapper,
209
+ clearChatButton: null,
210
+ clearChatButtonWrapper: null
211
+ };
212
+ };
213
+
214
+ /**
215
+ * Header layout registry
216
+ * Maps layout names to their renderer functions
217
+ */
218
+ export const headerLayouts: Record<string, HeaderLayoutRenderer> = {
219
+ default: buildDefaultHeader,
220
+ minimal: buildMinimalHeader,
221
+ expanded: buildExpandedHeader
222
+ };
223
+
224
+ /**
225
+ * Get header layout renderer by name
226
+ */
227
+ export const getHeaderLayout = (layoutName: string): HeaderLayoutRenderer => {
228
+ return headerLayouts[layoutName] ?? headerLayouts.default;
229
+ };
230
+
231
+ /**
232
+ * Build header based on layout configuration
233
+ * Applies layout config settings to determine which layout to use
234
+ */
235
+ export const buildHeaderWithLayout = (
236
+ config: AgentWidgetConfig,
237
+ layoutConfig?: AgentWidgetHeaderLayoutConfig,
238
+ context?: Partial<HeaderLayoutContext>
239
+ ): HeaderElements => {
240
+ // If custom render is provided, use it
241
+ if (layoutConfig?.render) {
242
+ const customHeader = layoutConfig.render({
243
+ config,
244
+ onClose: context?.onClose,
245
+ onClearChat: context?.onClearChat
246
+ });
247
+
248
+ // Wrap in HeaderElements structure
249
+ const iconHolder = createElement("div");
250
+ iconHolder.style.display = "none";
251
+ const headerTitle = createElement("span");
252
+ const headerSubtitle = createElement("span");
253
+ const closeButton = createElement("button") as HTMLButtonElement;
254
+ closeButton.style.display = "none";
255
+ const closeButtonWrapper = createElement("div");
256
+ closeButtonWrapper.style.display = "none";
257
+
258
+ return {
259
+ header: customHeader,
260
+ iconHolder,
261
+ headerTitle,
262
+ headerSubtitle,
263
+ closeButton,
264
+ closeButtonWrapper,
265
+ clearChatButton: null,
266
+ clearChatButtonWrapper: null
267
+ };
268
+ }
269
+
270
+ // Get layout renderer
271
+ const layoutName = layoutConfig?.layout ?? "default";
272
+ const layoutRenderer = getHeaderLayout(layoutName);
273
+
274
+ // Build header with layout
275
+ const headerElements = layoutRenderer({
276
+ config,
277
+ showClose: layoutConfig?.showCloseButton ?? context?.showClose ?? true,
278
+ onClose: context?.onClose,
279
+ onClearChat: context?.onClearChat
280
+ });
281
+
282
+ // Apply visibility settings from layout config
283
+ if (layoutConfig) {
284
+ if (layoutConfig.showIcon === false) {
285
+ headerElements.iconHolder.style.display = "none";
286
+ }
287
+ if (layoutConfig.showTitle === false) {
288
+ headerElements.headerTitle.style.display = "none";
289
+ }
290
+ if (layoutConfig.showSubtitle === false) {
291
+ headerElements.headerSubtitle.style.display = "none";
292
+ }
293
+ if (layoutConfig.showCloseButton === false) {
294
+ headerElements.closeButton.style.display = "none";
295
+ }
296
+ if (layoutConfig.showClearChat === false && headerElements.clearChatButtonWrapper) {
297
+ headerElements.clearChatButtonWrapper.style.display = "none";
298
+ }
299
+ }
300
+
301
+ return headerElements;
302
+ };
303
+
@@ -1,5 +1,10 @@
1
1
  import { createElement } from "../utils/dom";
2
- import { AgentWidgetMessage } from "../types";
2
+ import {
3
+ AgentWidgetMessage,
4
+ AgentWidgetMessageLayoutConfig,
5
+ AgentWidgetAvatarConfig,
6
+ AgentWidgetTimestampConfig
7
+ } from "../types";
3
8
 
4
9
  export type MessageTransform = (context: {
5
10
  text: string;
@@ -37,40 +42,188 @@ export const createTypingIndicator = (): HTMLElement => {
37
42
  return container;
38
43
  };
39
44
 
40
- export const createStandardBubble = (
41
- message: AgentWidgetMessage,
42
- transform: MessageTransform
45
+ /**
46
+ * Create an avatar element
47
+ */
48
+ const createAvatar = (
49
+ avatarConfig: AgentWidgetAvatarConfig,
50
+ role: "user" | "assistant"
43
51
  ): HTMLElement => {
44
- const classes = [
45
- "vanilla-message-bubble",
46
- "tvw-max-w-[85%]",
47
- "tvw-rounded-2xl",
48
- "tvw-text-sm",
49
- "tvw-leading-relaxed",
50
- "tvw-shadow-sm"
51
- ];
52
-
53
- if (message.role === "user") {
54
- classes.push(
55
- "vanilla-message-user-bubble",
56
- "tvw-ml-auto",
57
- "tvw-bg-cw-accent",
58
- "tvw-text-white",
59
- "tvw-px-5",
60
- "tvw-py-3"
61
- );
52
+ const avatar = createElement(
53
+ "div",
54
+ "tvw-flex-shrink-0 tvw-w-8 tvw-h-8 tvw-rounded-full tvw-flex tvw-items-center tvw-justify-center tvw-text-sm"
55
+ );
56
+
57
+ const avatarContent = role === "user"
58
+ ? avatarConfig.userAvatar
59
+ : avatarConfig.assistantAvatar;
60
+
61
+ if (avatarContent) {
62
+ // Check if it's a URL or emoji/text
63
+ if (avatarContent.startsWith("http") || avatarContent.startsWith("/") || avatarContent.startsWith("data:")) {
64
+ const img = createElement("img") as HTMLImageElement;
65
+ img.src = avatarContent;
66
+ img.alt = role === "user" ? "User" : "Assistant";
67
+ img.className = "tvw-w-full tvw-h-full tvw-rounded-full tvw-object-cover";
68
+ avatar.appendChild(img);
69
+ } else {
70
+ // Emoji or text
71
+ avatar.textContent = avatarContent;
72
+ avatar.classList.add(
73
+ role === "user" ? "tvw-bg-cw-accent" : "tvw-bg-cw-primary",
74
+ "tvw-text-white"
75
+ );
76
+ }
62
77
  } else {
63
- classes.push(
64
- "vanilla-message-assistant-bubble",
65
- "tvw-bg-cw-surface",
66
- "tvw-border",
67
- "tvw-border-cw-message-border",
68
- "tvw-text-cw-primary",
69
- "tvw-px-5",
70
- "tvw-py-3"
78
+ // Default avatar
79
+ avatar.textContent = role === "user" ? "U" : "A";
80
+ avatar.classList.add(
81
+ role === "user" ? "tvw-bg-cw-accent" : "tvw-bg-cw-primary",
82
+ "tvw-text-white"
71
83
  );
72
84
  }
73
85
 
86
+ return avatar;
87
+ };
88
+
89
+ /**
90
+ * Create a timestamp element
91
+ */
92
+ const createTimestamp = (
93
+ message: AgentWidgetMessage,
94
+ timestampConfig: AgentWidgetTimestampConfig
95
+ ): HTMLElement => {
96
+ const timestamp = createElement(
97
+ "div",
98
+ "tvw-text-xs tvw-text-cw-muted"
99
+ );
100
+
101
+ const date = new Date(message.createdAt);
102
+
103
+ if (timestampConfig.format) {
104
+ timestamp.textContent = timestampConfig.format(date);
105
+ } else {
106
+ // Default format: HH:MM
107
+ timestamp.textContent = date.toLocaleTimeString([], {
108
+ hour: "2-digit",
109
+ minute: "2-digit"
110
+ });
111
+ }
112
+
113
+ return timestamp;
114
+ };
115
+
116
+ /**
117
+ * Get bubble classes based on layout preset
118
+ */
119
+ const getBubbleClasses = (
120
+ role: "user" | "assistant" | "system",
121
+ layout: AgentWidgetMessageLayoutConfig["layout"] = "bubble"
122
+ ): string[] => {
123
+ const baseClasses = ["vanilla-message-bubble", "tvw-max-w-[85%]"];
124
+
125
+ switch (layout) {
126
+ case "flat":
127
+ // Flat layout: no bubble styling, just text
128
+ if (role === "user") {
129
+ baseClasses.push(
130
+ "vanilla-message-user-bubble",
131
+ "tvw-ml-auto",
132
+ "tvw-text-cw-primary",
133
+ "tvw-py-2"
134
+ );
135
+ } else {
136
+ baseClasses.push(
137
+ "vanilla-message-assistant-bubble",
138
+ "tvw-text-cw-primary",
139
+ "tvw-py-2"
140
+ );
141
+ }
142
+ break;
143
+
144
+ case "minimal":
145
+ // Minimal layout: reduced padding and styling
146
+ baseClasses.push(
147
+ "tvw-text-sm",
148
+ "tvw-leading-relaxed"
149
+ );
150
+ if (role === "user") {
151
+ baseClasses.push(
152
+ "vanilla-message-user-bubble",
153
+ "tvw-ml-auto",
154
+ "tvw-bg-cw-accent",
155
+ "tvw-text-white",
156
+ "tvw-px-3",
157
+ "tvw-py-2",
158
+ "tvw-rounded-lg"
159
+ );
160
+ } else {
161
+ baseClasses.push(
162
+ "vanilla-message-assistant-bubble",
163
+ "tvw-bg-cw-surface",
164
+ "tvw-text-cw-primary",
165
+ "tvw-px-3",
166
+ "tvw-py-2",
167
+ "tvw-rounded-lg"
168
+ );
169
+ }
170
+ break;
171
+
172
+ case "bubble":
173
+ default:
174
+ // Default bubble layout
175
+ baseClasses.push(
176
+ "tvw-rounded-2xl",
177
+ "tvw-text-sm",
178
+ "tvw-leading-relaxed",
179
+ "tvw-shadow-sm"
180
+ );
181
+ if (role === "user") {
182
+ baseClasses.push(
183
+ "vanilla-message-user-bubble",
184
+ "tvw-ml-auto",
185
+ "tvw-bg-cw-accent",
186
+ "tvw-text-white",
187
+ "tvw-px-5",
188
+ "tvw-py-3"
189
+ );
190
+ } else {
191
+ baseClasses.push(
192
+ "vanilla-message-assistant-bubble",
193
+ "tvw-bg-cw-surface",
194
+ "tvw-border",
195
+ "tvw-border-cw-message-border",
196
+ "tvw-text-cw-primary",
197
+ "tvw-px-5",
198
+ "tvw-py-3"
199
+ );
200
+ }
201
+ break;
202
+ }
203
+
204
+ return baseClasses;
205
+ };
206
+
207
+ /**
208
+ * Create standard message bubble
209
+ * Supports layout configuration for avatars, timestamps, and visual presets
210
+ */
211
+ export const createStandardBubble = (
212
+ message: AgentWidgetMessage,
213
+ transform: MessageTransform,
214
+ layoutConfig?: AgentWidgetMessageLayoutConfig
215
+ ): HTMLElement => {
216
+ const config = layoutConfig ?? {};
217
+ const layout = config.layout ?? "bubble";
218
+ const avatarConfig = config.avatar;
219
+ const timestampConfig = config.timestamp;
220
+ const showAvatar = avatarConfig?.show ?? false;
221
+ const showTimestamp = timestampConfig?.show ?? false;
222
+ const avatarPosition = avatarConfig?.position ?? "left";
223
+ const timestampPosition = timestampConfig?.position ?? "below";
224
+
225
+ // Create the bubble element
226
+ const classes = getBubbleClasses(message.role, layout);
74
227
  const bubble = createElement("div", classes.join(" "));
75
228
 
76
229
  // Add message content
@@ -81,21 +234,85 @@ export const createStandardBubble = (
81
234
  streaming: Boolean(message.streaming),
82
235
  raw: message.rawContent
83
236
  });
237
+
238
+ // Add inline timestamp if configured
239
+ if (showTimestamp && timestampPosition === "inline" && message.createdAt) {
240
+ const timestamp = createTimestamp(message, timestampConfig!);
241
+ timestamp.classList.add("tvw-ml-2", "tvw-inline");
242
+ contentDiv.appendChild(timestamp);
243
+ }
244
+
84
245
  bubble.appendChild(contentDiv);
85
246
 
247
+ // Add timestamp below if configured
248
+ if (showTimestamp && timestampPosition === "below" && message.createdAt) {
249
+ const timestamp = createTimestamp(message, timestampConfig!);
250
+ timestamp.classList.add("tvw-mt-1");
251
+ bubble.appendChild(timestamp);
252
+ }
253
+
86
254
  // Add typing indicator if this is a streaming assistant message
87
- // Show it when streaming starts (even if content is empty), hide it once content appears
88
255
  if (message.streaming && message.role === "assistant") {
89
- // Only show typing indicator if content is empty or just starting
90
- // Hide it once we have substantial content
91
256
  if (!message.content || !message.content.trim()) {
92
257
  const typingIndicator = createTypingIndicator();
93
258
  bubble.appendChild(typingIndicator);
94
259
  }
95
260
  }
96
261
 
97
- return bubble;
262
+ // If no avatar needed, return bubble directly
263
+ if (!showAvatar || message.role === "system") {
264
+ return bubble;
265
+ }
266
+
267
+ // Create wrapper with avatar
268
+ const wrapper = createElement(
269
+ "div",
270
+ `tvw-flex tvw-gap-2 ${message.role === "user" ? "tvw-flex-row-reverse" : ""}`
271
+ );
272
+
273
+ const avatar = createAvatar(avatarConfig!, message.role);
274
+
275
+ if (avatarPosition === "right" || (avatarPosition === "left" && message.role === "user")) {
276
+ wrapper.append(bubble, avatar);
277
+ } else {
278
+ wrapper.append(avatar, bubble);
279
+ }
280
+
281
+ // Adjust bubble max-width when avatar is present
282
+ bubble.classList.remove("tvw-max-w-[85%]");
283
+ bubble.classList.add("tvw-max-w-[calc(85%-2.5rem)]");
284
+
285
+ return wrapper;
98
286
  };
99
287
 
288
+ /**
289
+ * Create bubble with custom renderer support
290
+ * Uses custom renderer if provided in layout config, otherwise falls back to standard bubble
291
+ */
292
+ export const createBubbleWithLayout = (
293
+ message: AgentWidgetMessage,
294
+ transform: MessageTransform,
295
+ layoutConfig?: AgentWidgetMessageLayoutConfig
296
+ ): HTMLElement => {
297
+ const config = layoutConfig ?? {};
100
298
 
299
+ // Check for custom renderers
300
+ if (message.role === "user" && config.renderUserMessage) {
301
+ return config.renderUserMessage({
302
+ message,
303
+ config: {} as any, // Will be populated by caller
304
+ streaming: Boolean(message.streaming)
305
+ });
306
+ }
101
307
 
308
+ if (message.role === "assistant" && config.renderAssistantMessage) {
309
+ return config.renderAssistantMessage({
310
+ message,
311
+ config: {} as any, // Will be populated by caller
312
+ streaming: Boolean(message.streaming)
313
+ });
314
+ }
315
+
316
+ // Fall back to standard bubble
317
+ return createStandardBubble(message, transform, layoutConfig);
318
+ };