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,52 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { ChatWidgetSession } from "../session";
3
+ import { ChatWidgetMessage } from "../types";
4
+
5
+ export interface SuggestionButtons {
6
+ buttons: HTMLButtonElement[];
7
+ render: (chips: string[] | undefined, session: ChatWidgetSession, textarea: HTMLTextAreaElement, messages?: ChatWidgetMessage[]) => void;
8
+ }
9
+
10
+ export const createSuggestions = (container: HTMLElement): SuggestionButtons => {
11
+ const suggestionButtons: HTMLButtonElement[] = [];
12
+
13
+ const render = (chips: string[] | undefined, session: ChatWidgetSession, textarea: HTMLTextAreaElement, messages?: ChatWidgetMessage[]) => {
14
+ container.innerHTML = "";
15
+ suggestionButtons.length = 0;
16
+ if (!chips || !chips.length) return;
17
+
18
+ // Hide suggestions after the first user message is sent
19
+ // Use provided messages or get from session
20
+ const messagesToCheck = messages ?? (session ? session.getMessages() : []);
21
+ const hasUserMessage = messagesToCheck.some((msg) => msg.role === "user");
22
+ if (hasUserMessage) return;
23
+
24
+ const fragment = document.createDocumentFragment();
25
+ const streaming = session ? session.isStreaming() : false;
26
+ chips.forEach((chip) => {
27
+ const btn = createElement(
28
+ "button",
29
+ "tvw-rounded-button tvw-bg-cw-surface tvw-px-3 tvw-py-1.5 tvw-text-xs tvw-font-medium tvw-text-cw-muted hover:tvw-opacity-90 tvw-cursor-pointer tvw-border tvw-border-gray-200"
30
+ ) as HTMLButtonElement;
31
+ btn.type = "button";
32
+ btn.textContent = chip;
33
+ btn.disabled = streaming;
34
+ btn.addEventListener("click", () => {
35
+ if (!session || session.isStreaming()) return;
36
+ textarea.value = "";
37
+ session.sendMessage(chip);
38
+ });
39
+ fragment.appendChild(btn);
40
+ suggestionButtons.push(btn);
41
+ });
42
+ container.appendChild(fragment);
43
+ };
44
+
45
+ return {
46
+ buttons: suggestionButtons,
47
+ render
48
+ };
49
+ };
50
+
51
+
52
+
@@ -0,0 +1,158 @@
1
+ import { createElement } from "../utils/dom";
2
+ import { ChatWidgetMessage } from "../types";
3
+ import { formatUnknownValue, describeToolTitle } from "../utils/formatting";
4
+
5
+ // Expansion state per widget instance
6
+ const toolExpansionState = new Set<string>();
7
+
8
+ export const createToolBubble = (message: ChatWidgetMessage): HTMLElement => {
9
+ const tool = message.toolCall;
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 (!tool) {
27
+ return bubble;
28
+ }
29
+
30
+ let expanded = toolExpansionState.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-text-cw-primary");
40
+ title.textContent = describeToolTitle(tool);
41
+ headerContent.appendChild(title);
42
+
43
+ if (tool.name) {
44
+ const name = createElement("span", "tvw-text-[11px] tvw-text-cw-muted");
45
+ name.textContent = tool.name;
46
+ headerContent.appendChild(name);
47
+ }
48
+
49
+ const toggleLabel = createElement(
50
+ "span",
51
+ "tvw-text-xs tvw-text-cw-primary"
52
+ );
53
+ toggleLabel.textContent = expanded ? "Hide" : "Show";
54
+
55
+ const headerMeta = createElement("div", "tvw-flex tvw-items-center tvw-gap-2");
56
+ headerMeta.append(toggleLabel);
57
+
58
+ header.append(headerContent, headerMeta);
59
+
60
+ const content = createElement(
61
+ "div",
62
+ "tvw-border-t tvw-border-gray-200 tvw-bg-gray-50 tvw-space-y-3 tvw-px-4 tvw-py-3"
63
+ );
64
+ content.style.display = expanded ? "" : "none";
65
+
66
+ if (tool.args !== undefined) {
67
+ const argsBlock = createElement("div", "tvw-space-y-1");
68
+ const argsLabel = createElement(
69
+ "div",
70
+ "tvw-font-xxs tvw-font-medium tvw-text-cw-muted"
71
+ );
72
+ argsLabel.textContent = "Arguments";
73
+ const argsPre = createElement(
74
+ "pre",
75
+ "tvw-max-h-48 tvw-overflow-auto tvw-whitespace-pre-wrap tvw-rounded-lg tvw-border tvw-border-gray-100 tvw-bg-white tvw-px-3 tvw-py-2 tvw-font-xxs tvw-text-cw-primary"
76
+ );
77
+ argsPre.textContent = formatUnknownValue(tool.args);
78
+ argsBlock.append(argsLabel, argsPre);
79
+ content.appendChild(argsBlock);
80
+ }
81
+
82
+ if (tool.chunks && tool.chunks.length) {
83
+ const logsBlock = createElement("div", "tvw-space-y-1");
84
+ const logsLabel = createElement(
85
+ "div",
86
+ "tvw-font-xxs tvw-font-medium tvw-text-cw-muted"
87
+ );
88
+ logsLabel.textContent = "Activity";
89
+ const logsPre = createElement(
90
+ "pre",
91
+ "tvw-max-h-48 tvw-overflow-auto tvw-whitespace-pre-wrap tvw-rounded-lg tvw-border tvw-border-gray-100 tvw-bg-white tvw-px-3 tvw-py-2 tvw-font-xxs tvw-text-cw-primary"
92
+ );
93
+ logsPre.textContent = tool.chunks.join("\n");
94
+ logsBlock.append(logsLabel, logsPre);
95
+ content.appendChild(logsBlock);
96
+ }
97
+
98
+ if (tool.status === "complete" && tool.result !== undefined) {
99
+ const resultBlock = createElement("div", "tvw-space-y-1");
100
+ const resultLabel = createElement(
101
+ "div",
102
+ "tvw-font-xxs tvw-text-sm tvw-text-cw-muted"
103
+ );
104
+ resultLabel.textContent = "Result";
105
+ const resultPre = createElement(
106
+ "pre",
107
+ "tvw-max-h-48 tvw-overflow-auto tvw-whitespace-pre-wrap tvw-rounded-lg tvw-border tvw-border-gray-100 tvw-bg-white tvw-px-3 tvw-py-2 tvw-font-xxs tvw-text-cw-primary"
108
+ );
109
+ resultPre.textContent = formatUnknownValue(tool.result);
110
+ resultBlock.append(resultLabel, resultPre);
111
+ content.appendChild(resultBlock);
112
+ }
113
+
114
+ if (tool.status === "complete" && typeof tool.duration === "number") {
115
+ const duration = createElement(
116
+ "div",
117
+ "tvw-font-xxs tvw-text-cw-muted"
118
+ );
119
+ duration.textContent = `Duration: ${tool.duration}ms`;
120
+ content.appendChild(duration);
121
+ }
122
+
123
+ const applyToolExpansion = () => {
124
+ header.setAttribute("aria-expanded", expanded ? "true" : "false");
125
+ toggleLabel.textContent = expanded ? "Hide" : "Show";
126
+ content.style.display = expanded ? "" : "none";
127
+ };
128
+
129
+ const toggleToolExpansion = () => {
130
+ expanded = !expanded;
131
+ if (expanded) {
132
+ toolExpansionState.add(message.id);
133
+ } else {
134
+ toolExpansionState.delete(message.id);
135
+ }
136
+ applyToolExpansion();
137
+ };
138
+
139
+ header.addEventListener("pointerdown", (event) => {
140
+ event.preventDefault();
141
+ toggleToolExpansion();
142
+ });
143
+
144
+ header.addEventListener("keydown", (event) => {
145
+ if (event.key === "Enter" || event.key === " ") {
146
+ event.preventDefault();
147
+ toggleToolExpansion();
148
+ }
149
+ });
150
+
151
+ applyToolExpansion();
152
+
153
+ bubble.append(header, content);
154
+ return bubble;
155
+ };
156
+
157
+
158
+
package/src/index.ts ADDED
@@ -0,0 +1,37 @@
1
+ import {
2
+ initChatWidget as initChatWidgetFn,
3
+ type ChatWidgetInitHandle
4
+ } from "./runtime/init";
5
+
6
+ export type {
7
+ ChatWidgetConfig,
8
+ ChatWidgetTheme,
9
+ ChatWidgetFeatureFlags,
10
+ ChatWidgetInitOptions,
11
+ ChatWidgetMessage,
12
+ ChatWidgetLauncherConfig,
13
+ ChatWidgetEvent
14
+ } from "./types";
15
+
16
+ export { initChatWidgetFn as initChatWidget };
17
+ export {
18
+ createChatExperience,
19
+ type ChatWidgetController
20
+ } from "./ui";
21
+ export {
22
+ ChatWidgetSession,
23
+ type ChatWidgetSessionStatus
24
+ } from "./session";
25
+ export { ChatWidgetClient } from "./client";
26
+ export {
27
+ markdownPostprocessor,
28
+ escapeHtml,
29
+ directivePostprocessor
30
+ } from "./postprocessors";
31
+ export type { ChatWidgetInitHandle };
32
+
33
+ // Plugin system exports
34
+ export type { ChatWidgetPlugin } from "./plugins/types";
35
+ export { pluginRegistry } from "./plugins/registry";
36
+
37
+ export default initChatWidgetFn;
package/src/install.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Standalone installer script for easy script tag installation
3
+ * This script automatically loads CSS and JS, then initializes the widget
4
+ * if configuration is provided via window.siteAgentConfig
5
+ */
6
+
7
+ interface SiteAgentInstallConfig {
8
+ version?: string;
9
+ cdn?: "unpkg" | "jsdelivr";
10
+ cssUrl?: string;
11
+ jsUrl?: string;
12
+ target?: string | HTMLElement;
13
+ config?: any;
14
+ autoInit?: boolean;
15
+ }
16
+
17
+ declare global {
18
+ interface Window {
19
+ siteAgentConfig?: SiteAgentInstallConfig;
20
+ ChatWidget?: any;
21
+ }
22
+ }
23
+
24
+ (function() {
25
+ "use strict";
26
+
27
+ // Prevent double installation
28
+ if ((window as any).__siteAgentInstallerLoaded) {
29
+ return;
30
+ }
31
+ (window as any).__siteAgentInstallerLoaded = true;
32
+
33
+ const config: SiteAgentInstallConfig = window.siteAgentConfig || {};
34
+ const version = config.version || "latest";
35
+ const cdn = config.cdn || "jsdelivr";
36
+ const autoInit = config.autoInit !== false; // Default to true
37
+
38
+ // Determine CDN base URL
39
+ const getCdnBase = () => {
40
+ if (config.cssUrl && config.jsUrl) {
41
+ return { cssUrl: config.cssUrl, jsUrl: config.jsUrl };
42
+ }
43
+
44
+ const packageName = "vanilla-agent";
45
+ const basePath = `/npm/${packageName}@${version}/dist`;
46
+
47
+ if (cdn === "unpkg") {
48
+ return {
49
+ cssUrl: `https://unpkg.com${basePath}/widget.css`,
50
+ jsUrl: `https://unpkg.com${basePath}/index.global.js`
51
+ };
52
+ } else {
53
+ return {
54
+ cssUrl: `https://cdn.jsdelivr.net${basePath}/widget.css`,
55
+ jsUrl: `https://cdn.jsdelivr.net${basePath}/index.global.js`
56
+ };
57
+ }
58
+ };
59
+
60
+ const { cssUrl, jsUrl } = getCdnBase();
61
+
62
+ // Check if CSS is already loaded
63
+ const isCssLoaded = () => {
64
+ return !!document.head.querySelector('link[data-vanilla-agent]') ||
65
+ !!document.head.querySelector(`link[href*="widget.css"]`);
66
+ };
67
+
68
+ // Check if JS is already loaded
69
+ const isJsLoaded = () => {
70
+ return !!(window as any).ChatWidget;
71
+ };
72
+
73
+ // Load CSS
74
+ const loadCSS = (): Promise<void> => {
75
+ return new Promise((resolve, reject) => {
76
+ if (isCssLoaded()) {
77
+ resolve();
78
+ return;
79
+ }
80
+
81
+ const link = document.createElement("link");
82
+ link.rel = "stylesheet";
83
+ link.href = cssUrl;
84
+ link.setAttribute("data-vanilla-agent", "true");
85
+ link.onload = () => resolve();
86
+ link.onerror = () => reject(new Error(`Failed to load CSS from ${cssUrl}`));
87
+ document.head.appendChild(link);
88
+ });
89
+ };
90
+
91
+ // Load JS
92
+ const loadJS = (): Promise<void> => {
93
+ return new Promise((resolve, reject) => {
94
+ if (isJsLoaded()) {
95
+ resolve();
96
+ return;
97
+ }
98
+
99
+ const script = document.createElement("script");
100
+ script.src = jsUrl;
101
+ script.async = true;
102
+ script.onload = () => resolve();
103
+ script.onerror = () => reject(new Error(`Failed to load JS from ${jsUrl}`));
104
+ document.head.appendChild(script);
105
+ });
106
+ };
107
+
108
+ // Initialize widget
109
+ const initWidget = () => {
110
+ if (!window.ChatWidget || !window.ChatWidget.initChatWidget) {
111
+ console.warn("ChatWidget not available. Make sure the script loaded successfully.");
112
+ return;
113
+ }
114
+
115
+ const target = config.target || "body";
116
+ // Merge apiUrl from top-level config into widget config if present
117
+ const widgetConfig = { ...config.config };
118
+ if ((config as any).apiUrl && !widgetConfig.apiUrl) {
119
+ widgetConfig.apiUrl = (config as any).apiUrl;
120
+ }
121
+
122
+ // Only initialize if config is provided
123
+ if (!widgetConfig.apiUrl && Object.keys(widgetConfig).length === 0) {
124
+ return;
125
+ }
126
+
127
+ try {
128
+ window.ChatWidget.initChatWidget({
129
+ target,
130
+ config: widgetConfig
131
+ });
132
+ } catch (error) {
133
+ console.error("Failed to initialize ChatWidget:", error);
134
+ }
135
+ };
136
+
137
+ // Main installation flow
138
+ const install = async () => {
139
+ try {
140
+ await loadCSS();
141
+ await loadJS();
142
+
143
+ if (autoInit && (config.config || (config as any).apiUrl)) {
144
+ // Wait a tick to ensure ChatWidget is fully initialized
145
+ setTimeout(initWidget, 0);
146
+ }
147
+ } catch (error) {
148
+ console.error("Failed to install ChatWidget:", error);
149
+ }
150
+ };
151
+
152
+ // Start installation
153
+ if (document.readyState === "loading") {
154
+ document.addEventListener("DOMContentLoaded", install);
155
+ } else {
156
+ install();
157
+ }
158
+ })();
159
+
@@ -0,0 +1,72 @@
1
+ import { ChatWidgetPlugin } from "./types";
2
+
3
+ class PluginRegistry {
4
+ private plugins: Map<string, ChatWidgetPlugin> = new Map();
5
+
6
+ /**
7
+ * Register a plugin
8
+ */
9
+ register(plugin: ChatWidgetPlugin): void {
10
+ if (this.plugins.has(plugin.id)) {
11
+ console.warn(`Plugin "${plugin.id}" is already registered. Overwriting.`);
12
+ }
13
+
14
+ this.plugins.set(plugin.id, plugin);
15
+ plugin.onRegister?.();
16
+ }
17
+
18
+ /**
19
+ * Unregister a plugin
20
+ */
21
+ unregister(pluginId: string): void {
22
+ const plugin = this.plugins.get(pluginId);
23
+ if (plugin) {
24
+ plugin.onUnregister?.();
25
+ this.plugins.delete(pluginId);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get all plugins sorted by priority
31
+ */
32
+ getAll(): ChatWidgetPlugin[] {
33
+ return Array.from(this.plugins.values()).sort(
34
+ (a, b) => (b.priority ?? 0) - (a.priority ?? 0)
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Get plugins for a specific instance (from config)
40
+ * Merges instance plugins with globally registered plugins
41
+ */
42
+ getForInstance(instancePlugins?: ChatWidgetPlugin[]): ChatWidgetPlugin[] {
43
+ const allPlugins = this.getAll();
44
+
45
+ if (!instancePlugins || instancePlugins.length === 0) {
46
+ return allPlugins;
47
+ }
48
+
49
+ // Merge instance plugins with global plugins
50
+ // Instance plugins override global plugins with the same ID
51
+ const instanceIds = new Set(instancePlugins.map(p => p.id));
52
+ const merged = [
53
+ ...allPlugins.filter(p => !instanceIds.has(p.id)),
54
+ ...instancePlugins
55
+ ];
56
+
57
+ return merged.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
58
+ }
59
+
60
+ /**
61
+ * Clear all plugins
62
+ */
63
+ clear(): void {
64
+ this.plugins.forEach(plugin => plugin.onUnregister?.());
65
+ this.plugins.clear();
66
+ }
67
+ }
68
+
69
+ export const pluginRegistry = new PluginRegistry();
70
+
71
+
72
+
@@ -0,0 +1,90 @@
1
+ import { ChatWidgetMessage, ChatWidgetConfig } from "../types";
2
+
3
+ /**
4
+ * Plugin interface for customizing widget components
5
+ */
6
+ export interface ChatWidgetPlugin {
7
+ /**
8
+ * Unique identifier for the plugin
9
+ */
10
+ id: string;
11
+
12
+ /**
13
+ * Optional priority (higher = runs first). Default: 0
14
+ */
15
+ priority?: number;
16
+
17
+ /**
18
+ * Custom renderer for message bubbles
19
+ * Return null to use default renderer
20
+ */
21
+ renderMessage?: (context: {
22
+ message: ChatWidgetMessage;
23
+ defaultRenderer: () => HTMLElement;
24
+ config: ChatWidgetConfig;
25
+ }) => HTMLElement | null;
26
+
27
+ /**
28
+ * Custom renderer for launcher button
29
+ * Return null to use default renderer
30
+ */
31
+ renderLauncher?: (context: {
32
+ config: ChatWidgetConfig;
33
+ defaultRenderer: () => HTMLElement;
34
+ onToggle: () => void;
35
+ }) => HTMLElement | null;
36
+
37
+ /**
38
+ * Custom renderer for panel header
39
+ * Return null to use default renderer
40
+ */
41
+ renderHeader?: (context: {
42
+ config: ChatWidgetConfig;
43
+ defaultRenderer: () => HTMLElement;
44
+ onClose?: () => void;
45
+ }) => HTMLElement | null;
46
+
47
+ /**
48
+ * Custom renderer for composer/input area
49
+ * Return null to use default renderer
50
+ */
51
+ renderComposer?: (context: {
52
+ config: ChatWidgetConfig;
53
+ defaultRenderer: () => HTMLElement;
54
+ onSubmit: (text: string) => void;
55
+ disabled: boolean;
56
+ }) => HTMLElement | null;
57
+
58
+ /**
59
+ * Custom renderer for reasoning bubbles
60
+ * Return null to use default renderer
61
+ */
62
+ renderReasoning?: (context: {
63
+ message: ChatWidgetMessage;
64
+ defaultRenderer: () => HTMLElement;
65
+ config: ChatWidgetConfig;
66
+ }) => HTMLElement | null;
67
+
68
+ /**
69
+ * Custom renderer for tool call bubbles
70
+ * Return null to use default renderer
71
+ */
72
+ renderToolCall?: (context: {
73
+ message: ChatWidgetMessage;
74
+ defaultRenderer: () => HTMLElement;
75
+ config: ChatWidgetConfig;
76
+ }) => HTMLElement | null;
77
+
78
+ /**
79
+ * Called when plugin is registered
80
+ */
81
+ onRegister?: () => void;
82
+
83
+ /**
84
+ * Called when plugin is unregistered
85
+ */
86
+ onUnregister?: () => void;
87
+ }
88
+
89
+
90
+
@@ -0,0 +1,76 @@
1
+ import { marked } from "marked";
2
+
3
+ marked.setOptions({ breaks: true });
4
+
5
+ /**
6
+ * Basic markdown renderer. Remember to sanitize the returned HTML if you render
7
+ * untrusted content in your host page.
8
+ */
9
+ export const markdownPostprocessor = (text: string): string => {
10
+ return marked.parse(text) as string;
11
+ };
12
+
13
+ /**
14
+ * Escapes HTML entities. Used as the default safe renderer.
15
+ */
16
+ export const escapeHtml = (text: string): string =>
17
+ text
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;")
22
+ .replace(/'/g, "&#39;");
23
+
24
+ const escapeAttribute = (value: string) =>
25
+ value.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
26
+
27
+ const makeToken = (idx: number) => `%%FORM_PLACEHOLDER_${idx}%%`;
28
+
29
+ const directiveReplacer = (source: string, placeholders: Array<{ token: string; type: string }>) => {
30
+ let working = source;
31
+
32
+ // JSON directive pattern e.g. <Directive>{"component":"form","type":"init"}</Directive>
33
+ working = working.replace(/<Directive>([\s\S]*?)<\/Directive>/gi, (match, jsonText) => {
34
+ try {
35
+ const parsed = JSON.parse(jsonText.trim());
36
+ if (parsed && typeof parsed === "object" && parsed.component === "form" && parsed.type) {
37
+ const token = makeToken(placeholders.length);
38
+ placeholders.push({ token, type: String(parsed.type) });
39
+ return token;
40
+ }
41
+ } catch (error) {
42
+ return match;
43
+ }
44
+ return match;
45
+ });
46
+
47
+ // XML-style directive e.g. <Form type="init" />
48
+ working = working.replace(/<Form\s+type="([^"]+)"\s*\/>/gi, (_, type) => {
49
+ const token = makeToken(placeholders.length);
50
+ placeholders.push({ token, type });
51
+ return token;
52
+ });
53
+
54
+ return working;
55
+ };
56
+
57
+ /**
58
+ * Converts special directives (either `<Form type="init" />` or
59
+ * `<Directive>{"component":"form","type":"init"}</Directive>`) into placeholder
60
+ * elements that the widget upgrades after render. Remaining text is rendered as
61
+ * Markdown.
62
+ */
63
+ export const directivePostprocessor = (text: string): string => {
64
+ const placeholders: Array<{ token: string; type: string }> = [];
65
+ const withTokens = directiveReplacer(text, placeholders);
66
+ let html = markdownPostprocessor(withTokens);
67
+
68
+ placeholders.forEach(({ token, type }) => {
69
+ const tokenRegex = new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
70
+ const safeType = escapeAttribute(type);
71
+ const replacement = `<div class="tvw-form-directive" data-tv-form="${safeType}"></div>`;
72
+ html = html.replace(tokenRegex, replacement);
73
+ });
74
+
75
+ return html;
76
+ };