UNPKG

@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
/** * 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">&times;</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(); } }