nlweb-js-client
Version:
A streaming chat interface client library
1,241 lines (1,105 loc) • 45.3 kB
JavaScript
/**
* ManagedEventSource - Handles EventSource connections with retry logic
*/
class ManagedEventSource {
constructor(url, options = {}) {
this.url = url;
this.options = options;
this.maxRetries = options.maxRetries || 3;
this.retryCount = 0;
this.eventSource = null;
this.isStopped = false;
}
connect(chatInterface) {
if (this.isStopped) {
return;
}
this.eventSource = new EventSource(this.url);
this.eventSource.chatInterface = chatInterface;
this.eventSource.onopen = () => {
this.retryCount = 0; // Reset retry count on successful connection
};
this.eventSource.onerror = (error) => {
if (this.eventSource.readyState === EventSource.CLOSED) {
console.log('Connection was closed');
if (this.retryCount < this.maxRetries) {
this.retryCount++;
console.log(`Retry attempt ${this.retryCount} of ${this.maxRetries}`);
// Implement exponential backoff
const backoffTime = Math.min(1000 * Math.pow(2, this.retryCount), 10000);
setTimeout(() => this.connect(), backoffTime);
} else {
console.log('Max retries reached, stopping reconnection attempts');
this.stop();
}
}
};
this.eventSource.onmessage = function (event) {
if (this.chatInterface.dotsStillThere) {
this.chatInterface.handleFirstMessage(event);
const messageDiv = document.createElement('div');
messageDiv.className = `message assistant-message`;
const bubble = document.createElement('div');
bubble.className = 'message-bubble';
messageDiv.appendChild(bubble);
this.chatInterface.bubble = bubble;
this.chatInterface.messagesArea.appendChild(messageDiv);
this.chatInterface.currentItems = [];
this.chatInterface.thisRoundRemembered = null;
}
const data = JSON.parse(event.data);
// check that the query_id on this object and message match
if (this.query_id && data.query_id && this.query_id !== data.query_id) {
console.log("Query ID mismatch, ignoring message");
return;
}
if (data && data.message_type == "query_analysis") {
this.chatInterface.itemToRemember.push(data.item_to_remember);
this.chatInterface.decontextualizedQuery = data.decontextualized_query;
this.chatInterface.possiblyAnnotateUserQuery(this.chatInterface, data.decontextualized_query);
if (this.chatInterface.itemToRemember) {
this.chatInterface.memoryMessage(data.item_to_remember, this.chatInterface);
}
} else if (data && data.message_type == "remember") {
this.chatInterface.memoryMessage(data.message, this.chatInterface);
} else if (data && data.message_type == "asking_sites") {
this.chatInterface.sourcesMessage = this.chatInterface.createIntermediateMessageHtml(data.message);
this.chatInterface.bubble.appendChild(this.chatInterface.sourcesMessage);
} else if (data && data.message_type == "site_is_irrelevant_to_query") {
this.chatInterface.siteIsIrrelevantToQuery(data.message, this.chatInterface);
} else if (data && data.message_type == "ask_user") {
this.chatInterface.askUserMessage(data.message, this.chatInterface);
} else if (data && data.message_type == "item_details") {
this.chatInterface.itemDetailsMessage(data.message, this.chatInterface);
} else if (data && data.message_type == "result_batch") {
for (const item of data.results) {
const domItem = this.chatInterface.createJsonItemHtml(item);
this.chatInterface.currentItems.push([item, domItem]);
this.chatInterface.bubble.appendChild(domItem);
this.chatInterface.num_results_sent++;
}
this.chatInterface.resortResults(this.chatInterface);
} else if (data && data.message_type == "intermediate_message") {
this.chatInterface.bubble.appendChild(this.chatInterface.createIntermediateMessageHtml(data.message));
} else if (data && data.message_type == "summary") {
this.chatInterface.thisRoundSummary = this.chatInterface.createIntermediateMessageHtml(data.message);
this.chatInterface.resortResults(this.chatInterface);
} else if (data && data.message_type == "nlws") {
while (this.chatInterface.bubble.firstChild) {
this.chatInterface.bubble.removeChild(this.chatInterface.bubble.firstChild);
}
this.chatInterface.itemDetailsMessage(data.answer, this.chatInterface);
for (const item of data.items) {
const domItem = this.chatInterface.createJsonItemHtml(item);
this.chatInterface.currentItems.push([item, domItem]);
this.chatInterface.bubble.appendChild(domItem);
}
} else if (data && data.message_type == "complete") {
this.chatInterface.resortResults(this.chatInterface);
this.chatInterface.scrollDiv.scrollIntoView();
this.close();
}
};
}
stop() {
this.isStopped = true;
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
// Method to manually reset and reconnect
reset() {
this.retryCount = 0;
this.isStopped = false;
this.stop();
this.connect();
}
}
/**
* Utility functions for JSON handling and rendering
*/
/**
* Converts JSON-LD to formatted HTML for display
* @param {Object|string} jsonLd - The JSON-LD object or string to format
* @returns {string} HTML representation of the JSON-LD
*/
function jsonLdToHtml(jsonLd) {
// Helper function to escape HTML special characters
const escapeHtml = (str) => {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
// Helper function to format a single value
const formatValue = (value, indent) => {
const spaces = ' '.repeat(indent);
if (value === null) {
return `<span class="null">null</span>`;
}
switch (typeof value) {
case 'string':
// Special handling for URLs and IRIs in JSON-LD
if (value.startsWith('http://') || value.startsWith('https://')) {
return `<span class="string url">"${escapeHtml(value)}"</span>`;
}
return `<span class="string">"${escapeHtml(value)}"</span>`;
case 'number':
return `<span class="number">${value}</span>`;
case 'boolean':
return `<span class="boolean">${value}</span>`;
case 'object':
if (Array.isArray(value)) {
if (value.length === 0) return '[]';
const items = value.map(item =>
`${spaces} ${formatValue(item, indent + 1)}`
).join(',\n');
return `[\n${items}\n${spaces}]`;
}
return formatObject(value, indent);
}
};
// Helper function to format an object
const formatObject = (obj, indent = 0) => {
const spaces = ' '.repeat(indent);
if (Object.keys(obj).length === 0) return '{}';
const entries = Object.entries(obj).map(([key, value]) => {
// Special handling for JSON-LD keywords (starting with @)
const keySpan = key.startsWith('@')
? `<span class="keyword">"${escapeHtml(key)}"</span>`
: `<span class="key">"${escapeHtml(key)}"</span>`;
return `${spaces} ${keySpan}: ${formatValue(value, indent + 1)}`;
});
return `{\n${entries.join(',\n')}\n${spaces}}`;
};
// Main formatting logic
try {
const parsed = (typeof jsonLd === 'string') ? JSON.parse(jsonLd) : jsonLd;
const formatted = formatObject(parsed);
// Return complete HTML with styling
return `<pre class="json-ld"><code>${formatted}</code></pre>
<style>
.json-ld {
background-color: #f5f5f5;
padding: 1em;
border-radius: 4px;
font-family: monospace;
line-height: 1.5;
}
.json-ld .keyword { color: #e91e63; }
.json-ld .key { color: #2196f3; }
.json-ld .string { color: #4caf50; }
.json-ld .string.url { color: #9c27b0; }
.json-ld .number { color: #ff5722; }
.json-ld .boolean { color: #ff9800; }
.json-ld .null { color: #795548; }
</style>`;
} catch (error) {
return `<pre class="json-ld error">Error: ${error.message}</pre>`;
}
}
/**
* Sanitizes a URL to prevent XSS attacks
* @param {string} url - The URL to sanitize
* @returns {string} A sanitized URL or '#' if the URL is invalid
*/
function sanitizeUrl(url) {
// Return a safe default if input is null, undefined, or not a string
if (!url || typeof url !== 'string') return '#';
// Remove leading and trailing whitespace
const trimmedUrl = url.trim();
try {
// Check for dangerous protocols using a more comprehensive approach
const dangerousProtocols = /^(javascript|data|vbscript|file):/i;
if (dangerousProtocols.test(trimmedUrl)) {
return '#';
}
// Try to parse the URL - this will throw for malformed URLs
const parsedUrl = new URL(trimmedUrl, window.location.origin);
// Only allow specific protocols
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return '#';
}
// Return the sanitized URL
return parsedUrl.toString();
} catch (e) {
// If URL parsing fails or any other error occurs, return a safe default
console.warn("Invalid URL detected and sanitized:", url);
return '#';
}
}
/**
* Safely unescapes HTML entities in a string
* @param {string} str - The string to unescape
* @returns {string} The unescaped string
*/
function htmlUnescape(str) {
// Return empty string for null/undefined values
if (!str) return '';
// Check if input is a string
if (typeof str !== 'string') {
return String(str);
}
// Use the browser's built-in text decoder functionality
// This safely decodes HTML entities without execution risks
const textarea = document.createElement('textarea');
textarea.textContent = str;
return textarea.value;
}
/**
* Generates a quick hash for a string
* @param {string} string - The string to hash
* @returns {number} A numeric hash
*/
function quickHash(string) {
let hash = 0;
for (let i = 0; i < string.length; i++) {
const char = string.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0; // Convert to 32-bit integer
}
return hash;
}
/**
* UI-related components and methods for the ChatInterface
*/
/**
* Creates a selector label element
* @param {string} label - The label text
* @returns {HTMLElement} The label element
*/
function makeSelectorLabel(label) {
const labelDiv = document.createElement('span');
labelDiv.textContent = " " + label + " ";
return labelDiv;
}
/**
* Creates selectors for site and generate mode
* @param {Object} chatInterface - The chat interface instance
*/
function createSelectors(chatInterface) {
// Create selectors
const selector = document.createElement('div');
chatInterface.selector = selector;
selector.className = 'site-selector';
// Create site selector
const siteSelect = document.createElement('select');
chatInterface.siteSelect = siteSelect;
getSites().forEach(site => {
const option = document.createElement('option');
option.value = site;
option.textContent = site;
siteSelect.appendChild(option);
});
selector.appendChild(makeSelectorLabel("Site"));
selector.appendChild(siteSelect);
siteSelect.addEventListener('change', () => {
chatInterface.resetChatState();
});
const generateModeSelect = document.createElement('select');
chatInterface.generateModeSelect = generateModeSelect;
selector.appendChild(makeSelectorLabel("Mode"));
getGenerateModes().forEach(mode => {
const option = document.createElement('option');
option.value = mode;
option.textContent = mode;
generateModeSelect.appendChild(option);
});
generateModeSelect.addEventListener('change', () => {
chatInterface.generate_mode = generateModeSelect.value;
chatInterface.resetChatState();
});
selector.appendChild(generateModeSelect);
// Create clear chat icon
const clearIcon = document.createElement('span');
clearIcon.innerHTML = '<img src="/html/clear.jpeg" width="16" height="16" style="vertical-align: middle; cursor: pointer; margin-left: 8px;">';
clearIcon.title = "Clear chat history";
clearIcon.addEventListener('click', () => {
chatInterface.resetChatState();
});
selector.appendChild(clearIcon);
// Create debug icon
const debugIcon = document.createElement('span');
debugIcon.innerHTML = '<img src="/html/debug.png" width="16" height="16" style="vertical-align: middle; cursor: pointer; margin-left: 8px;">';
debugIcon.title = "Debug";
debugIcon.addEventListener('click', () => {
if (chatInterface.debug_mode) {
chatInterface.debug_mode = false;
chatInterface.bubble.innerHTML = '';
chatInterface.resortResults(chatInterface);
} else {
chatInterface.debug_mode = true;
chatInterface.bubble.innerHTML = chatInterface.createDebugString();
}
});
selector.appendChild(debugIcon);
const contextUrlDiv = document.createElement('div');
contextUrlDiv.id = 'context_url_div';
contextUrlDiv.style.display = 'none';
contextUrlDiv.style.marginTop = '8px';
const contextUrlInput = document.createElement('input');
contextUrlInput.type = 'text';
contextUrlInput.id = 'context_url';
contextUrlInput.placeholder = 'Enter Context URL';
contextUrlInput.style.width = '200px';
contextUrlDiv.appendChild(makeSelectorLabel("Context URL"));
contextUrlDiv.appendChild(contextUrlInput);
selector.appendChild(contextUrlDiv);
chatInterface.context_url = contextUrlInput;
chatInterface.container.appendChild(selector);
}
/**
* Creates the chat interface UI
* @param {Object} chatInterface - The chat interface instance
* @param {string} display_mode - The display mode (dropdown or full)
*/
function createInterface(chatInterface, display_mode = "dropdown") {
// Create main container
chatInterface.container = document.getElementById('chat-container');
if (display_mode == "dropdown") {
createSelectors(chatInterface);
}
// Create messages area
chatInterface.messagesArea = document.createElement('div');
chatInterface.messagesArea.className = (display_mode == "dropdown" ? 'messages' : 'messages_full');
// Create input area
chatInterface.inputArea = document.createElement('div');
chatInterface.inputArea.className = (display_mode == "dropdown" ? 'input-area' : 'input-area_full');
// Create input field
chatInterface.input = document.createElement('textarea');
chatInterface.input.className = 'message-input';
chatInterface.input.placeholder = 'Type your message...';
// Create send button
chatInterface.sendButton = document.createElement('button');
chatInterface.sendButton.className = 'send-button';
chatInterface.sendButton.textContent = 'Send';
// Assemble the interface
chatInterface.inputArea.appendChild(chatInterface.input);
chatInterface.inputArea.appendChild(chatInterface.sendButton);
chatInterface.container.appendChild(chatInterface.messagesArea);
chatInterface.container.appendChild(chatInterface.inputArea);
}
/**
* Binds event handlers to UI elements
* @param {Object} chatInterface - The chat interface instance
*/
function bindEvents(chatInterface) {
// Send message on button click
chatInterface.sendButton.addEventListener('click', () => chatInterface.sendMessage());
// Send message on Enter (but allow Shift+Enter for new lines)
chatInterface.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
chatInterface.sendMessage();
}
});
}
/**
* Creates an HTML element for an intermediate message
* @param {string} message - The message text
* @returns {HTMLElement} The message element
*/
function createIntermediateMessageHtml(message) {
const container = document.createElement('div');
container.className = 'intermediate-container';
container.textContent = message;
return container;
}
/**
* Creates an HTML element for a memory message
* @param {string} itemToRemember - The item to remember
* @param {Object} chatInterface - The chat interface instance
* @returns {HTMLElement|null} The message element or null
*/
function memoryMessage(itemToRemember, chatInterface) {
if (itemToRemember) {
const messageDiv = document.createElement('div');
messageDiv.className = `remember-message`;
messageDiv.textContent = itemToRemember;
chatInterface.thisRoundRemembered = messageDiv;
chatInterface.bubble.appendChild(messageDiv);
return messageDiv;
}
return null;
}
/**
* Creates an HTML element for an ask user message
* @param {string} message - The message text
* @param {Object} chatInterface - The chat interface instance
*/
function askUserMessage(message, chatInterface) {
console.log("askUserMessage", message);
const messageDiv = document.createElement('div');
messageDiv.className = `ask-user-message`;
messageDiv.textContent = message;
chatInterface.bubble.appendChild(messageDiv);
}
/**
* Creates an HTML element for a site is irrelevant to query message
* @param {string} message - The message text
* @param {Object} chatInterface - The chat interface instance
*/
function siteIsIrrelevantToQuery(message, chatInterface) {
console.log("siteIsIrrelevantToQuery", message);
const messageDiv = document.createElement('div');
messageDiv.className = `site-is-irrelevant-to-query`;
messageDiv.textContent = message;
chatInterface.bubble.appendChild(messageDiv);
}
/**
* Creates an HTML element for an item details message
* @param {string} itemDetails - The item details
* @param {Object} chatInterface - The chat interface instance
* @returns {HTMLElement|null} The message element or null
*/
function itemDetailsMessage(itemDetails, chatInterface) {
if (itemDetails) {
const messageDiv = document.createElement('div');
messageDiv.className = `item-details-message`;
messageDiv.textContent = itemDetails;
chatInterface.thisRoundRemembered = messageDiv;
chatInterface.bubble.appendChild(messageDiv);
return messageDiv;
}
return null;
}
/**
* Creates an HTML element for a span with specific styling
* @param {string} content - The content text
* @returns {HTMLElement} The span element
*/
function makeAsSpan(content) {
const span = document.createElement('span');
span.textContent = content;
span.style.fontSize = '0.85em';
return span;
}
/**
* Adds an explanation to an item
* @param {Object} item - The item object
* @param {HTMLElement} contentDiv - The content div to add the explanation to
* @param {boolean} force - Whether to force adding the explanation
* @returns {HTMLElement} The details div
*/
function possiblyAddExplanation(item, contentDiv, force = false) {
const detailsDiv = document.createElement('div');
contentDiv.appendChild(document.createElement('br'));
const expl_span = makeAsSpan(item.explanation);
expl_span.className = 'item-details-message';
detailsDiv.appendChild(expl_span);
contentDiv.appendChild(detailsDiv);
return detailsDiv;
}
/**
* Adds type-specific content to an item
* @param {Object} item - The item object
* @param {HTMLElement} contentDiv - The content div to add the content to
*/
function typeSpecificContent(item, contentDiv) {
const houseTypes = ["SingleFamilyResidence", "Apartment", "Townhouse", "House", "Condominium", "RealEstateListing"];
if (!item.schema_object) {
return;
}
const objType = item.schema_object['@type'];
if (objType == "PodcastEpisode") {
possiblyAddExplanation(item, contentDiv, true);
return;
}
if (houseTypes.includes(objType)) {
const detailsDiv = possiblyAddExplanation(item, contentDiv, true);
const price = item.schema_object.price;
const address = item.schema_object.address;
const numBedrooms = item.schema_object.numberOfRooms;
const numBathrooms = item.schema_object.numberOfBathroomsTotal;
const sqft = item.schema_object.floorSize?.value;
let priceValue = price;
if (typeof price === 'object') {
priceValue = price.price || price.value || price;
priceValue = Math.round(priceValue / 100000) * 100000;
priceValue = priceValue.toLocaleString('en-US');
}
if (address?.streetAddress && address?.addressLocality) {
detailsDiv.appendChild(makeAsSpan(address.streetAddress + ", " + address.addressLocality));
detailsDiv.appendChild(document.createElement('br'));
}
if (numBedrooms && numBathrooms && sqft) {
detailsDiv.appendChild(makeAsSpan(`${numBedrooms} bedrooms, ${numBathrooms} bathrooms, ${sqft} sqft`));
detailsDiv.appendChild(document.createElement('br'));
}
if (priceValue) {
detailsDiv.appendChild(makeAsSpan(`Listed at ${priceValue}`));
}
}
}
/**
* Gets the name of an item
* @param {Object} item - The item object
* @returns {string} The item name
*/
function getItemName(item) {
if (item.name) {
return item.name;
} else if (item.schema_object && item.schema_object.keywords) {
return item.schema_object.keywords;
}
return item.url;
}
/**
* Creates an HTML element for a JSON item
* @param {Object} item - The item object
* @param {Object} chatInterface - The chat interface instance
* @returns {HTMLElement} The item element
*/
function createJsonItemHtml(item, chatInterface) {
const container = document.createElement('div');
container.style.display = 'flex';
container.style.marginBottom = '1em';
container.style.gap = '1em';
// Left content div (title + description)
const contentDiv = document.createElement('div');
contentDiv.style.flex = '1';
// Title row with link and question mark
const titleRow = document.createElement('div');
titleRow.style.display = 'flex';
titleRow.style.alignItems = 'center';
titleRow.style.gap = '0.5em';
titleRow.style.marginBottom = '0.5em';
// Title/link
const titleLink = document.createElement('a');
// Use sanitizeUrl for URL attributes and add additional security measures
const sanitizedUrl = item.url ? sanitizeUrl(item.url) : '#';
titleLink.href = sanitizedUrl;
// Add rel="noopener noreferrer" for external links
if (sanitizedUrl !== '#' && !sanitizedUrl.startsWith(window.location.origin)) {
titleLink.rel = "noopener noreferrer";
// Optional: Open external links in new tab
titleLink.target = "_blank";
}
const itemName = getItemName(item);
titleLink.textContent = htmlUnescape(`${itemName}`);
titleLink.style.fontWeight = '600';
titleLink.style.textDecoration = 'none';
titleLink.style.color = '#2962ff';
titleRow.appendChild(titleLink);
// info icon
const infoIcon = document.createElement('span');
const imgElement = document.createElement('img');
imgElement.src = sanitizeUrl('static/images/info.png');
imgElement.width = 16;
imgElement.height = 16;
imgElement.alt = 'Info';
infoIcon.appendChild(imgElement);
infoIcon.style.fontSize = '0.5em';
infoIcon.style.position = 'relative';
// Create popup element
infoIcon.title = `${item.explanation || ''} (score=${item.score || 0}) (Ranking time=${item.time || 0})`;
titleRow.appendChild(infoIcon);
contentDiv.appendChild(titleRow);
// Description
const description = document.createElement('div');
description.textContent = item.description || '';
description.style.fontSize = '0.9em';
contentDiv.appendChild(description);
if (chatInterface.display_mode == "nlwebsearch") {
// visible url
document.createElement("div");
const visibleUrlLink = document.createElement("a");
// Use sanitizeUrl for URL attributes and add security attributes
const sanitizedSiteUrl = item.siteUrl ? sanitizeUrl(item.siteUrl) : '#';
visibleUrlLink.href = sanitizedSiteUrl;
// Add rel="noopener noreferrer" for external links
if (sanitizedSiteUrl !== '#' && !sanitizedSiteUrl.startsWith(window.location.origin)) {
visibleUrlLink.rel = "noopener noreferrer";
visibleUrlLink.target = "_blank";
}
// Sanitize the site text content to prevent XSS
visibleUrlLink.textContent = htmlUnescape(item.site || '');
visibleUrlLink.style.fontSize = "0.9em";
visibleUrlLink.style.textDecoration = "none";
visibleUrlLink.style.color = "#2962ff";
visibleUrlLink.style.fontWeight = "500";
visibleUrlLink.style.padding = "8px 0";
visibleUrlLink.style.display = "inline-block";
contentDiv.appendChild(visibleUrlLink);
}
typeSpecificContent(item, contentDiv);
// Feedback icons
const feedbackDiv = document.createElement('div');
feedbackDiv.style.display = 'flex';
feedbackDiv.style.gap = '0.5em';
feedbackDiv.style.marginTop = '0.5em';
const thumbsUp = document.createElement('span');
thumbsUp.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#D3D3D3">
<path d="M2 20h2c.55 0 1-.45 1-1v-9c0-.55-.45-1-1-1H2v11zm19.83-7.12c.11-.25.17-.52.17-.8V11c0-1.1-.9-2-2-2h-5.5l.92-4.65c.05-.22.02-.46-.08-.66-.23-.45-.52-.86-.88-1.22L14 2 7.59 8.41C7.21 8.79 7 9.3 7 9.83v7.84C7 18.95 8.05 20 9.34 20h8.11c.7 0 1.36-.37 1.72-.97l2.66-6.15z"/>
</svg>`;
thumbsUp.style.fontSize = '0.8em';
thumbsUp.style.cursor = 'pointer';
const thumbsDown = document.createElement('span');
thumbsDown.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="#D3D3D3">
<path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L9.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/>
</svg>`;
thumbsDown.style.fontSize = '0.8em';
thumbsDown.style.cursor = 'pointer';
feedbackDiv.appendChild(thumbsUp);
feedbackDiv.appendChild(thumbsDown);
contentDiv.appendChild(feedbackDiv);
container.appendChild(contentDiv);
// Check for image in schema object
if (item.schema_object) {
const imgURL = chatInterface.extractImage(item.schema_object);
if (imgURL) {
const imageDiv = document.createElement('div');
const img = document.createElement('img');
// Sanitize URL and verify it's an acceptable image URL
const sanitizedUrl = sanitizeUrl(imgURL);
if (sanitizedUrl !== '#') {
img.src = sanitizedUrl;
img.width = 80;
img.height = 80;
img.style.objectFit = 'cover';
img.alt = 'Item image';
// Add onerror handler to handle broken images
img.onerror = function () {
this.style.display = 'none';
};
imageDiv.appendChild(img);
container.appendChild(imageDiv);
}
}
}
return container;
}
/**
* Creates an HTML element for insufficient results
* @param {Object} chatInterface - The chat interface instance
* @returns {HTMLElement} The message element
*/
function createInsufficientResultsHtml(chatInterface) {
const container = document.createElement('div');
container.className = 'intermediate-container';
container.appendChild(document.createElement('br'));
if (chatInterface.currentItems.length > 0) {
container.textContent = "I couldn't find any more results that are relevant to your query.";
} else {
container.textContent = "I couldn't find any results that are relevant to your query.";
}
container.appendChild(document.createElement('br'));
return container;
}
/**
* Handles the first message from the server
* @param {Object} event - The event object
* @param {Object} chatInterface - The chat interface instance
*/
function handleFirstMessage(event, chatInterface) {
chatInterface.dotsStillThere = false;
chatInterface.messagesArea.removeChild(chatInterface.messagesArea.lastChild);
}
/**
* Possibly annotates the user query with decontextualized query
* @param {Object} chatInterface - The chat interface instance
* @param {string} decontextualizedQuery - The decontextualized query
*/
function possiblyAnnotateUserQuery(chatInterface, decontextualizedQuery) {
chatInterface.lastUserMessageDiv;
}
/**
* Resorts the results based on score
* @param {Object} chatInterface - The chat interface instance
*/
function resortResults(chatInterface) {
if (chatInterface.currentItems.length > 0) {
chatInterface.currentItems.sort((a, b) => b[0].score - a[0].score);
// Clear existing children
while (chatInterface.bubble.firstChild) {
chatInterface.bubble.removeChild(chatInterface.bubble.firstChild);
}
if (chatInterface.thisRoundRemembered) {
chatInterface.bubble.appendChild(chatInterface.thisRoundRemembered);
}
if (chatInterface.sourcesMessage) {
chatInterface.bubble.appendChild(chatInterface.sourcesMessage);
}
if (chatInterface.thisRoundSummary) {
chatInterface.bubble.appendChild(chatInterface.thisRoundSummary);
}
// Add sorted domItems back to bubble
for (const [item, domItem] of chatInterface.currentItems) {
chatInterface.bubble.appendChild(domItem);
}
}
}
/**
* Returns the list of available sites
* @returns {string[]} The list of sites
*/
function getSites() {
return ['imdb', 'nytimes', 'alltrails', 'allbirds', 'seriouseats', 'npr podcasts', 'backcountry', 'bc_product', 'neurips', 'zillow',
'tripadvisor', 'woksoflife', 'cheftariq', 'hebbarskitchen', 'latam_recipes', 'spruce', 'med podcast', 'all'];
}
/**
* Returns the list of available generate modes
* @returns {string[]} The list of generate modes
*/
function getGenerateModes() {
return ['list', 'summarize', 'generate'];
}
/**
* ChatInterface - Main class for the chat interface
*/
/**
* ChatInterface class - Handles the chat interface and communication with the server
*/
class ChatInterface {
/**
* Creates a new ChatInterface instance
* @param {string} site - The site to search
* @param {string} display_mode - The display mode (dropdown or full)
* @param {string} generate_mode - The generate mode (list, summarize, generate)
* @param {string} apiEndpoint - The API endpoint for the chat
*/
constructor(site = null, display_mode = "dropdown", generate_mode = "list", apiEndpoint = "/ask") {
if (site) {
this.site = site;
}
// Store API endpoint
this.apiEndpoint = apiEndpoint;
// Parse URL parameters
const urlParams = new URLSearchParams(window.location.search);
const query = urlParams.get('query');
const prevMessagesStr = urlParams.get('prev');
urlParams.get('context_url');
const url_generate_mode = urlParams.get('generate_mode');
if (url_generate_mode) {
this.generate_mode = url_generate_mode;
}
this.generate_mode = generate_mode;
this.display_mode = display_mode;
this.prevMessages = prevMessagesStr ? JSON.parse(decodeURIComponent(prevMessagesStr)) : [];
createInterface(this, display_mode);
bindEvents(this);
this.eventSource = null;
this.dotsStillThere = false;
this.resetChatState();
// Add message if query parameter exists
if (query) {
this.sendMessage(decodeURIComponent(query));
}
}
/**
* Resets the chat state
*/
resetChatState() {
this.messagesArea.innerHTML = '';
this.messages = [];
this.prevMessages = [];
this.currentMessage = [];
this.currentItems = [];
this.itemToRemember = [];
this.thisRoundSummary = null;
}
/**
* Sends a message to the server
* @param {string} query - The query to send
*/
sendMessage(query = null) {
const message = query || this.input.value.trim();
if (!message) return;
// Add user message
this.addMessage(message, 'user');
this.currentMessage = message;
this.input.value = '';
// Simulate assistant response
this.getResponse(message);
}
/**
* Adds a message to the chat
* @param {string} content - The message content
* @param {string} sender - The sender (user or assistant)
*/
addMessage(content, sender) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${sender}-message`;
if (sender == "user") {
this.lastUserMessageDiv = messageDiv;
const scrollDiv = document.createElement('span');
scrollDiv.id = quickHash(content.toString());
messageDiv.appendChild(scrollDiv);
messageDiv.appendChild(document.createElement('br'));
messageDiv.appendChild(document.createElement('br'));
this.scrollDiv = scrollDiv;
}
const bubble = document.createElement('div');
bubble.className = 'message-bubble';
let parsedContent;
try {
parsedContent = JSON.parse(content);
} catch (e) {
parsedContent = content;
}
// Replace innerHTML with safer DOM manipulation
if (Array.isArray(parsedContent)) {
// Clear any existing content
while (bubble.firstChild) {
bubble.removeChild(bubble.firstChild);
}
// Append each item
parsedContent.forEach(obj => {
const itemElement = this.createJsonItemHtml(obj);
bubble.appendChild(itemElement);
// Add line breaks between items if not the last one
if (parsedContent.indexOf(obj) < parsedContent.length - 1) {
bubble.appendChild(document.createElement('br'));
bubble.appendChild(document.createElement('br'));
}
});
} else {
bubble.textContent = content;
}
messageDiv.appendChild(bubble);
this.messagesArea.appendChild(messageDiv);
this.messagesArea.scrollTop = this.messagesArea.scrollHeight;
this.messages.push({content, sender});
this.currentMessage = "";
}
/**
* Extracts an image from a schema object
* @param {Object} schema_object - The schema object
* @returns {string|null} The image URL or null
*/
extractImage(schema_object) {
if (schema_object && schema_object.image) {
return this.extractImageInternal(schema_object.image);
}
return null;
}
/**
* Extracts an image URL from an image object
* @param {Object|string} image - The image object or URL
* @returns {string|null} The image URL or null
*/
extractImageInternal(image) {
if (typeof image === 'string') {
return image;
} else if (typeof image === 'object' && image.url) {
return image.url;
} else if (typeof image === 'object' && image.contentUrl) {
return image.contentUrl;
} else if (image instanceof Array) {
if (image[0] && typeof image[0] === 'string') {
return image[0];
} else if (image[0] && typeof image[0] === 'object') {
return this.extractImageInternal(image[0]);
}
}
return null;
}
/**
* Creates an HTML element for a JSON item
* @param {Object} item - The item object
* @returns {HTMLElement} The item element
*/
createJsonItemHtml(item) {
return createJsonItemHtml(item, this);
}
/**
* Creates an HTML element for an intermediate message
* @param {string} message - The message text
* @returns {HTMLElement} The message element
*/
createIntermediateMessageHtml(message) {
return createIntermediateMessageHtml(message);
}
/**
* Creates an HTML element for a memory message
* @param {string} itemToRemember - The item to remember
* @param {Object} chatInterface - The chat interface instance
* @returns {HTMLElement|null} The message element or null
*/
memoryMessage(itemToRemember, chatInterface) {
return memoryMessage(itemToRemember, chatInterface);
}
/**
* Creates an HTML element for an ask user message
* @param {string} message - The message text
* @param {Object} chatInterface - The chat interface instance
*/
askUserMessage(message, chatInterface) {
askUserMessage(message, chatInterface);
}
/**
* Creates an HTML element for a site is irrelevant to query message
* @param {string} message - The message text
* @param {Object} chatInterface - The chat interface instance
*/
siteIsIrrelevantToQuery(message, chatInterface) {
siteIsIrrelevantToQuery(message, chatInterface);
}
/**
* Creates an HTML element for an item details message
* @param {string} itemDetails - The item details
* @param {Object} chatInterface - The chat interface instance
* @returns {HTMLElement|null} The message element or null
*/
itemDetailsMessage(itemDetails, chatInterface) {
return itemDetailsMessage(itemDetails, chatInterface);
}
/**
* Resorts the results based on score
* @param {Object} chatInterface - The chat interface instance
*/
resortResults(chatInterface) {
resortResults(chatInterface);
}
/**
* Creates an HTML element for insufficient results
* @returns {HTMLElement} The message element
*/
createInsufficientResultsHtml() {
return createInsufficientResultsHtml(this);
}
/**
* Handles the first message from the server
* @param {Object} event - The event object
*/
handleFirstMessage(event) {
handleFirstMessage(event, this);
}
/**
* Possibly annotates the user query with decontextualized query
* @param {Object} chatInterface - The chat interface instance
* @param {string} decontextualizedQuery - The decontextualized query
*/
possiblyAnnotateUserQuery(chatInterface, decontextualizedQuery) {
possiblyAnnotateUserQuery(chatInterface);
}
/**
* Gets a response from the server
* @param {string} message - The message to send
*/
async getResponse(message) {
// Add loading state
const loadingDots = '...';
this.addMessage(loadingDots, 'assistant');
this.dotsStillThere = true;
try {
console.log("generate_mode", this.generate_mode);
const selectedSite = this.site || (this.siteSelect && this.siteSelect.value ? this.siteSelect.value : '');
const prev = JSON.stringify(this.prevMessages);
const generate_mode = this.generate_mode;
const context_url = this.context_url && this.context_url.value ? this.context_url.value : '';
// Generate a unique query ID based on query arguments and current timestamp
const timestamp = new Date().getTime();
const queryId = `query_${timestamp}_${Math.floor(Math.random() * 1000)}`;
console.log("Generated query ID:", queryId);
// Add the query ID to the request parameters
const queryParams = new URLSearchParams();
queryParams.append('query_id', queryId);
queryParams.append('query', message);
if (selectedSite) {
queryParams.append('site', selectedSite);
}
queryParams.append('generate_mode', generate_mode);
queryParams.append('prev', prev);
queryParams.append('item_to_remember', this.itemToRemember || '');
queryParams.append('context_url', context_url);
const queryString = queryParams.toString();
const url = `${this.apiEndpoint}?${queryString}`;
console.log("url", url);
this.eventSource = new ManagedEventSource(url);
this.eventSource.query_id = queryId;
this.eventSource.connect(this);
this.prevMessages.push(message);
} catch (error) {
console.error('Error fetching response:', error);
}
}
/**
* Creates a debug string for the current items
* @returns {string} The debug string
*/
createDebugString() {
return jsonLdToHtml(this.currentItems);
}
/**
* Clears the chat history
*/
clearHistory() {
this.messagesArea.innerHTML = "";
this.messages = [];
this.prevMessages = [];
}
}
/**
* Initialization script for the ChatInterface
*/
// Make ChatInterface available globally
window.ChatInterface = ChatInterface;
// Single global chat interface instance
let chatInterface = null;
/**
* Finds or creates the chat interface
* @param {string} site - The site to search
* @param {string} display_mode - The display mode (dropdown or full)
* @param {string} generate_mode - The generate mode (list, summarize, generate)
* @param {string} apiEndpoint - The API endpoint for the chat
* @returns {ChatInterface} The chat interface instance
*/
function findChatInterface(site = '', display_mode = 'nlwebsearch', generate_mode = 'generate', apiEndpoint = null) {
// Use the configured API endpoint if not provided
if (!apiEndpoint) {
apiEndpoint = window.chatApiEndpoint || 'http://localhost:8000/ask';
}
if (chatInterface) {
return chatInterface;
}
chatInterface = new ChatInterface(site, display_mode, generate_mode, apiEndpoint);
return chatInterface;
}
// Make findChatInterface available globally
window.findChatInterface = findChatInterface;
/**
* Handles a search query
*/
function handleSearch() {
const searchInput = document.getElementById('ai-search-input');
const chatContainer = document.getElementById('chat-container');
const query = searchInput.value.trim();
chatContainer.style.display = 'block';
// Find or create the chat interface
const chat = findChatInterface();
// Clear the search input
searchInput.value = '';
// Send the query to the chat interface
chat.sendMessage(query);
}
// Initialize the chat interface when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
const searchInput = document.getElementById('ai-search-input');
const searchButton = document.getElementById('ai-search-button');
const closeIcon = document.getElementById('close-icon');
// Add event listeners
if (searchButton) {
searchButton.addEventListener('click', handleSearch);
}
if (searchInput) {
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleSearch();
}
});
}
if (closeIcon) {
closeIcon.addEventListener('click', () => {
const chatContainer = document.getElementById('chat-container');
if (chatContainer) {
chatContainer.style.display = 'none';
}
});
}
});
export { findChatInterface };
//# sourceMappingURL=nlweb-js-client.esm.js.map