@spectrumsense/spectrum-chat-dev
Version:
Embeddable AI Widget - Add trusted, evidence-based answers directly to your website. Simple installation, enterprise-grade security.
790 lines (676 loc) • 21.1 kB
JavaScript
/**
* Spectrum Chat v2 - AI Chat Widget
* Built on DeepChat foundation
*
* One-line installation:
* <script src="https://unpkg.com/@spectrumsense/spectrum-chat@2.0.0/dist/spectrum-chat.js"
* data-site-key="pub_customer_xyz"></script>
*
* Traditional installation:
* <script>
* window.SpectrumChatConfig = { siteKey: 'pub_customer_xyz', title: 'Help' };
* </script>
* <script src="https://unpkg.com/@spectrumsense/spectrum-chat@2.0.0/dist/spectrum-chat.js"></script>
*/
console.log('Spectrum Chat v2 loaded');
// Capture script location immediately (currentScript becomes null after execution)
const SCRIPT_SRC = document.currentScript ? document.currentScript.src : '';
const SCRIPT_BASE_PATH = SCRIPT_SRC ? SCRIPT_SRC.substring(0, SCRIPT_SRC.lastIndexOf('/') + 1) : './';
console.log('Script loaded from:', SCRIPT_BASE_PATH);
// ========================================
// Configuration
// ========================================
// Default configuration
const defaultConfig = {
apiUrl: '{{API_URL}}',
siteKey: '{{SITE_KEY}}',
useJWT: true,
tenantId: '', // Backward compatibility
title: 'AI Assistant',
introText: 'Hello! How can I help you today?',
primaryColor: '#2c3e50',
userColor: '#3498db',
aiColor: '#2c3e50',
fabIcon: '💬',
position: 'bottom-right',
width: '400px',
height: '600px',
enableCitations: true,
debug: false
};
// Global state (singleton per site-key)
const state = {
isInitialized: false,
widget: null,
deepChat: null,
conversationId: null,
tokenData: null,
messages: [],
isOpen: false
};
// Parse configuration from script tag data-* attributes
function parseScriptConfig() {
const script = document.currentScript;
if (!script) return {};
const config = {};
for (const attr of script.attributes) {
if (attr.name.startsWith('data-')) {
const key = attr.name
.replace('data-', '')
.replace(/-([a-z])/g, (_, l) => l.toUpperCase());
config[key] = parseValue(attr.value);
}
}
return config;
}
function parseValue(value) {
if (value === 'true') return true;
if (value === 'false') return false;
if (!isNaN(value) && value !== '') return Number(value);
return value;
}
// Merge configurations: defaults < window.SpectrumChatConfig < script data-*
function getConfig() {
return {
...defaultConfig,
...(window.SpectrumChatConfig || {}),
...parseScriptConfig()
};
}
// ========================================
// Security Utilities
// ========================================
/**
* Generate SHA256 hash of current page URL for telemetry
*/
async function hashPageUrl() {
try {
const encoder = new TextEncoder();
const data = encoder.encode(window.location.href);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (error) {
console.warn('Failed to hash page URL:', error);
return '';
}
}
/**
* Generate UUID v4 nonce for request tracking
*/
function generateNonce() {
try {
return crypto.randomUUID();
} catch (error) {
// Fallback for older browsers
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
}
/**
* Check if JWT token is valid (not expired, with 5-minute buffer)
*/
function isTokenValid(tokenData) {
if (!tokenData || !tokenData.expires_at) {
return false;
}
try {
const expiresAt = new Date(tokenData.expires_at);
const now = new Date();
const bufferMs = 5 * 60 * 1000; // 5 minutes
return expiresAt.getTime() - now.getTime() > bufferMs;
} catch (error) {
console.warn('Failed to validate token:', error);
return false;
}
}
/**
* Check if error is due to token expiration
*/
function isTokenExpiredError(error) {
if (!error) return false;
const status = error?.response?.status || error?.status;
const errorMsg = error?.response?.data?.error || error?.error || '';
return (
status === 401 ||
errorMsg.includes('expired') ||
errorMsg.includes('Invalid token') ||
errorMsg.includes('token')
);
}
/**
* Create session token (JWT)
*/
async function createSession(config) {
if (!config.siteKey) {
throw new Error('Site key is required for session creation');
}
const apiUrl = config.apiUrl.replace(/\/conversations.*$/, '/sessions');
if (config.debug) {
console.log('Creating session with API URL:', apiUrl);
}
try {
const requestBody = {
siteKey: config.siteKey,
pageUrlHash: await hashPageUrl(),
nonce: generateNonce()
};
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (config.debug) {
console.log('Session created:', {
session_id: data.session_id,
expires_at: data.expires_at
});
}
return {
token: data.token,
session_id: data.session_id,
expires_at: data.expires_at
};
} catch (error) {
console.error('Failed to create session:', error);
throw error;
}
}
/**
* Initialize or refresh JWT session token
*/
async function initializeSession(config) {
if (!config.useJWT) {
return;
}
if (state.tokenData && isTokenValid(state.tokenData)) {
if (config.debug) {
console.log('Using existing valid token');
}
return;
}
try {
if (config.debug) {
console.log('Token expired or missing, creating new session...');
}
state.tokenData = await createSession(config);
sessionStorage.setItem('spectrum-chat-token', JSON.stringify(state.tokenData));
} catch (error) {
console.error('Failed to initialize session:', error);
throw error;
}
}
/**
* Load persisted data from storage
*/
function loadPersistedData() {
try {
const convId = sessionStorage.getItem('spectrum-chat-conversation-id');
if (convId) {
state.conversationId = convId;
}
const tokenStr = sessionStorage.getItem('spectrum-chat-token');
if (tokenStr) {
state.tokenData = JSON.parse(tokenStr);
}
const messagesStr = sessionStorage.getItem('spectrum-chat-messages');
if (messagesStr) {
state.messages = JSON.parse(messagesStr);
}
} catch (error) {
console.warn('Failed to load persisted data:', error);
}
}
// ========================================
// DeepChat Integration
// ========================================
/**
* Load DeepChat library from local bundle
*/
function loadDeepChat() {
return new Promise((resolve, reject) => {
if (window.customElements.get('deep-chat')) {
resolve();
return;
}
const script = document.createElement('script');
script.type = 'module';
// Use the captured base path (from top of file when currentScript was still valid)
const deepChatPath = SCRIPT_BASE_PATH + 'deep-chat/deepChat.bundle.js';
script.src = deepChatPath;
console.log('Loading DeepChat from:', deepChatPath);
script.onload = () => {
console.log('DeepChat loaded successfully from local bundle');
resolve();
};
script.onerror = (err) => {
console.error('Failed to load DeepChat from local bundle:', err);
console.error('Tried loading from:', script.src);
reject(new Error('Failed to load DeepChat'));
};
document.head.appendChild(script);
});
}
/**
* Create widget container with fixed positioning
*/
function createWidgetContainer(config) {
const container = document.createElement('div');
container.id = 'spectrum-chat-widget';
container.className = 'spectrum-chat-root';
const positionStyles = {
'bottom-right': 'bottom: 20px; right: 20px;',
'bottom-left': 'bottom: 20px; left: 20px;',
'top-right': 'top: 20px; right: 20px;',
'top-left': 'top: 20px; left: 20px;'
};
const positionStyle = positionStyles[config.position] || positionStyles['bottom-right'];
container.innerHTML = `
<style>
.spectrum-chat-root {
position: fixed;
${positionStyle}
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.spectrum-chat-fab {
width: 60px;
height: 60px;
border-radius: 50%;
background: ${config.primaryColor};
color: white;
border: none;
font-size: 28px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
}
.spectrum-chat-fab:hover {
transform: scale(1.1);
}
.spectrum-chat-panel {
position: fixed;
${positionStyle}
width: ${config.width};
height: ${config.height};
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
display: none;
flex-direction: column;
overflow: hidden;
border: 1px solid #e5e7eb;
}
.spectrum-chat-panel.open {
display: flex;
animation: spectrum-fade-up 0.3s ease-out;
}
@keyframes spectrum-fade-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.spectrum-chat-header {
background: ${config.primaryColor};
color: white;
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.spectrum-chat-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.spectrum-chat-close {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}
.spectrum-chat-close:hover {
background: rgba(255, 255, 255, 0.2);
}
#spectrum-deepchat-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Mobile responsive - center bottom */
@media (max-width: 768px) {
.spectrum-chat-root {
left: 50% !important;
right: auto !important;
bottom: 10px !important;
top: auto !important;
transform: translateX(-50%);
}
.spectrum-chat-panel {
width: calc(100vw - 20px) !important;
height: 70vh !important;
max-height: 600px;
left: 10px !important;
right: 10px !important;
bottom: 10px !important;
top: auto !important;
}
.spectrum-chat-fab {
left: 50% !important;
right: auto !important;
bottom: 10px !important;
top: auto !important;
transform: translateX(-50%);
}
}
</style>
<button class="spectrum-chat-fab" aria-label="Open chat">
${config.fabIcon}
</button>
<div class="spectrum-chat-panel">
<div class="spectrum-chat-header">
<h3 class="spectrum-chat-title">${config.title}</h3>
<button class="spectrum-chat-close" aria-label="Close">×</button>
</div>
<div id="spectrum-deepchat-container"></div>
</div>
`;
return container;
}
/**
* Create request interceptor for DeepChat
*/
function createRequestInterceptor(config) {
return async (requestDetails) => {
try {
// Initialize JWT session if enabled
await initializeSession(config);
// Build API URL with conversation ID
let apiUrl = config.apiUrl;
const isNewConversation = !state.conversationId;
if (state.conversationId) {
apiUrl = apiUrl.replace(/\/conversations\/?$/, `/conversations/${state.conversationId}`);
} else {
apiUrl = apiUrl.replace(/\/conversations\/.*$/, '/conversations');
}
// Extract message from DeepChat request
const userMessage = requestDetails.body?.messages?.[0]?.text || '';
// Build request body
const body = {
message: userMessage,
citations: config.enableCitations
};
// Add site-key for new conversations (Phase 0 security)
if (isNewConversation) {
body.siteKey = config.siteKey;
body.pageUrlHash = await hashPageUrl();
body.nonce = generateNonce();
}
// Backward compatibility: include tenant_id if provided
if (config.tenantId) {
body.tenant_id = config.tenantId;
}
// Build headers
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
// Add JWT token if enabled (Phase 1 security)
if (config.useJWT && state.tokenData?.token) {
headers['Authorization'] = `Bearer ${state.tokenData.token}`;
}
if (config.debug) {
console.log('Request interceptor:', { url: apiUrl, body, headers });
}
return {
url: apiUrl,
method: 'POST',
headers,
body
};
} catch (error) {
console.error('Request interceptor error:', error);
throw error;
}
};
}
/**
* Create response interceptor for DeepChat
*/
function createResponseInterceptor(config) {
return async (response) => {
try {
// Handle response based on type
let responseData;
if (response instanceof Response) {
responseData = await response.json();
} else {
responseData = response;
}
if (config.debug) {
console.log('Response interceptor:', responseData);
}
// Save conversation ID
if (responseData.conversation_id) {
state.conversationId = responseData.conversation_id;
sessionStorage.setItem('spectrum-chat-conversation-id', responseData.conversation_id);
}
// Process citations
let text = responseData.text || responseData.message || '';
if (config.enableCitations && responseData.sources) {
responseData.sources.forEach(source => {
if (source.index && source.url) {
const link = `<a href="${source.url}" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;" title="${source.title || ''}">[${source.index}]</a>`;
text = text.replace(new RegExp(`\\[${source.index}\\]`, 'g'), link);
}
});
}
// Save message to history
state.messages.push({
role: 'assistant',
text: text,
timestamp: Date.now()
});
sessionStorage.setItem('spectrum-chat-messages', JSON.stringify(state.messages));
return { text };
} catch (error) {
console.error('Response interceptor error:', error);
return { text: 'Sorry, an error occurred. Please try again.' };
}
};
}
/**
* Initialize DeepChat instance
*/
async function initializeDeepChat(container, config) {
const deepChatContainer = container.querySelector('#spectrum-deepchat-container');
const deepChat = document.createElement('deep-chat');
// DeepChat styling
deepChat.style.width = '100%';
deepChat.style.height = '100%';
deepChat.style.border = 'none';
deepChat.style.borderRadius = '0 0 12px 12px';
// Apply message bubble colors
deepChat.messageStyles = {
default: {
shared: {
bubble: {
maxWidth: '85%',
borderRadius: '12px',
padding: '10px 14px',
fontSize: '0.9rem',
lineHeight: '1.4'
}
},
user: {
bubble: {
backgroundColor: config.userColor,
color: 'white'
}
},
ai: {
bubble: {
backgroundColor: config.aiColor,
color: 'white'
}
}
}
};
// Enable HTML rendering for citations and markdown
deepChat.htmlClassUtilities = {
'deep-chat-temporary-message': true
};
// Set initial messages
if (config.introText && state.messages.length === 0) {
deepChat.initialMessages = [{
role: 'ai',
text: config.introText
}];
}
// Setup request/response interceptors
deepChat.requestInterceptor = createRequestInterceptor(config);
deepChat.responseInterceptor = createResponseInterceptor(config);
deepChatContainer.appendChild(deepChat);
return deepChat;
}
// ========================================
// UI Logic
// ========================================
function openChat() {
if (!state.widget) return;
const panel = state.widget.querySelector('.spectrum-chat-panel');
const fab = state.widget.querySelector('.spectrum-chat-fab');
panel.classList.add('open');
fab.style.display = 'none';
state.isOpen = true;
document.dispatchEvent(new CustomEvent('spectrum-chat-opened', {
detail: { isOpen: true }
}));
}
function closeChat() {
if (!state.widget) return;
const panel = state.widget.querySelector('.spectrum-chat-panel');
const fab = state.widget.querySelector('.spectrum-chat-fab');
panel.classList.remove('open');
fab.style.display = 'flex';
state.isOpen = false;
document.dispatchEvent(new CustomEvent('spectrum-chat-closed', {
detail: { isOpen: false }
}));
}
/**
* Initialize widget
*/
async function initialize() {
if (state.isInitialized) {
console.log('Spectrum Chat already initialized');
return;
}
const config = getConfig();
// Validate required config
if (!config.siteKey) {
console.error('Spectrum Chat: site-key is required');
return;
}
if (config.debug) {
console.log('Initializing Spectrum Chat with config:', config);
}
try {
// Load persisted data
loadPersistedData();
// Load DeepChat library
await loadDeepChat();
// Create widget container
const container = createWidgetContainer(config);
document.body.appendChild(container);
// Setup event handlers
const fab = container.querySelector('.spectrum-chat-fab');
const panel = container.querySelector('.spectrum-chat-panel');
const closeBtn = container.querySelector('.spectrum-chat-close');
fab.addEventListener('click', () => openChat());
closeBtn.addEventListener('click', () => closeChat());
// Click outside to close
document.addEventListener('click', (e) => {
if (state.isOpen && !container.contains(e.target)) {
closeChat();
}
});
// Initialize DeepChat
const deepChat = await initializeDeepChat(container, config);
state.widget = container;
state.deepChat = deepChat;
state.isInitialized = true;
if (config.debug) {
console.log('Spectrum Chat initialized successfully');
}
} catch (error) {
console.error('Failed to initialize Spectrum Chat:', error);
}
}
// ========================================
// Public API
// ========================================
window.SpectrumChat = {
open: openChat,
close: closeChat,
isOpen: () => state.isOpen,
getConfig: getConfig,
getConversationId: () => state.conversationId,
getSessionId: () => state.tokenData?.session_id || null,
getTokenData: () => state.tokenData,
isTokenValid: () => state.tokenData ? isTokenValid(state.tokenData) : false,
clearSession: () => {
sessionStorage.removeItem('spectrum-chat-conversation-id');
sessionStorage.removeItem('spectrum-chat-token');
sessionStorage.removeItem('spectrum-chat-messages');
state.conversationId = null;
state.tokenData = null;
state.messages = [];
console.log('Session cleared');
},
reinitialize: () => {
state.isInitialized = false;
if (state.widget) {
state.widget.remove();
state.widget = null;
state.deepChat = null;
}
initialize();
}
};
// ========================================
// Auto-initialize
// ========================================
if (typeof window !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
// DOM already loaded
initialize();
}
}