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,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, "&")
|
|
19
|
+
.replace(/</g, "<")
|
|
20
|
+
.replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """)
|
|
22
|
+
.replace(/'/g, "'");
|
|
23
|
+
|
|
24
|
+
const escapeAttribute = (value: string) =>
|
|
25
|
+
value.replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
+
};
|