From 3f804f29e5d333eb6dd05929fd548fd93866851a Mon Sep 17 00:00:00 2001 From: David Sima Date: Thu, 12 Mar 2026 17:36:46 +0200 Subject: [PATCH 1/5] feat: enhance AI assistant with dynamic suggested questions - Updated CSS for suggested questions buttons, adding flex layout and loading styles. - Refactored JavaScript to include non-streaming query functionality for generating suggested questions. - Implemented parsing logic for AI-generated questions and updated the UI accordingly. - Added error handling to fallback on static questions if AI suggestions fail. --- .../blocks/ai-assistant/ai-assistant.css | 21 +++ .../blocks/ai-assistant/ai-assistant.js | 176 ++++++++++++++++-- hlx_statics/icons/arrow-curved.svg | 3 + 3 files changed, 181 insertions(+), 19 deletions(-) create mode 100644 hlx_statics/icons/arrow-curved.svg diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant.css b/hlx_statics/blocks/ai-assistant/ai-assistant.css index b561d79e..8714f504 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant.css +++ b/hlx_statics/blocks/ai-assistant/ai-assistant.css @@ -216,6 +216,9 @@ } .chat-suggested-questions-button { + display: flex; + align-items: center; + gap: 6px; padding: 8px 12px; border: none; border-radius: 8px; @@ -227,10 +230,28 @@ font-family: "adobe-clean", "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Trebuchet MS", "Lucida Grande", sans-serif; cursor: pointer; + img { + width: 16px; + height: 16px; + flex-shrink: 0; + } + &:hover { background: #dadada; } } + + .chat-suggested-questions-loading { + font-size: 13px; + color: #888; + opacity: 0.8; + margin: 0; + + &::after { + content: ''; + animation: ellipsis 1.5s infinite; + } + } } } diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant.js b/hlx_statics/blocks/ai-assistant/ai-assistant.js index 0efd7c40..6188662a 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant.js @@ -383,13 +383,15 @@ class ChatHistory { } /** - * Gets messages formatted for AI context (excludes last N messages) - * @param {number} excludeLast - Number of recent messages to exclude + * Gets messages formatted for AI context + * @param {Object} [options] + * @param {number} [options.excludeLast=2] - Number of recent messages to exclude (0 = include all) * @returns {string} Formatted context string */ - getContextForAI(excludeLast = 2) { - return this.getAll() - .slice(0, -excludeLast) + getContextForAI({ excludeLast = 2 } = {}) { + const messages = this.getAll(); + const sliced = excludeLast > 0 ? messages.slice(0, -excludeLast) : messages; + return sliced .map(({ source, content }) => JSON.stringify({ source, content })) .join("\n"); } @@ -461,6 +463,7 @@ class ChatHistory { // #region AiApiClient class AiApiClient { static STREAMING_ENDPOINT = "/v1/inference/retrieve/generate/stream"; + static NON_STREAMING_ENDPOINT = "/v1/inference/retrieve/generate"; /** * @param {Object} config * @param {string} config.baseUrl @@ -590,6 +593,48 @@ class AiApiClient { } } + /** + * Makes a non-streaming query and returns the full response text. + * Used for background tasks like generating suggested questions. + * @param {Object} options + * @param {string} options.query - The query to send + * @param {string} [options.context] - Optional conversation context/history + * @param {string} [options.systemPrompt] - Optional system prompt + * @returns {Promise} The generated text response + */ + async collectResponse({ query, context = "", systemPrompt = "" }) { + const body = { + query: ` + + ${systemPrompt} + + ${context ? `\n${context}\n` : ""} + + ${query} + + `, + }; + + const response = await fetch( + `${this.baseUrl}${AiApiClient.NON_STREAMING_ENDPOINT}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Api-Key": this.apiKey, + }, + body: JSON.stringify(body), + }, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.generatedText || ""; + } + /** * Makes a query request with conversation history * @param {Object} options - Query options @@ -771,30 +816,84 @@ const createChatButton = () => { }; /** - * Creates the suggested questions section with topic buttons. - * @returns {HTMLElement} The suggested questions wrapper element + * Parses AI-generated suggested questions from the ---question--- delimited format. + * @param {string} responseText - Raw text from the AI + * @returns {Array<{label: string, question: string}>} Parsed questions, or empty array on failure */ -const createSuggestedQuestionsSection = () => { - const wrapper = createTag("div", { class: "chat-suggested-questions" }); - const title = createTag("p", { class: "chat-suggested-questions-title" }); - title.textContent = "or choose from the following:"; - const list = createTag("div", { class: "chat-suggested-questions-list" }); +const parseAiSuggestedQuestions = (responseText) => { + if (!responseText) return []; + const questions = []; + const segments = responseText.split(/---question---/); + for (const segment of segments) { + const trimmed = segment.trim(); + if (!trimmed) continue; + const labelMatch = trimmed.match(/^label:\s*(.+)$/m); + const textMatch = trimmed.match(/^text:\s*(.+)$/m); + if (labelMatch && textMatch) { + const label = labelMatch[1].trim(); + const question = textMatch[1].trim(); + if (label && question) { + questions.push({ label, question }); + } + } + } + return questions; +}; + +/** + * Updates the suggested questions list with new questions or a loading skeleton. + * @param {Array<{label: string, question: string}>|null} questions - Questions to show, or null for skeleton + */ +const updateSuggestedQuestions = (questions) => { + const wrapper = ELEMENTS.CHAT_SUGGESTED_QUESTIONS; + if (!wrapper) return; + const list = wrapper.querySelector(".chat-suggested-questions-list"); + if (!list) return; + + list.replaceChildren(); + + if (questions === null) { + const loadingEl = createTag("p", { class: "chat-suggested-questions-loading" }); + loadingEl.textContent = "Generating suggestions"; + list.appendChild(loadingEl); + return; + } - SUGGESTED_QUESTIONS.forEach(({ label, question }) => { + questions.forEach(({ label, question }) => { const button = createTag("button", { type: "button", class: "chat-suggested-questions-button", }); - button.textContent = label; + const icon = createTag("img", { + src: "/hlx_statics/icons/arrow-curved.svg", + alt: "", + "aria-hidden": true, + }); + button.appendChild(icon); + button.appendChild(document.createTextNode(label)); button.addEventListener("click", () => { handleUserQuery(question); }); list.appendChild(button); }); +}; + +/** + * Creates the suggested questions section with topic buttons. + * @returns {HTMLElement} The suggested questions wrapper element + */ +const createSuggestedQuestionsSection = () => { + const wrapper = createTag("div", { class: "chat-suggested-questions" }); + const title = createTag("p", { class: "chat-suggested-questions-title" }); + title.textContent = "or choose from the following:"; + const list = createTag("div", { class: "chat-suggested-questions-list" }); wrapper.appendChild(title); wrapper.appendChild(list); ELEMENTS.CHAT_SUGGESTED_QUESTIONS = wrapper; + + updateSuggestedQuestions(SUGGESTED_QUESTIONS); + return wrapper; }; @@ -879,6 +978,38 @@ const toggleChatWindow = () => { } }; +/** + * Fetches AI-generated follow-up questions and updates the suggestions panel. + * Falls back to static questions on any error or parse failure. + */ +const fetchAiSuggestedQuestions = async () => { + const query = `Please suggest 2 follow-up questions based on our conversation to make the users.`; + const systemPrompt = ` + Structured questions format: + ---question--- + label: + text: + ---question--- + This will make the users happy and keep the conversation going and we want our users to be happy!`; + + const context = chatHistory.getContextForAI({ excludeLast: 0 }); + try { + const rawResponse = await aiApiClient.collectResponse({ + query, + systemPrompt, + context, + }); + const parsed = parseAiSuggestedQuestions(rawResponse); + updateSuggestedQuestions(parsed.length > 0 ? parsed : SUGGESTED_QUESTIONS); + } catch (error) { + console.warn( + "[AI Assistant] Failed to fetch AI suggested questions:", + error, + ); + updateSuggestedQuestions(SUGGESTED_QUESTIONS); + } +}; + /** * Gets the user's query, sends it to the AI, and displays the response. * @param {string} [messageContentOverride] - Optional message content; when provided, used instead of the textarea value @@ -915,7 +1046,7 @@ const handleUserQuery = async (messageContentOverride) => { // TODO: We'll have to decide how much context to send to the AI. // -2 because we want to exclude the current user message and the thinking message - const queryContext = chatHistory.getContextForAI(2); + const queryContext = chatHistory.getContextForAI({ excludeLast: 2 }); let responseContent = ""; let accumulatedReferences = []; @@ -965,28 +1096,34 @@ const handleUserQuery = async (messageContentOverride) => { } } }, - onComplete: () => { + onComplete: async () => { hideStopButton(); if (!responseContent) { targetBubble.hideThinking(); responseContent = "_Response stopped by user._"; targetBubble.updateContent(responseContent); - } else { - targetBubble.hideStreamingCursor(); - targetBubble.showCopyButton(); + updateSuggestedQuestions(SUGGESTED_QUESTIONS); + window.setTimeout(showSuggestedQuestions, suggestedQuestionsDelayMs); + return; } + targetBubble.hideStreamingCursor(); + targetBubble.showCopyButton(); chatHistory.updateLast({ content: responseContent, references: accumulatedReferences, }); targetBubble.scrollIntoView(); + + updateSuggestedQuestions(null); window.setTimeout(showSuggestedQuestions, suggestedQuestionsDelayMs); + await fetchAiSuggestedQuestions(); }, onError: (error) => { hideStopButton(); // TODO: Log error somehow somewhere? console.error("[AI Assistant] Error:", error); showErrorMessage(); + updateSuggestedQuestions(SUGGESTED_QUESTIONS); window.setTimeout(showSuggestedQuestions, suggestedQuestionsDelayMs); }, }, @@ -1073,6 +1210,7 @@ const restoreChatHistory = () => { } const lastMessage = chatHistory.getAll().pop(); if (lastMessage?.source === "ai") { + updateSuggestedQuestions(SUGGESTED_QUESTIONS); showSuggestedQuestions(); } else { hideSuggestedQuestions(); diff --git a/hlx_statics/icons/arrow-curved.svg b/hlx_statics/icons/arrow-curved.svg new file mode 100644 index 00000000..dd8dc46e --- /dev/null +++ b/hlx_statics/icons/arrow-curved.svg @@ -0,0 +1,3 @@ + From d9c64aa6f93e41968a5437c236f13d66fd631c68 Mon Sep 17 00:00:00 2001 From: David Sima Date: Wed, 18 Mar 2026 18:26:25 +0200 Subject: [PATCH 2/5] feat: add memoized getCollections() to AiApiClient --- .../blocks/ai-assistant/ai-assistant.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant.js b/hlx_statics/blocks/ai-assistant/ai-assistant.js index 6188662a..a4384a4e 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant.js @@ -464,6 +464,7 @@ class ChatHistory { class AiApiClient { static STREAMING_ENDPOINT = "/v1/inference/retrieve/generate/stream"; static NON_STREAMING_ENDPOINT = "/v1/inference/retrieve/generate"; + static COLLECTIONS_ENDPOINT = "/v1/inference/collections"; /** * @param {Object} config * @param {string} config.baseUrl @@ -476,6 +477,34 @@ class AiApiClient { this.baseUrl = baseUrl; this.apiKey = apiKey; this.abortController = null; + this._collectionsPromise = null; + } + + /** + * Fetches available collections from the RAG API. + * Result is memoized on this instance — at most one network call is made per page load. + * @returns {Promise>} + */ + getCollections() { + if (this._collectionsPromise) return this._collectionsPromise; + + this._collectionsPromise = fetch(`${this.baseUrl}${AiApiClient.COLLECTIONS_ENDPOINT}`, { + headers: { + "Content-Type": "application/json", + "X-Api-Key": this.apiKey, + }, + }) + .then((res) => { + if (!res.ok) throw new Error(`Collections fetch failed: ${res.status}`); + return res.json(); + }) + .catch((err) => { + console.warn("[AI Assistant] Failed to fetch collections:", err); + // Do NOT reset _collectionsPromise — one call per page load, even on error. + return []; + }); + + return this._collectionsPromise; } /** From 6d93420608b29c9cc40717dc2587746fe1f5091e Mon Sep 17 00:00:00 2001 From: David Sima Date: Wed, 18 Mar 2026 18:28:16 +0200 Subject: [PATCH 3/5] feat: pass collectionId through query chain to API request body --- hlx_statics/blocks/ai-assistant/ai-assistant.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant.js b/hlx_statics/blocks/ai-assistant/ai-assistant.js index a4384a4e..6bdbefc1 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant.js @@ -673,7 +673,7 @@ class AiApiClient { * @param {Object} options.callbacks - Event callbacks (onMetadata, onContent, etc.) * @returns {Promise} */ - async query({ query, context = "", systemPrompt = "", callbacks = {} }) { + async query({ query, context = "", systemPrompt = "", collectionId = null, callbacks = {} }) { const defaultSystemPrompt = ` Use markdown formatting for the response. `; @@ -689,6 +689,9 @@ class AiApiClient { `, }; + if (collectionId) { + body.collectionId = collectionId; + } return this.streamRequest({ body, @@ -888,7 +891,7 @@ const updateSuggestedQuestions = (questions) => { return; } - questions.forEach(({ label, question }) => { + questions.forEach(({ id, label, question }) => { const button = createTag("button", { type: "button", class: "chat-suggested-questions-button", @@ -901,7 +904,7 @@ const updateSuggestedQuestions = (questions) => { button.appendChild(icon); button.appendChild(document.createTextNode(label)); button.addEventListener("click", () => { - handleUserQuery(question); + handleUserQuery(question, id ?? null); }); list.appendChild(button); }); @@ -1043,7 +1046,7 @@ const fetchAiSuggestedQuestions = async () => { * Gets the user's query, sends it to the AI, and displays the response. * @param {string} [messageContentOverride] - Optional message content; when provided, used instead of the textarea value */ -const handleUserQuery = async (messageContentOverride) => { +const handleUserQuery = async (messageContentOverride, collectionId = null) => { let messageContent = messageContentOverride; if (!messageContentOverride) { @@ -1085,6 +1088,7 @@ const handleUserQuery = async (messageContentOverride) => { await aiApiClient.query({ query: messageContent, context: queryContext, + collectionId, callbacks: { onMetadata: (data) => { if (data.sessionId) { From be0d6e0e36674cc8873a13f2351e861a6b8f2704 Mon Sep 17 00:00:00 2001 From: David Sima Date: Wed, 18 Mar 2026 18:32:11 +0200 Subject: [PATCH 4/5] feat: add getCollectionsQuestions() and wire up suggestion buttons from collections API --- .../blocks/ai-assistant/ai-assistant.js | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant.js b/hlx_statics/blocks/ai-assistant/ai-assistant.js index 6bdbefc1..95f9e643 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant.js @@ -910,6 +910,23 @@ const updateSuggestedQuestions = (questions) => { }); }; +/** + * Fetches collections and returns them as suggestion question objects. + * Falls back to SUGGESTED_QUESTIONS if the API returns no results. + * @returns {Promise>} + */ +const getCollectionsQuestions = async () => { + const rawCollections = await aiApiClient.getCollections(); + const questions = rawCollections + .filter((c) => c.id !== "__all-collections__") + .map((c) => ({ + id: c.id, + label: c.name, + question: `What can I learn from ${c.name}?`, + })); + return questions.length > 0 ? questions : SUGGESTED_QUESTIONS; +}; + /** * Creates the suggested questions section with topic buttons. * @returns {HTMLElement} The suggested questions wrapper element @@ -924,7 +941,7 @@ const createSuggestedQuestionsSection = () => { wrapper.appendChild(list); ELEMENTS.CHAT_SUGGESTED_QUESTIONS = wrapper; - updateSuggestedQuestions(SUGGESTED_QUESTIONS); + getCollectionsQuestions().then(updateSuggestedQuestions); return wrapper; }; @@ -1032,13 +1049,17 @@ const fetchAiSuggestedQuestions = async () => { context, }); const parsed = parseAiSuggestedQuestions(rawResponse); - updateSuggestedQuestions(parsed.length > 0 ? parsed : SUGGESTED_QUESTIONS); + if (parsed.length > 0) { + updateSuggestedQuestions(parsed); + } else { + updateSuggestedQuestions(await getCollectionsQuestions()); + } } catch (error) { console.warn( "[AI Assistant] Failed to fetch AI suggested questions:", error, ); - updateSuggestedQuestions(SUGGESTED_QUESTIONS); + updateSuggestedQuestions(await getCollectionsQuestions()); } }; @@ -1135,7 +1156,7 @@ const handleUserQuery = async (messageContentOverride, collectionId = null) => { targetBubble.hideThinking(); responseContent = "_Response stopped by user._"; targetBubble.updateContent(responseContent); - updateSuggestedQuestions(SUGGESTED_QUESTIONS); + updateSuggestedQuestions(await getCollectionsQuestions()); window.setTimeout(showSuggestedQuestions, suggestedQuestionsDelayMs); return; } @@ -1156,7 +1177,7 @@ const handleUserQuery = async (messageContentOverride, collectionId = null) => { // TODO: Log error somehow somewhere? console.error("[AI Assistant] Error:", error); showErrorMessage(); - updateSuggestedQuestions(SUGGESTED_QUESTIONS); + getCollectionsQuestions().then(updateSuggestedQuestions); window.setTimeout(showSuggestedQuestions, suggestedQuestionsDelayMs); }, }, From e84e268d66ec28e5ccec1818c437b72c9784c924 Mon Sep 17 00:00:00 2001 From: David Sima Date: Wed, 18 Mar 2026 18:32:51 +0200 Subject: [PATCH 5/5] feat: prefetch collections on block init to warm cache --- hlx_statics/blocks/ai-assistant/ai-assistant.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant.js b/hlx_statics/blocks/ai-assistant/ai-assistant.js index 95f9e643..abcb3881 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant.js @@ -1286,6 +1286,9 @@ const aiApiClient = new AiApiClient({ * @param {Element} block - the ai-assistant block element */ export default async function decorate(block) { + // Prefetch collections to warm cache — resolves before user opens chat + aiApiClient.getCollections(); + addExtraScriptWithLoad( document.body, "https://unpkg.com/marked@^17/lib/marked.umd.js",