senangwebs-chatbot
Version:
Lightweight JavaScript library with OpenRouter API integration for AI-powered conversations.
1,210 lines (1,080 loc) • 39.5 kB
JavaScript
// SenangWebs Chatbot Library
// Import API classes if they exist (for modular usage)
// These classes can also be included separately in HTML
let OpenRouterAPI, ContextManager;
// Try to import classes (for webpack bundling)
try {
if (typeof require !== "undefined") {
OpenRouterAPI = require("./openrouter-client.js");
ContextManager = require("./context-manager.js");
}
} catch (e) {
// Classes will be loaded from global scope or separate script tags
}
// Make classes available globally if not already defined
if (typeof window !== "undefined") {
if (!window.OpenRouterAPI && typeof OpenRouterAPI !== "undefined") {
window.OpenRouterAPI = OpenRouterAPI;
}
if (!window.ContextManager && typeof ContextManager !== "undefined") {
window.ContextManager = ContextManager;
}
// Use global classes if available
OpenRouterAPI = window.OpenRouterAPI || OpenRouterAPI;
ContextManager = window.ContextManager || ContextManager;
}
class SenangWebsChatbot {
constructor(knowledgeBase, botMetadata = {}, apiConfig = null) {
this.knowledgeBase = knowledgeBase;
this.currentNode = null;
this.chatHistory = [];
this.botMetadata = {
botName: botMetadata.botName || "Bot",
themeColor: botMetadata.themeColor || "#007bff",
timestamp: new Date().toISOString(),
};
// API Configuration
this.apiConfig = apiConfig;
this.mode = apiConfig?.mode || "keyword-only"; // 'keyword-only', 'ai-only', 'hybrid'
this.streamingEnabled = apiConfig?.streaming !== false;
this.aiResponseInProgress = false;
this.hybridThreshold = apiConfig?.hybridThreshold || 0.3; // Lower default for better keyword matching
// Initialize API client and context manager if API is configured
if (apiConfig && apiConfig.apiKey) {
try {
// Check if OpenRouterAPI class is available
if (typeof OpenRouterAPI !== "undefined") {
this.apiClient = new OpenRouterAPI(apiConfig);
} else {
console.error(
"[SWC] OpenRouterAPI class not found. Please include openrouter-client.js"
);
this.apiClient = null;
}
// Check if ContextManager class is available
if (typeof ContextManager !== "undefined") {
this.contextManager = new ContextManager({
systemPrompt:
apiConfig.systemPrompt || "You are a helpful assistant.",
maxMessages: apiConfig.contextMaxMessages || 10,
maxTokens: apiConfig.contextMaxTokens || 2000,
debug: apiConfig.debug || false,
});
} else {
console.error(
"[SWC] ContextManager class not found. Please include context-manager.js"
);
this.contextManager = null;
}
} catch (error) {
console.error("[SWC] Error initializing API components:", error);
this.apiClient = null;
this.contextManager = null;
}
} else {
this.apiClient = null;
this.contextManager = null;
}
}
init() {
this.currentNode =
this.knowledgeBase.find((node) => node.id === "welcome") ||
this.knowledgeBase[0];
const response = {
reply: this.currentNode.reply,
options: this.currentNode.options,
};
// Add welcome message to history
this.addToHistory(
"bot",
this.currentNode.reply,
this.currentNode.id,
this.currentNode.options
);
return response;
}
addToHistory(
type,
content,
nodeId = null,
options = null,
source = "keyword",
modelInfo = null
) {
const message = {
id: `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
timestamp: new Date().toISOString(),
type: type,
content: content,
source: source, // 'keyword', 'api', or 'fallback'
};
if (type === "bot") {
message.nodeId = nodeId;
if (options && options.length > 0) {
message.options = options;
}
if (modelInfo) {
message.model = modelInfo.model;
}
}
this.chatHistory.push(message);
}
async handleInput(input, callbacks = {}) {
const lowercaseInput = input.toLowerCase();
const words = lowercaseInput.split(/\s+/);
// Add user message to history
this.addToHistory("user", input);
// Add user message to context if API is enabled
if (this.contextManager) {
this.contextManager.addMessage("user", input);
}
// Keyword matching
const keywordScores = {};
this.knowledgeBase.forEach((node) => {
keywordScores[node.id] = 0;
node.keyword.forEach((keyword) => {
const lowercaseKeyword = keyword.toLowerCase();
words.forEach((word) => {
if (
word.includes(lowercaseKeyword) ||
lowercaseKeyword.includes(word)
) {
keywordScores[node.id]++;
}
});
});
});
let bestMatch = null;
let maxScore = 0;
Object.entries(keywordScores).forEach(([nodeId, score]) => {
if (score > maxScore) {
maxScore = score;
bestMatch = this.knowledgeBase.find((node) => node.id === nodeId);
}
});
// Calculate confidence score (0-1 range)
// If we have a keyword match, confidence should be high enough to use it in hybrid mode
// Confidence increases with number of matching keywords
let confidence = 0;
if (maxScore > 0) {
// Base confidence of 0.5 for any match, plus 0.1 per additional match (capped at 1.0)
confidence = Math.min(0.5 + (maxScore - 1) * 0.1, 1.0);
}
// Debug logging for hybrid mode
if (this.mode === "hybrid") {
console.log("[SWC Hybrid Debug]", {
input: input,
bestMatch: bestMatch ? bestMatch.id : null,
maxScore: maxScore,
confidence: confidence,
threshold: this.hybridThreshold,
willUseAI: !bestMatch || confidence < this.hybridThreshold,
});
}
// Mode-based routing
if (this.mode === "ai-only" && this.apiClient) {
// Always use AI
return await this.handleAIResponse(input, callbacks);
} else if (this.mode === "hybrid" && this.apiClient) {
// Use AI if confidence is low or no match found
if (!bestMatch || confidence < this.hybridThreshold) {
return await this.handleAIResponse(input, callbacks);
}
} else if (this.mode === "keyword-only" || !this.apiClient) {
// Fall through to keyword response
}
// Keyword-based response
if (bestMatch) {
this.currentNode = bestMatch;
// Add bot response to history
this.addToHistory(
"bot",
bestMatch.reply,
bestMatch.id,
bestMatch.options,
"keyword"
);
// Add to context if API is enabled
if (this.contextManager) {
this.contextManager.addMessage("assistant", bestMatch.reply);
}
return {
reply: bestMatch.reply,
options: bestMatch.options,
source: "keyword",
confidence: confidence,
};
} else {
// No keyword match - try AI if available in hybrid mode
if (this.mode === "hybrid" && this.apiClient) {
return await this.handleAIResponse(input, callbacks);
}
// Fallback response
const fallbackReply =
"I'm sorry, I didn't understand that. Can you please rephrase?";
this.addToHistory("bot", fallbackReply, null, null, "fallback");
return {
reply: fallbackReply,
options: null,
source: "fallback",
};
}
}
/**
* Handle AI-powered response using OpenRouter API
* @param {string} input - User input
* @param {Object} callbacks - Callbacks for streaming: onStart, onChunk, onComplete, onError
* @returns {Promise<Object>} Response object
*/
async handleAIResponse(input, callbacks = {}) {
if (!this.apiClient) {
console.error("[SWC] API client not initialized");
return {
reply: "AI features are not configured properly.",
options: null,
source: "error",
};
}
if (this.aiResponseInProgress) {
console.warn("[SWC] AI response already in progress");
return {
reply: "Please wait for the current response to complete.",
options: null,
source: "error",
};
}
this.aiResponseInProgress = true;
try {
// Get conversation context
if (!this.contextManager) {
throw new Error("Context manager not initialized");
}
const messages = this.contextManager.getContext(true);
// onStart callback will be triggered on first chunk, not here
let fullResponse = "";
let onStartCalled = false;
// Send message with streaming
const result = await this.apiClient.sendMessage(
messages,
// onChunk callback
(chunk) => {
// Trigger onStart on first chunk (when streaming actually begins)
if (!onStartCalled && callbacks.onStart) {
callbacks.onStart();
onStartCalled = true;
}
fullResponse = chunk.fullContent;
if (callbacks.onChunk) {
callbacks.onChunk(chunk);
}
},
// onComplete callback
(response) => {
// Add AI response to history
const modelInfo = this.apiClient.getModelInfo();
this.addToHistory(
"bot",
response.content,
null,
null,
"api",
modelInfo
);
// Add to context
if (this.contextManager) {
this.contextManager.addMessage("assistant", response.content);
}
if (callbacks.onComplete) {
callbacks.onComplete(response);
}
},
// onError callback
(error) => {
console.error("[SWC] AI response error:", error);
if (callbacks.onError) {
callbacks.onError(error);
}
}
);
this.aiResponseInProgress = false;
return {
reply: result.content,
options: null,
source: "api",
model: result.model,
};
} catch (error) {
this.aiResponseInProgress = false;
console.error("[SWC] Error in handleAIResponse:", error);
// Add error to history
const errorMessage = this._getErrorMessage(error);
this.addToHistory("bot", errorMessage, null, null, "error");
if (callbacks.onError) {
callbacks.onError(error);
}
return {
reply: errorMessage,
options: null,
source: "error",
};
}
}
/**
* Cancel ongoing AI response
*/
cancelAIResponse() {
if (this.apiClient && this.aiResponseInProgress) {
this.apiClient.cancel();
this.aiResponseInProgress = false;
return true;
}
return false;
}
/**
* Get user-friendly error message
* @private
*/
_getErrorMessage(error) {
if (error.message.includes("Invalid API key")) {
return "⚠️ API authentication failed. Please check your API key configuration.";
} else if (error.message.includes("Rate limit")) {
return "⚠️ Too many requests. Please wait a moment and try again.";
} else if (error.message.includes("cancelled")) {
return "Response cancelled.";
} else if (error.message.includes("service is temporarily unavailable")) {
return "⚠️ The AI service is temporarily unavailable. Please try again later.";
} else {
return `⚠️ An error occurred: ${error.message}`;
}
}
/**
* Enhance prompt with knowledge base (RAG approach)
* @private
*/
_enhancePromptWithKnowledge(input) {
// Find relevant knowledge base entries
const relevantNodes = [];
const lowercaseInput = input.toLowerCase();
this.knowledgeBase.forEach((node) => {
node.keyword.forEach((keyword) => {
if (lowercaseInput.includes(keyword.toLowerCase())) {
relevantNodes.push(node);
}
});
});
if (relevantNodes.length > 0) {
const knowledge = relevantNodes
.map((node) => `Topic: ${node.id}\nInformation: ${node.reply}`)
.join("\n\n");
this.contextManager.injectKnowledge(knowledge);
}
}
/**
* Get API configuration and status
* @returns {Object} API status information
*/
getAPIStatus() {
return {
enabled: !!this.apiClient,
mode: this.mode,
streaming: this.streamingEnabled,
model: this.apiClient ? this.apiClient.getModelInfo() : null,
contextStats: this.contextManager ? this.contextManager.getStats() : null,
responseInProgress: this.aiResponseInProgress,
};
}
handleOptionSelection(replyId) {
const nextNode = this.knowledgeBase.find((node) => node.id === replyId);
if (nextNode) {
this.currentNode = nextNode;
// Add bot response to history
this.addToHistory("bot", nextNode.reply, nextNode.id, nextNode.options);
return {
reply: nextNode.reply,
options: nextNode.options,
};
} else {
const fallbackReply =
"I'm sorry, I couldn't find the appropriate response. How else can I assist you?";
// Add fallback response to history
this.addToHistory("bot", fallbackReply, null, null);
return {
reply: fallbackReply,
options: null,
};
}
}
// Phase 1.2: Export History
exportHistory() {
const historyData = {
version: "2.0", // Updated version for API support
timestamp: new Date().toISOString(),
botName: this.botMetadata.botName,
themeColor: this.botMetadata.themeColor,
messages: this.chatHistory,
currentNodeId: this.currentNode ? this.currentNode.id : null,
// API metadata
mode: this.mode,
apiEnabled: !!this.apiClient,
apiConfig: this.apiClient
? {
model: this.apiClient.model,
lastUsed: new Date().toISOString(),
}
: null,
};
return JSON.stringify(historyData, null, 2);
}
getCurrentState() {
return {
currentNodeId: this.currentNode ? this.currentNode.id : null,
messageCount: this.chatHistory.length,
lastMessageTimestamp:
this.chatHistory.length > 0
? this.chatHistory[this.chatHistory.length - 1].timestamp
: null,
};
}
// Phase 1.3: Load History
loadHistory(historyData) {
try {
// Parse if string, use directly if object
const data =
typeof historyData === "string" ? JSON.parse(historyData) : historyData;
// Validate structure
if (!data.version || !data.messages || !Array.isArray(data.messages)) {
throw new Error("Invalid history format: missing required fields");
}
// Check version compatibility
if (!data.version.startsWith("1.") && !data.version.startsWith("2.")) {
console.warn(
`History version ${data.version} may not be fully compatible`
);
}
// Update bot metadata if present
if (data.botName) this.botMetadata.botName = data.botName;
if (data.themeColor) this.botMetadata.themeColor = data.themeColor;
// Restore chat history
this.chatHistory = data.messages;
// Restore current node state
if (data.currentNodeId) {
const node = this.knowledgeBase.find(
(n) => n.id === data.currentNodeId
);
if (node) {
this.currentNode = node;
}
}
// Restore API context if available
if (this.contextManager && data.messages) {
this.contextManager.clear();
data.messages.forEach((msg) => {
if (msg.type === "user") {
this.contextManager.addMessage("user", msg.content);
} else if (msg.type === "bot") {
this.contextManager.addMessage("assistant", msg.content);
}
});
}
return {
success: true,
messageCount: this.chatHistory.length,
messages: this.chatHistory,
};
} catch (error) {
console.error("Error loading history:", error);
return {
success: false,
error: error.message,
messages: [],
};
}
}
// Phase 1.4: Clear History
clearHistory() {
this.chatHistory = [];
this.currentNode =
this.knowledgeBase.find((node) => node.id === "welcome") ||
this.knowledgeBase[0];
// Add welcome message to fresh history
if (this.currentNode) {
this.addToHistory(
"bot",
this.currentNode.reply,
this.currentNode.id,
this.currentNode.options
);
}
return {
reply: this.currentNode ? this.currentNode.reply : "",
options: this.currentNode ? this.currentNode.options : null,
};
}
// Phase 4.1: Get History (returns object not string)
getHistory() {
return {
version: "2.0",
timestamp: new Date().toISOString(),
botName: this.botMetadata.botName,
themeColor: this.botMetadata.themeColor,
messages: this.chatHistory,
currentNodeId: this.currentNode ? this.currentNode.id : null,
mode: this.mode,
apiEnabled: !!this.apiClient,
};
}
}
// Default knowledge base
const defaultKnowledgeBase = [
{
id: "welcome",
keyword: ["hello", "hi", "hey"],
reply:
'Welcome! How can I assist you <b>today?</b> <a href="https://senangwebs.com">senangwebs.com</a>',
options: [
{ label: "Get Help", reply_id: "help" },
{ label: "End Chat", reply_id: "goodbye" },
],
},
{
id: "help",
keyword: ["help", "support", "assist"],
reply: "Sure, I can help! What do you need assistance with?",
options: [
{ label: "Product Information", reply_id: "product" },
{ label: "Billing", reply_id: "billing" },
{ label: "Technical Support", reply_id: "tech_support" },
],
},
{
id: "product",
keyword: ["product", "information"],
reply:
"Our product is designed to make your life easier. Would you like to know more about its features or pricing?",
options: [
{ label: "Features", reply_id: "features" },
{ label: "Pricing", reply_id: "pricing" },
],
},
{
id: "billing",
keyword: ["billing", "payment", "invoice"],
reply:
"For billing inquiries, please visit our billing portal or contact our finance department at billing@example.com.",
options: [
{ label: "Back to Help", reply_id: "help" },
{ label: "End Chat", reply_id: "goodbye" },
],
},
{
id: "tech_support",
keyword: ["technical", "support", "issue"],
reply:
"For technical support, please describe your issue in detail and well do our best to assist you.",
},
{
id: "features",
keyword: ["features", "functionality"],
reply:
"Our product offers cutting-edge features including AI-powered analytics, real-time collaboration, and seamless integration with popular tools.",
options: [
{ label: "Back to Product Info", reply_id: "product" },
{ label: "End Chat", reply_id: "goodbye" },
],
},
{
id: "pricing",
keyword: ["pricing", "cost", "plans"],
reply:
"We offer flexible pricing plans starting at $9.99/month. For detailed pricing information, please visit our website or contact our sales team.",
options: [
{ label: "Back to Product Info", reply_id: "product" },
{ label: "End Chat", reply_id: "goodbye" },
],
},
{
id: "goodbye",
keyword: ["bye", "goodbye", "end"],
reply: "Thank you for chatting with us. Have a great day!",
options: [{ label: "Restart Chat", reply_id: "welcome" }],
},
];
function createChatbotUI(
containerElement,
themeColor,
botName,
chatDisplayStyle
) {
const chatDisplay = document.createElement("div");
chatDisplay.className = `swc-chat-display ${
chatDisplayStyle === "modern" ? "swc-modern" : "swc-classic"
}`;
const inputContainer = document.createElement("div");
inputContainer.className = "swc-input-container";
const userInput = document.createElement("input");
userInput.type = "text";
userInput.className = "swc-user-input";
userInput.placeholder = "Type your message...";
const sendButton = document.createElement("button");
sendButton.className = "swc-send-button";
sendButton.textContent = "Send";
const optionsContainer = document.createElement("div");
optionsContainer.className = "swc-options-container";
inputContainer.appendChild(userInput);
inputContainer.appendChild(sendButton);
const typingIndicator = document.createElement("div");
typingIndicator.className = "swc-typing-indicator";
typingIndicator.innerHTML = "<span></span><span></span><span></span>";
containerElement.appendChild(chatDisplay);
containerElement.appendChild(optionsContainer);
containerElement.appendChild(inputContainer);
// Apply theme color and bot name
containerElement.style.setProperty("--swc-theme-color", themeColor);
containerElement.style.setProperty("--swc-bot-name", `"${botName}"`);
return {
chatDisplay,
userInput,
sendButton,
optionsContainer,
typingIndicator,
inputContainer,
};
}
function initializeChatbot(customKnowledgeBase = null) {
const chatbotElements = document.querySelectorAll("[data-swc]");
chatbotElements.forEach((element) => {
// Prevent double initialization
if (element.chatbotInstance) return;
// Skip if manual initialization is requested and we are in auto-init mode (no custom KB)
if (!customKnowledgeBase && element.hasAttribute("data-swc-manual-init")) {
return;
}
const themeColor =
element.getAttribute("data-swc-theme-color") || "#007bff";
const botName = element.getAttribute("data-swc-bot-name") || "Bot";
const chatDisplayStyle =
element.getAttribute("data-swc-chat-display") || "classic";
const replyDuration =
parseInt(element.getAttribute("data-swc-reply-duration")) || 0;
const loadHistory = element.getAttribute("data-swc-load");
// Parse API configuration from data attributes
const apiMode = element.getAttribute("data-swc-api-mode");
const apiKey = element.getAttribute("data-swc-api-key");
const apiModel = element.getAttribute("data-swc-api-model");
const apiStreaming = element.getAttribute("data-swc-api-streaming");
const apiMaxTokens = element.getAttribute("data-swc-api-max-tokens");
const apiTemperature = element.getAttribute("data-swc-api-temperature");
const systemPrompt = element.getAttribute("data-swc-system-prompt");
const contextMaxMessages = element.getAttribute(
"data-swc-context-max-messages"
);
const apiBaseURL = element.getAttribute("data-swc-api-base-url");
const hybridThreshold = element.getAttribute("data-swc-hybrid-threshold");
// Build API config object if API key OR custom base URL is provided
// (proxy setups use custom base URL and don't need client-side API key)
let apiConfig = null;
if ((apiKey || apiBaseURL) && apiMode !== "keyword-only") {
apiConfig = {
apiKey: apiKey || "proxy-mode", // Use placeholder for proxy mode
mode: apiMode || "hybrid",
model: apiModel || "openai/gpt-3.5-turbo",
streaming: apiStreaming !== "false",
maxTokens: parseInt(apiMaxTokens) || 500,
temperature: parseFloat(apiTemperature) || 0.7,
systemPrompt: systemPrompt || "You are a helpful assistant.",
contextMaxMessages: parseInt(contextMaxMessages) || 10,
baseURL: apiBaseURL,
hybridThreshold: parseFloat(hybridThreshold) || 0.3,
siteName: botName,
siteUrl: window.location.origin,
};
// Show warning about client-side API key only if directly using OpenRouter
if (apiKey && (!apiBaseURL || apiBaseURL.includes("openrouter.ai"))) {
console.warn(
"[SWC] ⚠️ API key is exposed in client-side code. For production, use a server-side proxy."
);
}
}
// Phase 2.1: Create chatbot instance with metadata and API config
const chatbot = new SenangWebsChatbot(
customKnowledgeBase || defaultKnowledgeBase,
{ botName, themeColor },
apiConfig
);
// Phase 2.1: Store instance on element for external access
element.chatbotInstance = chatbot;
const {
chatDisplay,
userInput,
sendButton,
optionsContainer,
typingIndicator,
inputContainer,
} = createChatbotUI(element, themeColor, botName, chatDisplayStyle);
// HTML sanitization helper to prevent XSS attacks
function escapeHTML(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
// Check if content appears to be safe HTML (from bot responses)
function isSafeHTML(content) {
// Bot replies from knowledge base may contain safe HTML like <b>, <a>, etc.
// We'll allow content that doesn't contain script tags or event handlers
const dangerousPatterns = /<script|javascript:|on\w+\s*=/i;
return !dangerousPatterns.test(content);
}
// Phase 2.3: Render message helper function
function renderMessage(message) {
const messageElement = document.createElement("div");
messageElement.className = `swc-message swc-${message.type}-message`;
// Sanitize user messages, allow safe HTML in bot messages
if (message.type === "user") {
messageElement.textContent = message.content;
} else if (isSafeHTML(message.content)) {
messageElement.innerHTML = message.content;
} else {
messageElement.textContent = message.content;
}
chatDisplay.appendChild(messageElement);
}
// Phase 2.3: Clear display helper
function clearDisplay() {
chatDisplay.innerHTML = "";
optionsContainer.innerHTML = "";
optionsContainer.style.display = "none";
}
function displayBotMessage(
message,
options,
isStreaming = false,
source = "keyword"
) {
removeTypingIndicator();
const messageElement = document.createElement("div");
messageElement.className = `swc-message swc-bot-message ${
isStreaming ? "swc-streaming" : ""
} ${source === "api" ? "swc-ai-message" : ""}`;
// Sanitize content: for API responses, escape HTML; for keyword responses, allow safe HTML
if (source === "api" || !isSafeHTML(message)) {
messageElement.textContent = message;
} else {
messageElement.innerHTML = message;
}
messageElement.setAttribute("data-message-id", `msg-${Date.now()}`);
chatDisplay.appendChild(messageElement);
smoothScrollToBottom(chatDisplay);
optionsContainer.innerHTML = "";
if (options && options.length > 0) {
optionsContainer.style.display = "flex";
options.forEach((option) => {
const button = document.createElement("button");
button.textContent = option.label;
button.onclick = () => handleOptionClick(option.reply_id);
optionsContainer.appendChild(button);
});
} else {
optionsContainer.style.display = "none";
}
return messageElement;
}
// Create stop button for AI streaming
function createStopButton() {
const stopBtn = document.createElement("button");
stopBtn.className = "swc-stop-button";
stopBtn.innerHTML = "Stop";
stopBtn.onclick = () => {
chatbot.cancelAIResponse();
stopBtn.remove();
enableUserInput();
};
return stopBtn;
}
async function handleUserInput() {
const message = userInput.value.trim();
if (message) {
const userMessageElement = document.createElement("div");
userMessageElement.className = "swc-message swc-user-message";
// Use textContent to prevent XSS from user input
userMessageElement.textContent = message;
chatDisplay.appendChild(userMessageElement);
smoothScrollToBottom(chatDisplay);
userInput.value = "";
disableUserInput();
showTypingIndicator();
// Check if this will be an AI response
const isAIEnabled =
chatbot.mode !== "keyword-only" && chatbot.apiClient;
let stopButton = null;
if (isAIEnabled && replyDuration === 0) {
// For AI responses, add delay then proceed
setTimeout(async () => {
// Typing indicator stays visible until onStart is triggered
// Create streaming message element
let streamingMessage = null;
let streamStopButton = null;
const response = await chatbot.handleInput(message, {
onStart: () => {
// Remove typing indicator now that streaming is starting
removeTypingIndicator();
// Create message element for streaming
streamingMessage = displayBotMessage("", null, true, "api");
// Add stop button if streaming
if (chatbot.streamingEnabled) {
streamStopButton = createStopButton();
inputContainer.insertBefore(
streamStopButton,
inputContainer.firstChild
);
}
},
onChunk: (chunk) => {
// Update streaming message with escaped content to prevent XSS
if (streamingMessage) {
requestAnimationFrame(() => {
streamingMessage.textContent = chunk.fullContent;
smoothScrollToBottom(chatDisplay);
});
}
},
onComplete: (result) => {
// Remove streaming class
if (streamingMessage) {
streamingMessage.classList.remove("swc-streaming");
}
// Remove stop button
if (streamStopButton) {
streamStopButton.remove();
}
enableUserInput();
},
onError: (error) => {
// Remove stop button
if (streamStopButton) {
streamStopButton.remove();
}
// Display error
if (streamingMessage) {
streamingMessage.classList.remove("swc-streaming");
streamingMessage.classList.add("swc-error-message");
}
enableUserInput();
},
});
// If not streaming or error occurred, display normally
if (!streamingMessage) {
displayBotMessage(
response.reply,
response.options,
false,
response.source
);
enableUserInput();
}
}, 500);
} else {
// Keyword-only mode or delay is set
setTimeout(async () => {
const response = await chatbot.handleInput(message);
displayBotMessage(
response.reply,
response.options,
false,
response.source
);
enableUserInput();
}, replyDuration);
}
}
}
function handleOptionClick(replyId) {
disableUserInput();
showTypingIndicator();
// Use minimum 500ms delay for option clicks to show typing indicator
const optionDelay = Math.max(replyDuration, 500);
setTimeout(() => {
const response = chatbot.handleOptionSelection(replyId);
displayBotMessage(response.reply, response.options);
enableUserInput();
}, optionDelay);
}
function disableUserInput() {
userInput.disabled = true;
sendButton.disabled = true;
}
function enableUserInput() {
userInput.disabled = false;
sendButton.disabled = false;
}
function showTypingIndicator() {
removeTypingIndicator(); // Remove any existing indicator first
chatDisplay.appendChild(typingIndicator);
smoothScrollToBottom(chatDisplay);
}
function removeTypingIndicator() {
if (typingIndicator.parentNode === chatDisplay) {
chatDisplay.removeChild(typingIndicator);
}
}
function smoothScrollToBottom(element) {
const targetScrollTop = element.scrollHeight - element.clientHeight;
const startScrollTop = element.scrollTop;
const distance = targetScrollTop - startScrollTop;
const duration = 300; // ms
let start = null;
function step(timestamp) {
if (!start) start = timestamp;
const progress = timestamp - start;
element.scrollTop = easeInOutCubic(
progress,
startScrollTop,
distance,
duration
);
if (progress < duration) {
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);
}
function easeInOutCubic(t, b, c, d) {
t /= d / 2;
if (t < 1) return (c / 2) * t * t * t + b;
t -= 2;
return (c / 2) * (t * t * t + 2) + b;
}
sendButton.addEventListener("click", handleUserInput);
userInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") {
handleUserInput();
}
});
// Phase 4.2: Enhanced clearHistory with UI update and event
const originalClearHistory = chatbot.clearHistory.bind(chatbot);
chatbot.clearHistory = function () {
const result = originalClearHistory();
clearDisplay();
displayBotMessage(result.reply, result.options);
// Dispatch custom event
element.dispatchEvent(
new CustomEvent("swc:history-cleared", {
detail: { timestamp: new Date().toISOString() },
})
);
return result;
};
// Phase 4.2: Enhanced exportHistory with event
const originalExportHistory = chatbot.exportHistory.bind(chatbot);
chatbot.exportHistory = function () {
const historyJSON = originalExportHistory();
// Dispatch custom event
element.dispatchEvent(
new CustomEvent("swc:history-exported", {
detail: {
messageCount: chatbot.chatHistory.length,
historyJSON: historyJSON,
timestamp: new Date().toISOString(),
},
})
);
return historyJSON;
};
// Phase 4.2: Enhanced loadHistory with UI update and event
const originalLoadHistory = chatbot.loadHistory.bind(chatbot);
chatbot.loadHistory = function (historyData) {
const result = originalLoadHistory(historyData);
if (result.success) {
clearDisplay();
// Render all messages from history
result.messages.forEach((msg) => {
renderMessage(msg);
});
// Render options from last message if present
if (result.messages.length > 0) {
const lastMessage = result.messages[result.messages.length - 1];
if (lastMessage.options && lastMessage.options.length > 0) {
optionsContainer.style.display = "flex";
lastMessage.options.forEach((option) => {
const button = document.createElement("button");
button.textContent = option.label;
button.onclick = () => handleOptionClick(option.reply_id);
optionsContainer.appendChild(button);
});
}
}
smoothScrollToBottom(chatDisplay);
// Dispatch custom event
element.dispatchEvent(
new CustomEvent("swc:history-loaded", {
detail: {
messageCount: result.messageCount,
timestamp: new Date().toISOString(),
},
})
);
} else {
console.error("Failed to load history:", result.error);
}
return result;
};
// Phase 3: Declarative History Loading
if (loadHistory) {
// Check if it's a URL/file path or JSON string
const isUrl =
loadHistory.startsWith("http://") ||
loadHistory.startsWith("https://") ||
loadHistory.startsWith("./") ||
loadHistory.startsWith("../") ||
loadHistory.endsWith(".json");
if (isUrl) {
// Phase 3.2: Load from external file
fetch(loadHistory)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((data) => {
chatbot.loadHistory(data);
})
.catch((error) => {
console.error("Error loading history from file:", error);
// Fallback to default initialization
const initialResponse = chatbot.init();
displayBotMessage(initialResponse.reply, initialResponse.options);
});
} else {
// Phase 3.3: Load from inline JSON string
try {
const data = JSON.parse(loadHistory);
chatbot.loadHistory(data);
} catch (error) {
console.error("Error parsing inline history JSON:", error);
// Fallback to default initialization
const initialResponse = chatbot.init();
displayBotMessage(initialResponse.reply, initialResponse.options);
}
}
} else {
// Initialize the chatbot normally
const initialResponse = chatbot.init();
displayBotMessage(initialResponse.reply, initialResponse.options);
}
});
}
// Export the main class and functions
export { SenangWebsChatbot, initializeChatbot, defaultKnowledgeBase };
// Make initializeChatbot globally accessible
if (typeof window !== "undefined") {
window.initializeChatbot = initializeChatbot;
// Auto-initialize on DOMContentLoaded
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
initializeChatbot();
});
} else {
// DOM already loaded
initializeChatbot();
}
}