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.
@@ -0,0 +1,165 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { ChatWidgetMessage, ChatWidgetConfig } from "../types";
3
+ import { ChatWidgetSession } from "../session";
4
+
5
+ export const formDefinitions: Record<
6
+ string,
7
+ {
8
+ title: string;
9
+ description?: string;
10
+ fields: Array<{
11
+ name: string;
12
+ label: string;
13
+ placeholder?: string;
14
+ type?: "text" | "email" | "textarea";
15
+ required?: boolean;
16
+ }>;
17
+ submitLabel?: string;
18
+ }
19
+ > = {
20
+ init: {
21
+ title: "Schedule a Demo",
22
+ description: "Share the basics and we'll follow up with a confirmation.",
23
+ fields: [
24
+ { name: "name", label: "Full name", placeholder: "Jane Doe", required: true },
25
+ { name: "email", label: "Work email", placeholder: "jane@example.com", type: "email", required: true },
26
+ { name: "notes", label: "What would you like to cover?", type: "textarea" }
27
+ ],
28
+ submitLabel: "Submit details"
29
+ },
30
+ followup: {
31
+ title: "Additional Information",
32
+ description: "Provide any extra details to tailor the next steps.",
33
+ fields: [
34
+ { name: "company", label: "Company", placeholder: "Acme Inc." },
35
+ { name: "context", label: "Context", type: "textarea", placeholder: "Share more about your use case" }
36
+ ],
37
+ submitLabel: "Send"
38
+ }
39
+ };
40
+
41
+ export const enhanceWithForms = (
42
+ bubble: HTMLElement,
43
+ message: ChatWidgetMessage,
44
+ config: ChatWidgetConfig,
45
+ session: ChatWidgetSession
46
+ ) => {
47
+ const placeholders = bubble.querySelectorAll<HTMLElement>("[data-tv-form]");
48
+ if (placeholders.length) {
49
+ placeholders.forEach((placeholder) => {
50
+ if (placeholder.dataset.enhanced === "true") return;
51
+ const type = placeholder.dataset.tvForm ?? "init";
52
+ placeholder.dataset.enhanced = "true";
53
+
54
+ const definition = formDefinitions[type] ?? formDefinitions.init;
55
+ placeholder.classList.add("tvw-form-card", "tvw-space-y-4");
56
+
57
+ const heading = createElement("div", "tvw-space-y-1");
58
+ const title = createElement(
59
+ "h3",
60
+ "tvw-text-base tvw-font-semibold tvw-text-cw-primary"
61
+ );
62
+ title.textContent = definition.title;
63
+ heading.appendChild(title);
64
+ if (definition.description) {
65
+ const desc = createElement(
66
+ "p",
67
+ "tvw-text-sm tvw-text-cw-muted"
68
+ );
69
+ desc.textContent = definition.description;
70
+ heading.appendChild(desc);
71
+ }
72
+
73
+ const form = document.createElement("form");
74
+ form.className = "tvw-form-grid tvw-space-y-3";
75
+
76
+ definition.fields.forEach((field) => {
77
+ const group = createElement("label", "tvw-form-field tvw-flex tvw-flex-col tvw-gap-1");
78
+ group.htmlFor = `${message.id}-${type}-${field.name}`;
79
+ const label = createElement("span", "tvw-text-xs tvw-font-medium tvw-text-cw-muted");
80
+ label.textContent = field.label;
81
+ group.appendChild(label);
82
+
83
+ const inputType = field.type ?? "text";
84
+ let control: HTMLInputElement | HTMLTextAreaElement;
85
+ if (inputType === "textarea") {
86
+ control = document.createElement("textarea");
87
+ control.rows = 3;
88
+ } else {
89
+ control = document.createElement("input");
90
+ control.type = inputType;
91
+ }
92
+ control.className =
93
+ "tvw-rounded-xl tvw-border tvw-border-gray-200 tvw-bg-white tvw-px-3 tvw-py-2 tvw-text-sm tvw-text-cw-primary focus:tvw-outline-none focus:tvw-border-cw-primary";
94
+ control.id = `${message.id}-${type}-${field.name}`;
95
+ control.name = field.name;
96
+ control.placeholder = field.placeholder ?? "";
97
+ if (field.required) {
98
+ control.required = true;
99
+ }
100
+ group.appendChild(control);
101
+ form.appendChild(group);
102
+ });
103
+
104
+ const actions = createElement(
105
+ "div",
106
+ "tvw-flex tvw-items-center tvw-justify-between tvw-gap-2"
107
+ );
108
+ const status = createElement(
109
+ "div",
110
+ "tvw-text-xs tvw-text-cw-muted tvw-min-h-[1.5rem]"
111
+ );
112
+ const submit = createElement(
113
+ "button",
114
+ "tvw-inline-flex tvw-items-center tvw-rounded-full tvw-bg-cw-primary tvw-px-4 tvw-py-2 tvw-text-sm tvw-font-semibold tvw-text-white disabled:tvw-opacity-60 tvw-cursor-pointer"
115
+ ) as HTMLButtonElement;
116
+ submit.type = "submit";
117
+ submit.textContent = definition.submitLabel ?? "Submit";
118
+ actions.appendChild(status);
119
+ actions.appendChild(submit);
120
+ form.appendChild(actions);
121
+
122
+ placeholder.replaceChildren(heading, form);
123
+
124
+ form.addEventListener("submit", async (event) => {
125
+ event.preventDefault();
126
+ const formEndpoint = config.formEndpoint ?? "/form";
127
+ const formData = new FormData(form as HTMLFormElement);
128
+ const payload: Record<string, unknown> = {};
129
+ formData.forEach((value, key) => {
130
+ payload[key] = value;
131
+ });
132
+ payload["type"] = type;
133
+
134
+ submit.disabled = true;
135
+ status.textContent = "Submitting…";
136
+
137
+ try {
138
+ const response = await fetch(formEndpoint, {
139
+ method: "POST",
140
+ headers: {
141
+ "Content-Type": "application/json"
142
+ },
143
+ body: JSON.stringify(payload)
144
+ });
145
+ if (!response.ok) {
146
+ throw new Error(`Form submission failed (${response.status})`);
147
+ }
148
+ const data = await response.json();
149
+ status.textContent = data.message ?? "Thanks! We'll be in touch soon.";
150
+ if (data.success && data.nextPrompt) {
151
+ await session.sendMessage(String(data.nextPrompt));
152
+ }
153
+ } catch (error) {
154
+ status.textContent =
155
+ error instanceof Error ? error.message : "Something went wrong. Please try again.";
156
+ } finally {
157
+ submit.disabled = false;
158
+ }
159
+ });
160
+ });
161
+ }
162
+ };
163
+
164
+
165
+
@@ -0,0 +1,184 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { ChatWidgetConfig } from "../types";
3
+ import { positionMap } from "../utils/positioning";
4
+ import { renderLucideIcon } from "../utils/icons";
5
+
6
+ export interface LauncherButton {
7
+ element: HTMLButtonElement;
8
+ update: (config: ChatWidgetConfig) => void;
9
+ destroy: () => void;
10
+ }
11
+
12
+ export const createLauncherButton = (
13
+ config: ChatWidgetConfig | undefined,
14
+ onToggle: () => void
15
+ ): LauncherButton => {
16
+ const button = createElement("button") as HTMLButtonElement;
17
+ button.type = "button";
18
+ button.innerHTML = `
19
+ <span class="tvw-inline-flex tvw-items-center tvw-justify-center tvw-rounded-full tvw-bg-cw-primary tvw-text-white" data-role="launcher-icon">💬</span>
20
+ <img data-role="launcher-image" class="tvw-rounded-full tvw-object-cover" alt="" style="display:none" />
21
+ <span class="tvw-flex tvw-flex-col tvw-items-start tvw-text-left">
22
+ <span class="tvw-text-sm tvw-font-semibold tvw-text-cw-primary" data-role="launcher-title"></span>
23
+ <span class="tvw-text-xs tvw-text-cw-muted" data-role="launcher-subtitle"></span>
24
+ </span>
25
+ <span class="tvw-ml-2 tvw-grid tvw-place-items-center tvw-rounded-full tvw-bg-cw-primary tvw-text-cw-call-to-action" data-role="launcher-call-to-action-icon">↗</span>
26
+ `;
27
+ button.addEventListener("click", onToggle);
28
+
29
+ const update = (newConfig: ChatWidgetConfig) => {
30
+ const launcher = newConfig.launcher ?? {};
31
+
32
+ const titleEl = button.querySelector("[data-role='launcher-title']");
33
+ if (titleEl) {
34
+ titleEl.textContent = launcher.title ?? "Chat Assistant";
35
+ }
36
+
37
+ const subtitleEl = button.querySelector("[data-role='launcher-subtitle']");
38
+ if (subtitleEl) {
39
+ subtitleEl.textContent = launcher.subtitle ?? "Get answers fast";
40
+ }
41
+
42
+ // Hide/show text container
43
+ const textContainer = button.querySelector(".tvw-flex-col");
44
+ if (textContainer) {
45
+ if (launcher.textHidden) {
46
+ (textContainer as HTMLElement).style.display = "none";
47
+ } else {
48
+ (textContainer as HTMLElement).style.display = "";
49
+ }
50
+ }
51
+
52
+ const icon = button.querySelector<HTMLSpanElement>("[data-role='launcher-icon']");
53
+ if (icon) {
54
+ if (launcher.agentIconHidden) {
55
+ icon.style.display = "none";
56
+ } else {
57
+ const iconSize = launcher.agentIconSize ?? "40px";
58
+ icon.style.height = iconSize;
59
+ icon.style.width = iconSize;
60
+
61
+ // Clear existing content
62
+ icon.innerHTML = "";
63
+
64
+ // Render icon based on priority: Lucide icon > iconUrl > agentIconText
65
+ if (launcher.agentIconName) {
66
+ // Use Lucide icon
67
+ const iconSizeNum = parseFloat(iconSize) || 24;
68
+ const iconSvg = renderLucideIcon(launcher.agentIconName, iconSizeNum * 0.6, "#ffffff", 2);
69
+ if (iconSvg) {
70
+ icon.appendChild(iconSvg);
71
+ icon.style.display = "";
72
+ } else {
73
+ // Fallback to agentIconText if Lucide icon fails
74
+ icon.textContent = launcher.agentIconText ?? "💬";
75
+ icon.style.display = "";
76
+ }
77
+ } else if (launcher.iconUrl) {
78
+ // Use image URL - hide icon span and show img
79
+ icon.style.display = "none";
80
+ } else {
81
+ // Use text/emoji
82
+ icon.textContent = launcher.agentIconText ?? "💬";
83
+ icon.style.display = "";
84
+ }
85
+ }
86
+ }
87
+
88
+ const img = button.querySelector<HTMLImageElement>("[data-role='launcher-image']");
89
+ if (img) {
90
+ const iconSize = launcher.agentIconSize ?? "40px";
91
+ img.style.height = iconSize;
92
+ img.style.width = iconSize;
93
+ if (launcher.iconUrl && !launcher.agentIconName && !launcher.agentIconHidden) {
94
+ // Only show image if not using Lucide icon and not hidden
95
+ img.src = launcher.iconUrl;
96
+ img.style.display = "block";
97
+ } else {
98
+ img.style.display = "none";
99
+ }
100
+ }
101
+
102
+ const callToActionIconEl = button.querySelector<HTMLSpanElement>("[data-role='launcher-call-to-action-icon']");
103
+ if (callToActionIconEl) {
104
+ const callToActionIconSize = launcher.callToActionIconSize ?? "32px";
105
+ callToActionIconEl.style.height = callToActionIconSize;
106
+ callToActionIconEl.style.width = callToActionIconSize;
107
+
108
+ // Apply background color if configured
109
+ if (launcher.callToActionIconBackgroundColor) {
110
+ callToActionIconEl.style.backgroundColor = launcher.callToActionIconBackgroundColor;
111
+ callToActionIconEl.classList.remove("tvw-bg-cw-primary");
112
+ } else {
113
+ callToActionIconEl.style.backgroundColor = "";
114
+ callToActionIconEl.classList.add("tvw-bg-cw-primary");
115
+ }
116
+
117
+ // Calculate padding to adjust icon size
118
+ let paddingTotal = 0;
119
+ if (launcher.callToActionIconPadding) {
120
+ callToActionIconEl.style.boxSizing = "border-box";
121
+ callToActionIconEl.style.padding = launcher.callToActionIconPadding;
122
+ // Parse padding value to calculate total padding (padding applies to both sides)
123
+ const paddingValue = parseFloat(launcher.callToActionIconPadding) || 0;
124
+ paddingTotal = paddingValue * 2; // padding on both sides
125
+ } else {
126
+ callToActionIconEl.style.boxSizing = "";
127
+ callToActionIconEl.style.padding = "";
128
+ }
129
+
130
+ if (launcher.callToActionIconHidden) {
131
+ callToActionIconEl.style.display = "none";
132
+ } else {
133
+ callToActionIconEl.style.display = "";
134
+
135
+ // Clear existing content
136
+ callToActionIconEl.innerHTML = "";
137
+
138
+ // Use Lucide icon if provided, otherwise fall back to text
139
+ if (launcher.callToActionIconName) {
140
+ // Calculate actual icon size by subtracting padding
141
+ const containerSize = parseFloat(callToActionIconSize) || 24;
142
+ const iconSize = Math.max(containerSize - paddingTotal, 8); // Ensure minimum size of 8px
143
+ const iconSvg = renderLucideIcon(launcher.callToActionIconName, iconSize, "currentColor", 2);
144
+ if (iconSvg) {
145
+ callToActionIconEl.appendChild(iconSvg);
146
+ } else {
147
+ // Fallback to text if icon fails to render
148
+ callToActionIconEl.textContent = launcher.callToActionIconText ?? "↗";
149
+ }
150
+ } else {
151
+ callToActionIconEl.textContent = launcher.callToActionIconText ?? "↗";
152
+ }
153
+ }
154
+ }
155
+
156
+ const positionClass =
157
+ launcher.position && positionMap[launcher.position]
158
+ ? positionMap[launcher.position]
159
+ : positionMap["bottom-right"];
160
+
161
+ const base =
162
+ "tvw-fixed tvw-flex tvw-items-center tvw-gap-3 tvw-rounded-launcher tvw-bg-cw-surface tvw-py-2.5 tvw-pl-3 tvw-pr-3 tvw-shadow-lg tvw-border tvw-border-gray-200 tvw-transition hover:tvw-translate-y-[-2px] tvw-cursor-pointer";
163
+
164
+ button.className = `${base} ${positionClass}`;
165
+ };
166
+
167
+ const destroy = () => {
168
+ button.removeEventListener("click", onToggle);
169
+ button.remove();
170
+ };
171
+
172
+ // Initial update
173
+ if (config) {
174
+ update(config);
175
+ }
176
+
177
+ return {
178
+ element: button,
179
+ update,
180
+ destroy
181
+ };
182
+ };
183
+
184
+
@@ -0,0 +1,52 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { ChatWidgetMessage } from "../types";
3
+
4
+ export type MessageTransform = (context: {
5
+ text: string;
6
+ message: ChatWidgetMessage;
7
+ streaming: boolean;
8
+ }) => string;
9
+
10
+ export const createStandardBubble = (
11
+ message: ChatWidgetMessage,
12
+ transform: MessageTransform
13
+ ): HTMLElement => {
14
+ const classes = [
15
+ "tvw-max-w-[85%]",
16
+ "tvw-rounded-2xl",
17
+ "tvw-text-sm",
18
+ "tvw-leading-relaxed",
19
+ "tvw-shadow-sm"
20
+ ];
21
+
22
+ if (message.role === "user") {
23
+ classes.push(
24
+ "tvw-ml-auto",
25
+ "tvw-bg-cw-accent",
26
+ "tvw-text-white",
27
+ "tvw-px-5",
28
+ "tvw-py-3"
29
+ );
30
+ } else {
31
+ classes.push(
32
+ "tvw-bg-cw-surface",
33
+ "tvw-border",
34
+ "tvw-border-cw-message-border",
35
+ "tvw-text-cw-primary",
36
+ "tvw-px-5",
37
+ "tvw-py-3"
38
+ );
39
+ }
40
+
41
+ const bubble = createElement("div", classes.join(" "));
42
+ bubble.innerHTML = transform({
43
+ text: message.content,
44
+ message,
45
+ streaming: Boolean(message.streaming)
46
+ });
47
+
48
+ return bubble;
49
+ };
50
+
51
+
52
+
@@ -0,0 +1,43 @@
1
+ import { createElement, createFragment } from "../utils/dom";
2
+ import { ChatWidgetMessage } from "../types";
3
+ import { MessageTransform } from "./message-bubble";
4
+ import { createStandardBubble } from "./message-bubble";
5
+ import { createReasoningBubble } from "./reasoning-bubble";
6
+ import { createToolBubble } from "./tool-bubble";
7
+
8
+ export const renderMessages = (
9
+ container: HTMLElement,
10
+ messages: ChatWidgetMessage[],
11
+ transform: MessageTransform,
12
+ showReasoning: boolean,
13
+ showToolCalls: boolean
14
+ ) => {
15
+ container.innerHTML = "";
16
+ const fragment = createFragment();
17
+
18
+ messages.forEach((message) => {
19
+ let bubble: HTMLElement;
20
+ if (message.variant === "reasoning" && message.reasoning) {
21
+ if (!showReasoning) return;
22
+ bubble = createReasoningBubble(message);
23
+ } else if (message.variant === "tool" && message.toolCall) {
24
+ if (!showToolCalls) return;
25
+ bubble = createToolBubble(message);
26
+ } else {
27
+ bubble = createStandardBubble(message, transform);
28
+ }
29
+
30
+ const wrapper = createElement("div", "tvw-flex");
31
+ if (message.role === "user") {
32
+ wrapper.classList.add("tvw-justify-end");
33
+ }
34
+ wrapper.appendChild(bubble);
35
+ fragment.appendChild(wrapper);
36
+ });
37
+
38
+ container.appendChild(fragment);
39
+ container.scrollTop = container.scrollHeight;
40
+ };
41
+
42
+
43
+