@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
JavaScript
/**
* 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, '"')}">[${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, '"')}">[${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;