diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js b/hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js index 7142ae37..1718b6fc 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js @@ -7,6 +7,8 @@ import { isProdEnvironment } from "../../scripts/lib-adobeio.js"; * @property {string} query * @property {string} [collectionId] * @property {string} [sessionId] + * @property {boolean} [includeFollowupQuestions] + * @property {number} [followupQuestionsCount=2] - Count the client requests; the backend falls back to 3 when omitted. */ /** @@ -79,12 +81,35 @@ import { isProdEnvironment } from "../../scripts/lib-adobeio.js"; * @property {CompleteTimingData} timingDataMs */ +/** + * Marks the end of the generated answer. Arrives after all content/citation + * events but before the trailing `followupQuestions` and `complete` events. + * @typedef {Object} AnswerCompleteEvent + * @property {'answerComplete'} type + * @property {CompleteTimingData} timingDataMs + */ + +/** + * @typedef {Object} FollowupQuestion + * @property {string} label - Short summary shown on the suggestion button + * @property {string} text - Full question sent to the AI when clicked + */ + +/** + * @typedef {Object} FollowupQuestionsEvent + * @property {'followupQuestions'} type + * @property {FollowupQuestion[]} followupQuestions + * @property {Object} timingDataMs + */ + /** * @typedef {Object} StreamRequestCallbacks * @property {(event: MetadataEvent) => void} onMetadata * @property {(event: ContentEvent) => void} onContent * @property {(event: CitationEvent) => void} onCitation * @property {(event: TimingEvent) => void} onTiming + * @property {(event: AnswerCompleteEvent) => void} onAnswerComplete + * @property {(event: FollowupQuestionsEvent) => void} onFollowupQuestions * @property {(event?: CompleteEvent) => void} onComplete * @property {(error: unknown) => void} onError */ @@ -99,7 +124,6 @@ const IS_PROD = isProdEnvironment(window.location.host); export class AiApiClient { static STREAMING_ENDPOINT = "/retrieve/generate/stream"; - static NON_STREAMING_ENDPOINT = "/retrieve/generate"; static COLLECTIONS_ENDPOINT = "/collections"; static FEEDBACK_ENDPOINT = "/feedback"; static LOCAL_STORAGE_COLLECTIONS_KEY = "ai-assistant__collections"; @@ -239,6 +263,8 @@ export class AiApiClient { onContent = () => {}, onCitation = () => {}, onTiming = () => {}, + onAnswerComplete = () => {}, + onFollowupQuestions = () => {}, onComplete = () => {}, onError = () => {}, }) { @@ -302,6 +328,12 @@ export class AiApiClient { case "timing": onTiming(data); break; + case "answerComplete": + onAnswerComplete(data); + break; + case "followupQuestions": + onFollowupQuestions(data); + break; case "complete": onComplete(data); return; @@ -337,48 +369,6 @@ export 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 streaming query request. * @@ -411,6 +401,8 @@ export class AiApiClient { ${query} `, + includeFollowupQuestions: true, + followupQuestionsCount: 2, }; if (collectionId) { body.collectionId = collectionId; diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js index 784bd140..bb8fbdd2 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js @@ -15,7 +15,6 @@ import { } from "./ai-assistant_constants.js"; import { hideSuggestedQuestions, - parseAiSuggestedQuestions, showSuggestedQuestions, updateSuggestedQuestions, } from "./ai-assistant_suggested-questions.js"; @@ -23,6 +22,7 @@ import { announce, setResponding } from "./ai-assistant_announcer.js"; let userScrolledUp = false; let lastScrollTop = 0; +let isResponding = false; /** @param {KeyboardEvent} e */ const escapeKeyHandler = (e) => { @@ -99,6 +99,22 @@ export const onUserScroll = (event) => { } }; +/** + * Scrolls the chat content area to the bottom. + * @param {Object} [options] + * @param {boolean} [options.force=false] - Scroll even if the user has scrolled up + */ +const scrollToBottom = ({ force = false } = {}) => { + if (!ELEMENTS.CHAT_WINDOW_CONTENT) { + return; + } + + if (force || !userScrolledUp) { + ELEMENTS.CHAT_WINDOW_CONTENT.scrollTop = + ELEMENTS.CHAT_WINDOW_CONTENT.scrollHeight; + } +}; + /** * @param {Partial<{delay: number}>} [options] */ @@ -261,42 +277,6 @@ export const toggleChatWindow = () => { } }; -/** - * Fetches AI-generated follow-up questions and updates the suggestions panel. - * Falls back to static questions on any error or parse failure. - */ -export const fetchAiSuggestedQuestions = async () => { - const query = `Please suggest 2 follow-up questions based on our conversation to make the users happy.`; - 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); - if (parsed.length > 0) { - updateSuggestedQuestions(parsed); - } else { - updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); - } - } catch (error) { - console.warn( - "[AI Assistant] Failed to fetch AI suggested questions:", - error, - ); - updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); - } -}; - const showStopButton = () => { const btn = /** @type {HTMLButtonElement} */ (ELEMENTS.CHAT_SEND_BUTTON); const btnImage = btn.querySelector("img"); @@ -338,6 +318,8 @@ export const handleUserQuery = async ( messageContentOverride, collectionId = null, ) => { + if (isResponding) return; + userScrolledUp = false; lastScrollTop = 0; let messageContent = messageContentOverride; @@ -353,6 +335,8 @@ export const handleUserQuery = async ( return; } + isResponding = true; + hideSuggestedQuestions(); // Clicking a suggested-question button then hides that button, which would @@ -362,8 +346,6 @@ export const handleUserQuery = async ( // already sits after a typed submission. textarea.focus(); - const suggestedQuestionsDelayMs = 600; - sendMessage({ content: messageContent, source: "user" }); // a11y: confirm the message that was just sent. This matters most for // suggested-question clicks, where the sent text differs from the button's @@ -372,7 +354,7 @@ export const handleUserQuery = async ( const targetBubble = sendMessage({ content: "Thinking", source: "ai" }); targetBubble.showThinking(); - // a11y: announce the "responding" state while thinking/streaming is active, + // a11y: announce the "responding" state while thinking/streaming is active. setResponding(true); const showErrorMessage = (message = GENERIC_ERROR_MESSAGE) => { @@ -386,6 +368,43 @@ export const handleUserQuery = async ( let responseContent = ""; /** @type {import('./ai-assistant_chat-history.js').ChatReference[]} */ let accumulatedReferences = []; + // The backend sends `answerComplete`, then `followupQuestions`, then the + // terminal `complete`. These flags let `onComplete` act as a safety net: + // finalizing the bubble and falling back to default suggestions when those + // earlier events don't arrive (e.g. an aborted stream). + let answerFinalized = false; + let followupsReceived = false; + + const showFallbackSuggestions = () => { + updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); + showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }); + }; + + // Marks the bubble complete: reveals the feedback/copy buttons, decorates code + // blocks and persists the final content. + const finalizeAnswer = () => { + if (answerFinalized) return; + answerFinalized = true; + targetBubble.hideThinking(); + targetBubble.completeBubble(); + chatHistory.updateLast({ + content: responseContent, + references: accumulatedReferences, + }); + scrollToBottom(); + }; + + // Ends the visible "responding" state: reverts the stop button to send, + // clears the a11y busy indicator and announces the finished reply. + let respondingEnded = false; + const endResponding = () => { + if (respondingEnded) return; + respondingEnded = true; + hideStopButton(); + setResponding(false); + // a11y: announce the completed reply once, as plain text. + announce(`${CHAT_BUBBLE_AI_LABEL}: ${targetBubble.getPlainText()}`); + }; showStopButton(); @@ -410,10 +429,7 @@ export const handleUserQuery = async ( targetBubble.hideThinking(); targetBubble.showStreamingCursor(); targetBubble.updateContent(responseContent); - if (!userScrolledUp && ELEMENTS.CHAT_WINDOW_CONTENT) { - ELEMENTS.CHAT_WINDOW_CONTENT.scrollTop = - ELEMENTS.CHAT_WINDOW_CONTENT.scrollHeight; - } + scrollToBottom(); } }, onCitation: (data) => { @@ -436,64 +452,57 @@ export const handleUserQuery = async ( content: responseContent, references, }); - if (!userScrolledUp && ELEMENTS.CHAT_WINDOW_CONTENT) { - ELEMENTS.CHAT_WINDOW_CONTENT.scrollTop = - ELEMENTS.CHAT_WINDOW_CONTENT.scrollHeight; - } + scrollToBottom(); } } }, - onComplete: async () => { - hideStopButton(); - setResponding(false); + // Fired once the answer text is complete, before the follow-up questions. + onAnswerComplete: () => { + finalizeAnswer(); + endResponding(); + updateSuggestedQuestions(null); + showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }); + }, + // Fired with the backend-generated follow-up questions. + onFollowupQuestions: (data) => { + followupsReceived = true; + const questions = (data.followupQuestions ?? []) + .map(({ label, text }) => ({ label, question: text })) + .filter(({ label, question }) => label && question); + updateSuggestedQuestions( + questions.length > 0 ? questions : INITIAL_SUGGESTED_QUESTIONS, + ); + }, + // Terminal safety net: covers aborted/partial streams where + // `answerComplete` never fired, and always reveals the suggestions. + onComplete: () => { if (!responseContent) { targetBubble.hideThinking(); + targetBubble.hideStreamingCursor(); responseContent = "_Response stopped by user._"; targetBubble.updateContent(responseContent); - // a11y: announce the completed reply once, as plain text, - // from the final content only - announce(`${CHAT_BUBBLE_AI_LABEL}: ${targetBubble.getPlainText()}`); - updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); - window.setTimeout( - () => - showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }), - suggestedQuestionsDelayMs, - ); + endResponding(); + showFallbackSuggestions(); return; } - targetBubble.completeBubble(); - // a11y: announce the completed reply once, as plain text. - announce(`${CHAT_BUBBLE_AI_LABEL}: ${targetBubble.getPlainText()}`); - chatHistory.updateLast({ - content: responseContent, - references: accumulatedReferences, - }); - if (!userScrolledUp && ELEMENTS.CHAT_WINDOW_CONTENT) { - ELEMENTS.CHAT_WINDOW_CONTENT.scrollTop = - ELEMENTS.CHAT_WINDOW_CONTENT.scrollHeight; + finalizeAnswer(); + endResponding(); + if (!followupsReceived) { + showFallbackSuggestions(); + } else { + showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }); } - - updateSuggestedQuestions(null); - window.setTimeout( - () => - showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }), - suggestedQuestionsDelayMs, - ); - await fetchAiSuggestedQuestions(); }, onError: (error) => { hideStopButton(); // TODO: Log error somehow somewhere? console.error("[AI Assistant] Error:", error); showErrorMessage(); - updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS); - window.setTimeout( - () => - showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }), - suggestedQuestionsDelayMs, - ); + showFallbackSuggestions(); }, }, + }).finally(() => { + isResponding = false; }); }; @@ -543,7 +552,7 @@ const sendMessage = ({ } else { contentContainer.appendChild(bubble.element); } - contentContainer.scrollTop = contentContainer.scrollHeight; + scrollToBottom({ force: true }); } return bubble; @@ -572,10 +581,7 @@ export const restoreChatHistory = async () => { bubble.appendReferences(references); } } - if (ELEMENTS.CHAT_WINDOW_CONTENT) { - ELEMENTS.CHAT_WINDOW_CONTENT.scrollTop = - ELEMENTS.CHAT_WINDOW_CONTENT.scrollHeight; - } + scrollToBottom({ force: true }); } const lastMessage = chatHistory.getAll().pop(); if (lastMessage?.source === "ai") { diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js index 0f3c7277..d061eb07 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js @@ -162,9 +162,10 @@ export class ChatHistory { } /** - * Stores the Bedrock session id for the current conversation. Overwrites any - * existing value, so a fresh id minted after an expiry-triggered reset is - * reused transparently on subsequent requests. No-ops when unchanged. + * Stores the Bedrock session id for the current conversation, overwriting any + * existing value. Recovery from an expired id is handled by the backend: it + * mints a fresh session and returns the new id in the `metadata` event, which + * we simply persist here. No-ops when unchanged. * @param {string|null} sessionId */ setSessionId(sessionId) { @@ -173,20 +174,6 @@ export class ChatHistory { this._save({ ...conversation, sessionId }); } - /** - * 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 } = {}) { - const messages = this.getAll(); - const sliced = excludeLast > 0 ? messages.slice(0, -excludeLast) : messages; - return sliced - .map(({ source, content }) => JSON.stringify({ source, content })) - .join("\n"); - } - /** * Clears all history */ diff --git a/hlx_statics/blocks/ai-assistant/ai-assistant_suggested-questions.js b/hlx_statics/blocks/ai-assistant/ai-assistant_suggested-questions.js index 2236de57..78d813de 100644 --- a/hlx_statics/blocks/ai-assistant/ai-assistant_suggested-questions.js +++ b/hlx_statics/blocks/ai-assistant/ai-assistant_suggested-questions.js @@ -7,31 +7,6 @@ import { INITIAL_SUGGESTED_QUESTIONS, } from "./ai-assistant_constants.js"; -/** - * 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 - */ -export 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, id?: string|null}>|null} questions - Questions to show, or null for skeleton @@ -102,15 +77,16 @@ export const createSuggestedQuestionsSection = () => { }; /** - * Shows the suggested questions section with optional scroll behavior. + * Reveals the suggested questions section (fade-in + optional scroll). No-ops + * unless the section is currently `hidden`. * @param {Object} [options={}] - Options object - * @param {boolean} [options.shouldScrollIntoView=true] - Whether to scroll the element into view + * @param {boolean} [options.shouldScrollIntoView=true] - Scroll the section into view on reveal. Only applies when the section is `hidden` (i.e. actually being revealed). */ export const showSuggestedQuestions = ({ shouldScrollIntoView = true, } = {}) => { const el = ELEMENTS.CHAT_SUGGESTED_QUESTIONS; - if (el) { + if (el?.classList.contains("hidden")) { el.classList.remove("hidden"); el.classList.remove("animate-fade-in"); requestAnimationFrame(() => {