UNPKG

@spectrumsense/spectrum-chat-dev

Version:

Embeddable AI Widget - Add trusted, evidence-based answers directly to your website. Simple installation, enterprise-grade security.

1,625 lines (1,407 loc) 62.6 kB
/** * Spectrum Chat - Unified Version * Supports both custom element and global script approaches * * Usage 1 - Custom Element (Plain HTML): * <script src="spectrum-chat.js"></script> * <spectrum-chat api-url="..." tenant-id="..."></spectrum-chat> * * Usage 2 - Global Script (Templates): * <script> * window.SpectrumChatConfig = { apiUrl: '...', tenantId: '...' }; * </script> * <script src="spectrum-chat.js"></script> */ console.log('Spectrum Chat Unified script loaded!'); // Global configuration with defaults // apiUrl and siteKey will be replaced during the build process based on environment const defaultConfig = { apiUrl: '{{API_URL}}', tenantId: 'brightspectrum-tenant-123', siteKey: '{{SITE_KEY}}', // Public site key for origin validation useJWT: true, // Enable JWT session tokens for enhanced security title: 'AI Assistant', introText: 'Hello! I am your AI assistant. How can I help you today?', primaryColor: 'hsl(220 15% 25%)', userColor: 'hsl(220 15% 45%)', aiColor: 'hsl(220 15% 25%)', position: 'bottom-right', width: '320px', height: '350px', showIntro: true, enableCitations: false, maxMessages: 100, fabIcon: '💬', fabColor: 'hsl(220 15% 25%)', panelBorderRadius: '1rem', panelShadow: '0 8px 32px -8px rgba(0,0,0,0.2)', debug: true }; // Global state management for global mode const globalState = { isInitialized: false, chatWidget: null, conversationId: null, tokenData: null, // JWT token data: { token, session_id, expires_at } messages: [], isOpen: false }; // Load Deep Chat dynamically if not already loaded if (typeof window !== 'undefined' && !window.DeepChat) { const script = document.createElement('script'); script.type = 'module'; script.src = 'https://unpkg.com/deep-chat@latest/dist/deepChat.bundle.js'; script.onload = () => { console.log('Deep Chat loaded internally by Spectrum Chat'); }; document.head.appendChild(script); } // ======================================== // Markdown to HTML Converter (for Global Mode) // ======================================== /** * Convert markdown text to HTML for global mode * @param {string} markdown - Markdown text * @returns {string} HTML string */ function convertMarkdownToHtml(markdown) { if (!markdown) return ''; let html = markdown; // Convert **bold** to <strong> html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); // Convert *italic* to <em> (but not if part of **) html = html.replace(/\*(?!\*)(.+?)\*(?!\*)/g, '<em>$1</em>'); // First, identify and convert bullet lists (before adding <br> tags) // Match lines starting with - or * followed by space html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '<li>$1</li>'); // Wrap consecutive <li> items in <ul> html = html.replace(/(<li>.*?<\/li>\n?)+/g, (match) => { // Remove trailing newlines inside ul const cleanedMatch = match.replace(/\n/g, ''); return '<ul>' + cleanedMatch + '</ul>'; }); // Convert double line breaks to paragraph breaks (but not around lists) html = html.replace(/\n\n+/g, '<br><br>'); // Convert single line breaks to <br> (but not around lists) html = html.replace(/\n(?!<\/?ul>)/g, '<br>'); // Clean up extra <br> tags around lists html = html.replace(/<br>\s*<ul>/g, '<ul>'); html = html.replace(/<\/ul>\s*<br>/g, '</ul>'); // Convert [link text](url) to <a> html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color: #60a5fa; text-decoration: underline;">$1</a>'); return html; } // ======================================== // Security Utilities (Phase 0 & Phase 1) // ======================================== /** * Generate SHA256 hash of current page URL for telemetry * @returns {Promise<string>} SHA256 hash or empty string if failed */ 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 * @returns {string} UUID string */ 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) * @param {Object} tokenData - Token data object with expires_at * @returns {boolean} True if token is valid */ function isTokenValid(tokenData) { if (!tokenData || !tokenData.expires_at) { return false; } try { const expiresAt = new Date(tokenData.expires_at); const now = new Date(); // Check if token expires in next 5 minutes (refresh buffer) 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 * @param {Error|Object} error - Error object or response * @returns {boolean} True if token expired */ 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 (Phase 1 - JWT) * @param {Object} config - Configuration object with apiUrl and siteKey * @returns {Promise<Object>} Token data object */ 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; } } /** * Load JWT token from storage */ function loadTokenData() { try { const stored = sessionStorage.getItem('spectrum-chat-token'); if (stored) { globalState.tokenData = JSON.parse(stored); if (defaultConfig.debug) { console.log('Loaded token data:', { session_id: globalState.tokenData?.session_id, expires_at: globalState.tokenData?.expires_at }); } } } catch (error) { console.warn('Failed to load token data:', error); } } /** * Save JWT token to storage */ function saveTokenData() { try { if (globalState.tokenData) { sessionStorage.setItem('spectrum-chat-token', JSON.stringify(globalState.tokenData)); if (defaultConfig.debug) { console.log('Saved token data:', { session_id: globalState.tokenData.session_id, expires_at: globalState.tokenData.expires_at }); } } } catch (error) { console.warn('Failed to save token data:', error); } } /** * Initialize or refresh JWT session token * @param {Object} config - Configuration object * @returns {Promise<void>} */ async function initializeSession(config) { // Phase 1 only - skip if JWT not enabled if (!config.useJWT) { return; } // Check if existing token is still valid if (globalState.tokenData && isTokenValid(globalState.tokenData)) { if (config.debug) { console.log('Using existing valid token'); } return; } // Create new session try { if (config.debug) { console.log('Token expired or missing, creating new session...'); } globalState.tokenData = await createSession(config); saveTokenData(); } catch (error) { console.error('Failed to initialize session:', error); throw error; } } /** * Handle API errors with user-friendly messages * @param {Error|Object} error - Error object * @returns {Object} Formatted error response */ function handleApiError(error) { // Network errors if (!error.response && !error.status) { return { text: 'Unable to connect to chat service. Please check your internet connection.', error: 'network_error' }; } const status = error?.response?.status || error?.status || 500; const errorData = error?.response?.data || error?.data || {}; const errorMsg = errorData?.error || error?.message || ''; // Origin validation errors (403) if (status === 403) { if (errorMsg.includes('Origin not allowed')) { return { text: 'This domain is not authorized to use this chat service.', error: 'origin_not_allowed' }; } if (errorMsg.includes('HTTPS')) { return { text: 'This chat service requires a secure connection (HTTPS).', error: 'https_required' }; } if (errorMsg.includes('Origin mismatch')) { return { text: 'Session security error. Please refresh the page.', error: 'origin_mismatch' }; } return { text: 'Access denied. Please contact support.', error: 'forbidden' }; } // Not found errors (404) if (status === 404) { if (errorMsg.includes('Conversation not found')) { return { text: 'Conversation expired. Starting a new conversation.', error: 'conversation_expired' }; } if (errorMsg.includes('site key')) { return { text: 'Chat service is currently unavailable.', error: 'invalid_site_key' }; } } // Token errors (401) if (status === 401) { return { text: 'Session expired. Please try again.', error: 'token_expired' }; } // Content moderation if (errorMsg === 'Content flagged by moderation' || errorData?.error === 'Content flagged by moderation') { return { text: errorData.text || 'Your message was flagged by content moderation. Please rephrase your question.', error: 'moderation_flagged' }; } // Server errors (500+) if (status >= 500) { return { text: 'An error occurred. Please try again later.', error: 'server_error' }; } // Default error return { text: 'An unexpected error occurred. Please try again.', error: 'unknown_error' }; } // ======================================== // End Security Utilities // ======================================== // Utility functions for global mode function loadConversationId() { try { const stored = sessionStorage.getItem('spectrum-chat-conversation-id'); if (stored) { globalState.conversationId = stored; if (defaultConfig.debug) console.log('Loaded conversation ID:', globalState.conversationId); } } catch (e) { console.warn('Failed to load conversation ID:', e); } } function saveConversationId() { try { if (globalState.conversationId) { sessionStorage.setItem('spectrum-chat-conversation-id', globalState.conversationId); if (defaultConfig.debug) console.log('Saved conversation ID:', globalState.conversationId); } } catch (e) { console.warn('Failed to save conversation ID:', e); } } function loadMessages() { try { const stored = sessionStorage.getItem('spectrum-chat-messages'); if (stored) { globalState.messages = JSON.parse(stored); if (defaultConfig.debug) console.log('Loaded messages:', globalState.messages.length); } } catch (e) { console.warn('Failed to load messages:', e); } } function saveMessages() { try { sessionStorage.setItem('spectrum-chat-messages', JSON.stringify(globalState.messages)); if (defaultConfig.debug) console.log('Saved messages:', globalState.messages.length); } catch (e) { console.warn('Failed to save messages:', e); } } // Send message to API (works for both modes) async function sendMessage(messageText, config) { if (config.debug) console.log('Sending message:', messageText); try { // Phase 1: Initialize session if JWT enabled await initializeSession(config); // Determine the API endpoint let apiUrl = config.apiUrl; let isNewConversation = false; if (globalState.conversationId) { // Continue existing conversation if (config.apiUrl.includes('/conversations')) { // Replace /conversations with /conversations/{id} apiUrl = config.apiUrl.replace(/\/conversations\/?$/, `/conversations/${globalState.conversationId}`); } else { apiUrl = `${config.apiUrl}/${globalState.conversationId}`; } } else { // Start new conversation isNewConversation = true; // Ensure URL ends with /conversations (not /conversations/{id}) apiUrl = config.apiUrl.replace(/\/conversations\/.*$/, '/conversations'); } // Build request body based on Phase 0 or Phase 1 const requestBody = { message: messageText, citations: config.enableCitations }; // Phase 0: Include siteKey for start conversation if (isNewConversation && config.siteKey) { requestBody.siteKey = config.siteKey; requestBody.pageUrlHash = await hashPageUrl(); requestBody.nonce = generateNonce(); } // Backward compatibility: include tenant_id if provided if (config.tenantId) { requestBody.tenant_id = config.tenantId; } // Build headers const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; // Phase 1: Include Authorization header if JWT enabled if (config.useJWT && globalState.tokenData?.token) { headers['Authorization'] = `Bearer ${globalState.tokenData.token}`; } if (config.debug) { console.log('API URL:', apiUrl); console.log('Request body:', requestBody); console.log('Headers:', headers); console.log('Conversation ID:', globalState.conversationId || 'None (starting new conversation)'); } const response = await fetch(apiUrl, { method: 'POST', headers: headers, body: JSON.stringify(requestBody) }); const data = await response.json(); // Check for errors in response if (!response.ok) { // Create error object with response data const error = new Error(data.error || `HTTP error! status: ${response.status}`); error.status = response.status; error.data = data; throw error; } if (config.debug) console.log('Response data:', data); // Handle conversation not found error (404) - clear stale conversation if (data.error === 'Conversation not found' || data.error?.includes('Conversation not found')) { if (config.debug) console.log('Conversation not found, clearing and retrying...'); globalState.conversationId = null; sessionStorage.removeItem('spectrum-chat-conversation-id'); // Retry with new conversation return await sendMessage(messageText, config); } // Extract conversation ID from response if (data.conversation_id && !globalState.conversationId) { globalState.conversationId = data.conversation_id; saveConversationId(); if (config.debug) console.log('New conversation started:', globalState.conversationId); } return { text: data.text || data.message || '', role: data.role || 'assistant', sources: data.sources || null, error: data.error || null }; } catch (error) { console.error('Failed to send message:', error); // Handle token expiration - refresh and retry if (isTokenExpiredError(error) && config.useJWT) { if (config.debug) console.log('Token expired, refreshing and retrying...'); globalState.tokenData = null; sessionStorage.removeItem('spectrum-chat-token'); try { await initializeSession(config); return await sendMessage(messageText, config); } catch (retryError) { console.error('Failed to retry after token refresh:', retryError); const errorResponse = handleApiError(retryError); return { text: errorResponse.text, role: 'assistant', error: errorResponse.error }; } } // Handle other errors with user-friendly messages const errorResponse = handleApiError(error); return { text: errorResponse.text, role: 'assistant', error: errorResponse.error }; } } // Custom Element Implementation class SpectrumChatElement extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.isOpen = false; this.chatElement = null; this.panelElement = null; this.fabElement = null; this.deepChatLoaded = false; } static get observedAttributes() { return [ 'api-url', 'tenant-id', 'site-key', 'use-jwt', 'title', 'intro-text', 'primary-color', 'user-color', 'ai-color', 'position', 'width', 'height', 'show-intro', 'citations', 'max-messages', 'browser-storage', 'fab-icon', 'fab-color', 'panel-border-radius', 'panel-shadow', 'enable-response-interceptor', 'custom-styles' ]; } connectedCallback() { console.log('Custom element connected!'); this.render(); // Initialize DeepChat asynchronously but don't block this.initializeDeepChat().catch(console.error); } disconnectedCallback() { this.removeEventListeners(); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue) { this.render(); this.initializeDeepChat(); this.setupEventListeners(); // Re-establish event listeners after re-rendering if (['ai-color', 'user-color', 'primary-color'].includes(name)) { setTimeout(() => { const config = this.getConfig(); this.setupMessageStyles(config); }, 200); } } } getConfig() { const config = { apiUrl: this.getAttribute('api-url') || defaultConfig.apiUrl, tenantId: this.getAttribute('tenant-id') || defaultConfig.tenantId, siteKey: this.getAttribute('site-key') || defaultConfig.siteKey, useJWT: this.getAttribute('use-jwt') === 'true' ? true : (this.getAttribute('use-jwt') === 'false' ? false : defaultConfig.useJWT), title: this.getAttribute('title') || defaultConfig.title, introText: this.getAttribute('intro-text') || defaultConfig.introText, primaryColor: this.getAttribute('primary-color') || defaultConfig.primaryColor, userColor: this.getAttribute('user-color') || defaultConfig.userColor, aiColor: this.getAttribute('ai-color') || this.getAttribute('primary-color') || defaultConfig.aiColor, position: this.getAttribute('position') || defaultConfig.position, width: this.getAttribute('width') && this.getAttribute('width') !== '' ? this.getAttribute('width') : defaultConfig.width, height: this.getAttribute('height') && this.getAttribute('height') !== '' ? this.getAttribute('height') : defaultConfig.height, showIntro: this.getAttribute('show-intro') !== 'false' ? (this.getAttribute('show-intro') === 'true' || defaultConfig.showIntro) : defaultConfig.showIntro, enableCitations: this.getAttribute('citations') === 'true' ? true : (this.getAttribute('citations') === 'false' ? false : defaultConfig.enableCitations), maxMessages: this.getAttribute('max-messages') || defaultConfig.maxMessages.toString(), browserStorage: this.getAttribute('browser-storage') === 'true' ? true : (this.getAttribute('browser-storage') === 'false' ? false : false), fabIcon: this.getAttribute('fab-icon') || defaultConfig.fabIcon, fabColor: this.getAttribute('fab-color') || defaultConfig.fabColor, panelBorderRadius: this.getAttribute('panel-border-radius') || defaultConfig.panelBorderRadius, panelShadow: this.getAttribute('panel-shadow') || defaultConfig.panelShadow, enableResponseInterceptor: this.getAttribute('enable-response-interceptor') !== 'false', customStyles: this.getAttribute('custom-styles') || '{}', debug: this.getAttribute('debug') === 'true' ? true : (this.getAttribute('debug') === 'false' ? false : defaultConfig.debug) }; if (config.debug) { console.log('SpectrumChatElement.getConfig() called, returning:', config); } return config; } render() { const config = this.getConfig(); this.shadowRoot.innerHTML = ` <style> :host { --spectrum-primary: ${config.primaryColor}; --spectrum-user: ${config.userColor}; --spectrum-fab-color: ${config.fabColor}; --spectrum-panel-radius: ${config.panelBorderRadius}; --spectrum-panel-shadow: ${config.panelShadow}; --spectrum-width: ${config.width}; --spectrum-height: ${config.height}; } * { box-sizing: border-box; } .spectrum-chat-fab { position: fixed; bottom: 2rem; right: 2rem; width: 3.5rem; height: 3.5rem; border-radius: 50%; background: var(--spectrum-fab-color); color: white; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: var(--spectrum-panel-shadow); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; display: flex; align-items: center; justify-content: center; font-family: inherit; } .spectrum-chat-fab:hover { transform: scale(1.1); box-shadow: 0 8px 32px -8px var(--spectrum-fab-color, 0.4); } .spectrum-chat-panel { position: fixed; bottom: 1.5rem; right: 1.5rem; background: white; border-radius: var(--spectrum-panel-radius); box-shadow: var(--spectrum-panel-shadow); z-index: 999; display: none; border: 1px solid #e5e7eb; overflow: hidden; } .spectrum-chat-panel.active { display: block; animation: spectrum-fade-up 0.3s ease-out; } .spectrum-chat-header { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; background: var(--spectrum-primary); color: white; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .spectrum-chat-title { font-size: 0.875rem; font-weight: 600; color: white; margin: 0; } .spectrum-chat-close { background: none; border: none; color: white; font-size: 1.125rem; cursor: pointer; padding: 0.25rem; border-radius: 0.25rem; transition: background-color 0.3s ease; } .spectrum-chat-close:hover { background: rgba(255, 255, 255, 0.1); } .spectrum-chat-body { padding: 0; position: relative; overflow: hidden; } .spectrum-chat-intro { width: 200px; background-color: var(--spectrum-primary); color: white; border-radius: 10px; padding: 12px; padding-bottom: 15px; display: none; margin: 8px auto; position: absolute; top: 8px; left: 50%; transform: translateX(-50%); z-index: 10; box-shadow: 0 2px 8px rgba(0,0,0,0.15); } .spectrum-chat-intro.show { display: block; animation: spectrum-fade-in 0.3s ease-out; } .spectrum-chat-intro.hide { animation: spectrum-fade-out 0.3s ease-out forwards; } @keyframes spectrum-fade-in { from { opacity: 0; transform: translateX(-50%) translateY(-10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } @keyframes spectrum-fade-out { from { opacity: 1; transform: translateX(-50%) translateY(0); } to { opacity: 0; transform: translateX(-50%) translateY(-10px); } } .spectrum-chat-intro-title { text-align: center; margin-bottom: 8px; font-size: 16px; font-weight: bold; } .spectrum-chat-intro-text { font-size: 15px; line-height: 20px; } @keyframes spectrum-fade-up { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } /* Position variants */ .spectrum-chat-panel.position-bottom-left { right: auto; left: 1.5rem; } .spectrum-chat-fab.position-bottom-left { right: auto; left: 2rem; } .spectrum-chat-panel.position-top-right { bottom: auto; top: 1.5rem; } .spectrum-chat-fab.position-top-right { bottom: auto; top: 2rem; } .spectrum-chat-panel.position-top-left { bottom: auto; top: 1.5rem; right: auto; left: 1.5rem; } .spectrum-chat-fab.position-top-left { bottom: auto; top: 2rem; right: auto; left: 2rem; } /* Responsive adjustments */ @media (max-width: 650px) and (min-width: 356px) { .spectrum-chat-panel { right: 0.75rem; left: 0.75rem; bottom: 0.75rem; width: calc(100vw - 1.5rem) !important; max-width: none !important; height: calc(100vh - 4rem) !important; } .spectrum-chat-panel .deep-chat { width: 100% !important; max-width: none !important; height: 100% !important; } .spectrum-chat-fab { right: 0.75rem; bottom: 0.75rem; } } @media (max-width: 650px) { .spectrum-chat-panel { right: 0.5rem; left: 0.5rem; bottom: 0.5rem; width: calc(100vw - 1rem) !important; max-width: none !important; height: calc(100vh - 3rem) !important; } .spectrum-chat-panel .deep-chat { width: 100% !important; max-width: none !important; height: 100% !important; } .spectrum-chat-fab { right: 0.5rem; bottom: 0.5rem; } } @media (max-width: 480px) { .spectrum-chat-panel { right: 0.25rem; left: 0.25rem; bottom: 0.25rem; width: calc(100vw - 0.5rem) !important; height: calc(100vh - 4rem) !important; } .spectrum-chat-fab { right: 0.25rem; bottom: 0.25rem; } } .spectrum-chat-loading { display: flex; align-items: center; justify-content: center; padding: 2rem; color: #666; font-size: 0.875rem; } .spectrum-chat-loading.hidden { display: none; } </style> <button class="spectrum-chat-fab position-${config.position}" aria-label="Open AI Assistant"> ${config.fabIcon} </button> <div class="spectrum-chat-panel position-${config.position}" role="dialog" aria-label="AI Assistant"> <div class="spectrum-chat-header"> <strong class="spectrum-chat-title">${config.title}</strong> <button class="spectrum-chat-close" aria-label="Close">x</button> </div> <div class="spectrum-chat-body"> <div class="spectrum-chat-loading" id="loading-indicator"> Loading chat... </div> <div class="spectrum-chat-intro ${config.showIntro ? 'show' : ''}"> <div class="spectrum-chat-intro-title">Hello</div> <div class="spectrum-chat-intro-text">${config.introText}</div> </div> <div id="chat-container"></div> </div> </div> `; } async initializeDeepChat() { if (this.chatElement) return; console.log('Initializing DeepChat...'); if (!window.customElements.get('deep-chat')) { console.log('Waiting for DeepChat to load...'); await this.waitForDeepChat(); console.log('DeepChat loaded successfully'); } const config = this.getConfig(); this.fabElement = this.shadowRoot.querySelector('.spectrum-chat-fab'); this.panelElement = this.shadowRoot.querySelector('.spectrum-chat-panel'); const chatContainer = this.shadowRoot.querySelector('#chat-container'); const loadingIndicator = this.shadowRoot.querySelector('#loading-indicator'); if (!chatContainer) return; if (chatContainer.querySelector('deep-chat')) return; const deepChatElement = document.createElement('deep-chat'); // Set all attributes BEFORE appending to DOM to ensure proper initialization deepChatElement.setAttribute('remarkable', '{"html": true}'); const useConversationsAPI = config.apiUrl.includes('/conversations'); if (useConversationsAPI) { deepChatElement.setAttribute('connect', JSON.stringify({ url: config.apiUrl, method: "POST", additionalBodyProps: { tenant_id: config.tenantId, citations: config.enableCitations } })); } else { deepChatElement.setAttribute('connect', JSON.stringify({ url: config.apiUrl, method: "POST", additionalBodyProps: { tenant_id: config.tenantId, citations: config.enableCitations } })); } deepChatElement.style.cssText = `height: var(--spectrum-height); width: var(--spectrum-width); border-width: 1px; border-style: solid; border-color: rgb(202, 202, 202); font-family: Inter, sans-serif, Avenir, Helvetica, Arial; font-size: 0.9rem; background-color: white; position: relative; overflow: hidden;`; if (config.browserStorage) { deepChatElement.setAttribute('browserStorage', JSON.stringify({ maxMessages: parseInt(config.maxMessages) })); } // Append to DOM so Deep Chat can initialize chatContainer.appendChild(deepChatElement); this.chatElement = deepChatElement; // Set interceptors AFTER element is connected to DOM setTimeout(() => { if (useConversationsAPI && this.chatElement) { this.chatElement.requestInterceptor = this.createConversationRequestInterceptor(config); this.chatElement.responseInterceptor = this.createConversationResponseInterceptor(config); } }, 100); if (loadingIndicator) { loadingIndicator.classList.add('hidden'); } this.initializeChat(); this.setupEventListeners(); } waitForDeepChat() { return new Promise((resolve) => { const checkDeepChat = () => { if (window.customElements.get('deep-chat')) { resolve(); } else { setTimeout(checkDeepChat, 100); } }; checkDeepChat(); }); } initializeChat() { const config = this.getConfig(); if (!this.chatElement) return; // Set response interceptor after a delay to ensure Deep Chat is fully initialized if (config.enableResponseInterceptor) { setTimeout(() => { if (this.chatElement) { this.chatElement.responseInterceptor = this.handleResponseInterceptor.bind(this); } }, 100); } this.setupMessageStyles(config); this.setupIntroBehavior(); this.setupAutoScroll(); } setupMessageStyles(config) { if (!this.chatElement) return; const customMessageStyles = { default: { shared: { innerContainer: { padding: '0 8px' }, bubble: { color: 'white', maxWidth: '85%', wordWrap: 'break-word' } }, ai: { bubble: { backgroundColor: config.aiColor, padding: '10px 12px' } }, user: { bubble: { backgroundColor: config.userColor, padding: '10px 12px' } } } }; try { const customStyles = JSON.parse(config.customStyles); Object.assign(customMessageStyles, customStyles); } catch (e) { console.warn('Invalid custom styles JSON:', e); } this.chatElement.messageStyles = customMessageStyles; } setupIntroBehavior() { if (!this.chatElement) return; this.chatElement.addEventListener('message', () => { const intro = this.shadowRoot.querySelector('.spectrum-chat-intro'); if (intro) { intro.classList.add('hide'); setTimeout(() => { intro.classList.remove('show', 'hide'); }, 300); } }); this.setupAutoScroll(); } setupAutoScroll() { if (!this.chatElement) return; const scrollToBottom = () => { setTimeout(() => { const chatContainer = this.chatElement.shadowRoot || this.chatElement; const messagesContainer = chatContainer.querySelector('.messages') || chatContainer.querySelector('[class*="message"]') || chatContainer.querySelector('.chat-messages'); if (messagesContainer) { messagesContainer.scrollTop = messagesContainer.scrollHeight; } else { const scrollableElements = chatContainer.querySelectorAll('*'); scrollableElements.forEach(el => { if (el.scrollHeight > el.clientHeight) { el.scrollTop = el.scrollHeight; } }); } }, 100); }; this.chatElement.addEventListener('message', scrollToBottom); this.chatElement.addEventListener('response', scrollToBottom); this.chatElement.addEventListener('deep-chat-ready', scrollToBottom); this.addEventListener('spectrum-chat-opened', scrollToBottom); setTimeout(scrollToBottom, 500); setTimeout(scrollToBottom, 1000); } handleResponseInterceptor(response) { const config = this.getConfig(); if (!response || !response.text) { return response; } let processedText = response.text; // Replace citation markers with HTML links if citations are enabled if (config.enableCitations && response.sources && response.sources.length > 0) { response.sources.forEach(source => { if (source.index && source.title && source.url) { const citationLink = `<a href="${source.url}" target="_blank" rel="noopener noreferrer" style="color: #1e5a7a; text-decoration: underline; cursor: pointer; position: relative;" title="${source.title.replace(/"/g, '&quot;')}">[${source.index}]</a>`; const citationPattern = new RegExp(`\\[${source.index}\\]`, 'g'); processedText = processedText.replace(citationPattern, citationLink); } }); } // Return as text so Deep Chat's remarkable processor can convert markdown to HTML // The HTML citation links will be preserved because remarkable has html:true return { ...response, text: processedText }; } setupEventListeners() { // Always re-find elements since DOM might have been re-rendered this.fabElement = this.shadowRoot.querySelector('.spectrum-chat-fab'); this.panelElement = this.shadowRoot.querySelector('.spectrum-chat-panel'); // Remove existing listeners to prevent duplicates if (this.fabClickHandler) { this.fabElement?.removeEventListener('click', this.fabClickHandler); } if (this.closeClickHandler) { const closeButton = this.shadowRoot.querySelector('.spectrum-chat-close'); closeButton?.removeEventListener('click', this.closeClickHandler); } // Create new handlers this.fabClickHandler = (e) => { e.preventDefault(); e.stopPropagation(); this.toggleChat(); }; this.closeClickHandler = () => this.closeChat(); // Add new listeners if (this.fabElement) { this.fabElement.addEventListener('click', this.fabClickHandler); } const closeButton = this.shadowRoot.querySelector('.spectrum-chat-close'); if (closeButton) { closeButton.addEventListener('click', this.closeClickHandler); } // Only add document listener once if (!this.documentClickHandler) { this.documentClickHandler = (event) => { if (!this.contains(event.target) && this.isOpen) { this.closeChat(); } }; document.addEventListener('click', this.documentClickHandler); } } removeEventListeners() { // Clean up if needed } createConversationRequestInterceptor(config) { return async (request) => { const { body, url, method } = request; let messageData; try { // body might already be an object or a JSON string messageData = typeof body === 'string' ? JSON.parse(body) : body; } catch (e) { console.error('Failed to parse request body:', e); return request; } let messageText; if (Array.isArray(messageData.messages) && messageData.messages.length > 0) { const lastMessage = messageData.messages[messageData.messages.length - 1]; messageText = lastMessage.text || lastMessage.message || ''; } else if (typeof messageData.message === 'string') { messageText = messageData.message; } else { console.warn('Could not extract message text from request:', messageData); return request; } // Phase 1: Initialize session if JWT enabled try { await initializeSession(config); } catch (e) { console.error('Failed to initialize session:', e); } let conversationId = null; try { conversationId = sessionStorage.getItem('spectrum-chat-conversation-id'); } catch (e) { console.warn('Failed to load conversation ID:', e); } let apiUrl = config.apiUrl; let isNewConversation = false; if (conversationId) { apiUrl = config.apiUrl.replace(/\/conversations\/?$/, `/conversations/${conversationId}`); } else { isNewConversation = true; apiUrl = config.apiUrl.replace(/\/conversations\/.*$/, '/conversations'); } const newBody = { message: messageText, citations: config.enableCitations }; // Phase 0: Include siteKey for start conversation if (isNewConversation && config.siteKey) { newBody.siteKey = config.siteKey; newBody.pageUrlHash = await hashPageUrl(); newBody.nonce = generateNonce(); } // Backward compatibility: include tenant_id if provided if (config.tenantId) { newBody.tenant_id = config.tenantId; } // Build headers const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; // Phase 1: Include Authorization header if JWT enabled if (config.useJWT && globalState.tokenData?.token) { headers['Authorization'] = `Bearer ${globalState.tokenData.token}`; } return { ...request, url: apiUrl, body: newBody, headers: headers }; }; } createConversationResponseInterceptor(config) { return async (response) => { try { const responseData = await response.json(); if (responseData.conversation_id) { try { sessionStorage.setItem('spectrum-chat-conversation-id', responseData.conversation_id); } catch (e) { console.warn('Failed to save conversation ID:', e); } } const messageText = responseData.text || responseData.message || ''; let processedText = messageText; // Replace citation markers with HTML links if citations are enabled if (config.enableCitations && responseData.sources && responseData.sources.length > 0) { responseData.sources.forEach(source => { if (source.index && source.title && source.url) { const citationLink = `<a href="${source.url}" target="_blank" rel="noopener noreferrer" style="color: #1e5a7a; text-decoration: underline; cursor: pointer;" title="${source.title.replace(/"/g, '&quot;')}">[${source.index}]</a>`; const citationPattern = new RegExp(`\\[${source.index}\\]`, 'g'); processedText = processedText.replace(citationPattern, citationLink); } }); } // Return as text so Deep Chat's remarkable processor can convert markdown to HTML // The HTML citation links will be preserved because remarkable has html:true return { text: processedText, role: responseData.role || 'assistant', sources: responseData.sources || null, error: responseData.error || null }; } catch (e) { console.error('Failed to process conversation response:', e); return response; } }; } toggleChat() { if (this.isOpen) { this.closeChat(); } else { this.openChat(); } } openChat() { this.isOpen = true; this.panelElement.classList.add('active'); this.fabElement.style.display = 'none'; this.dispatchEvent(new CustomEvent('spectrum-chat-opened')); } closeChat() { this.isOpen = false; this.panelElement.classList.remove('active'); this.fabElement.style.display = 'flex'; this.dispatchEvent(new CustomEvent('spectrum-chat-closed')); } // Public API methods open() { this.openChat(); } close() { this.closeChat(); } isChatOpen() { return this.isOpen; } updateConfig(newConfig) { Object.keys(newConfig).forEach(key => { this.setAttribute(key, newConfig[key]); }); } refreshStyles() { const config = this.getConfig(); this.setupMessageStyles(config); } hideIntro() { const intro = this.shadowRoot.querySelector('.spectrum-chat-intro'); if (intro) { intro.classList.add('hide'); setTimeout(() => { intro.classList.remove('show', 'hide'); }, 300); } } } // Register the custom element console.log('Registering spectrum-chat custom element...'); customElements.define('spectrum-chat', SpectrumChatElement); console.log('spectrum-chat custom element registered successfully'); // Global mode implementation function createGlobalChatWidget(config) { const widget = document.createElement('div'); widget.id = 'spectrum-chat-global-widget'; widget.innerHTML = ` <style> #spectrum-chat-global-widget { position: fixed; bottom: 2rem; right: 2rem; z-index: 10000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; pointer-events: none; } .spectrum-chat-fab { width: 3.5rem; height: 3.5rem; border-radius: 50%; background: ${config.fabColor}; color: white; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: ${config.panelShadow}; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; justify-content: center; pointer-events: auto; } .spectrum-chat-fab:hover { transform: scale(1.1); box-shadow: 0 8px 32px -8px ${config.fabColor}40; } .spectrum-chat-panel { position: absolute; bottom: 1rem; right: 0; width: ${config.width}; min-width: ${config.width}; max-width: ${config.width}; height: ${config.height}; max-height: ${config.height}; background: white; border-radius: ${config.panelBorderRadius}; box-shadow: ${config.panelShadow}; border: 1px solid #e5e7eb; display: none; flex-direction: column; overflow: hidden; pointer-events: auto; z-index: 10001; animation: spectrum-fade-up 0.3s ease-out; } .spectrum-chat-panel.active { display: flex; } @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: 0.75rem 1rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .spectrum-chat-title { font-weight: 600; font-size: 0.9rem; } .spectrum-chat-close { background: none; border: none; color: white; font-size: 1.2rem; cursor: pointer; padding: 0; width: 1.5rem; height: 1.5rem; display: flex; align-items: center; justify-content: center; border-radius: 0.25rem; transition: background-color 0.2s; } .spectrum-chat-close:hover { background: rgba(255, 255, 255, 0.1); } .spectrum-chat-messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; scroll-behavior: smooth; } .spectrum-chat-message { max-width: 85%; padding: 0.5rem 0.75rem; border-radius: 1rem; font-size: 0.9rem; line-height: 1.4; word-wrap: break-word; animation: spectrum-message-appear 0.3s ease-out; } /* Markdown HTML element styles */ .spectrum-chat-message ul { margin: 0.3rem 0; padding-left: 1.25rem; list-style-type: disc; } .spectrum-chat-message li { margin: 0; line-height: 1.4; } .spectrum-chat-message strong { font-weight: 600; } .spectrum-chat-message em { font-style: italic; } .spectrum-chat-message a { color: #60a5fa; text-decoration: underline; } .spectrum-chat-message a:hover { color: #93c5fd; } .spectrum-chat-message br { line-height: 0.3; } @keyframes spectrum-message-appear { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .spectrum-chat-message.user { background: ${config.userColor}; color: white; align-self: flex-end; border-bottom-right-radius: 0.25rem; } .spectrum-chat-message.assistant { background: ${config.aiColor}; color: white; align-self: flex-start; border-bottom-left-radius: 0.25rem; } .spectrum-chat-input-container { padding: 1rem; border-top: 1px solid #e5e7eb; display: flex; gap: 0.5rem; background: #f9fafb; } .spectrum-chat-input { flex: 1; border: 1px solid #d1d5db; border-radius: 1.5rem; padding: 0.5rem 1rem; font-size: 0.9rem; outline: none; transition: border-color 0.2s; } .spectrum-chat-input:focus { border-color: ${config.primaryColor}; box-shadow: 0 0 0 3px ${config.primaryColor}20; } .spectrum-chat-send { background: ${config.primaryColor}; color: white; border: none; border-radius: 1.5rem; padding: 0.5rem 1rem; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; min-width: 4rem; } .spectrum-chat-send:hover:not(:disabled) { background: ${config.primaryColor}dd; transform: translateY(-1px); } .spectrum-chat-send:disabled { background: #9ca3af; cursor: not-allowed; transform: none; } .spectrum-chat-typing { display: flex; align-items: center; gap: 0.25rem; padding: 0.5rem 0.75rem; color: #6b7280; font-size: 0.8rem; } .spectrum-chat-typing-dots { display: flex; gap: 0.125rem; } .spectrum-chat-typing-dot { width: 0.25rem; height: 0.25rem;