Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 35 additions & 43 deletions hlx_statics/blocks/ai-assistant/ai-assistant_api-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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=3]
*/

/**
Expand Down Expand Up @@ -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
*/
Expand All @@ -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";
Expand Down Expand Up @@ -239,6 +263,8 @@ export class AiApiClient {
onContent = () => {},
onCitation = () => {},
onTiming = () => {},
onAnswerComplete = () => {},
onFollowupQuestions = () => {},
onComplete = () => {},
onError = () => {},
}) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>} The generated text response
*/
async collectResponse({ query, context = "", systemPrompt = "" }) {
const body = {
query: `
<system>
${systemPrompt}
</system>
${context ? `<history>\n${context}\n</history>` : ""}
<question>
${query}
</question>
`,
};

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.
*
Expand Down Expand Up @@ -411,6 +401,8 @@ export class AiApiClient {
${query}
</question>
`,
includeFollowupQuestions: true,
followupQuestionsCount: 2,
};
if (collectionId) {
body.collectionId = collectionId;
Expand Down
124 changes: 64 additions & 60 deletions hlx_statics/blocks/ai-assistant/ai-assistant_chat-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
} from "./ai-assistant_constants.js";
import {
hideSuggestedQuestions,
parseAiSuggestedQuestions,
showSuggestedQuestions,
updateSuggestedQuestions,
} from "./ai-assistant_suggested-questions.js";
Expand Down Expand Up @@ -261,42 +260,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: <short summary describing the question>
text: <full question to send to the AI>
---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");
Expand Down Expand Up @@ -386,6 +349,46 @@ export const handleUserQuery = async (
let responseContent = "";
/** @type {import('./ai-assistant_chat-history.js').ChatReference[]} */
let accumulatedReferences = [];
// The backend marks the answer done with an `answerComplete` event and sends
// follow-ups in a later `followupQuestions` event; both arrive before the
// terminal `complete` event. These flags let `onComplete` act as a safety net
// when the stream is aborted (or the backend omits an event) so we always
// finalize the bubble and show some suggestions.
let answerFinalized = false;
let followupsReceived = false;

const scrollToBottom = () => {
if (!userScrolledUp && ELEMENTS.CHAT_WINDOW_CONTENT) {
ELEMENTS.CHAT_WINDOW_CONTENT.scrollTop =
ELEMENTS.CHAT_WINDOW_CONTENT.scrollHeight;
}
};

const revealSuggestedQuestions = () => {
window.setTimeout(
() => showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }),
suggestedQuestionsDelayMs,
);
};

const showFallbackSuggestions = () => {
updateSuggestedQuestions(INITIAL_SUGGESTED_QUESTIONS);
revealSuggestedQuestions();
};

// 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();
};

showStopButton();

Expand Down Expand Up @@ -443,43 +446,44 @@ export const handleUserQuery = async (
}
}
},
onComplete: async () => {
// Fired once the answer text is complete, before the follow-up questions.
onAnswerComplete: () => {
finalizeAnswer();
updateSuggestedQuestions(null);
revealSuggestedQuestions();
},
// 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 event. This acts as a
// safety net for aborted streams or a backend that omits those events.
onComplete: () => {
hideStopButton();
setResponding(false);
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,
);
showFallbackSuggestions();
return;
}
targetBubble.completeBubble();
finalizeAnswer();
// 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;
if (!followupsReceived) {
showFallbackSuggestions();
}

updateSuggestedQuestions(null);
window.setTimeout(
() =>
showSuggestedQuestions({ shouldScrollIntoView: !userScrolledUp }),
suggestedQuestionsDelayMs,
);
await fetchAiSuggestedQuestions();
},
onError: (error) => {
hideStopButton();
Expand Down
14 changes: 0 additions & 14 deletions hlx_statics/blocks/ai-assistant/ai-assistant_chat-history.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,20 +173,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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading