From 6c091a9f90b6c7cdb1410a2f1c0d21d0995c20a1 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Thu, 25 Dec 2025 12:10:53 -0500 Subject: [PATCH 01/17] state schema adapted to generated answer conversation --- .../generated-answer-mocks.ts | 1 + .../generated-answer-state.ts | 71 +++++++++++-------- 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/headless/src/features/generated-answer/generated-answer-mocks.ts b/packages/headless/src/features/generated-answer/generated-answer-mocks.ts index c46e5ae674e..fa0ebc2be7e 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-mocks.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-mocks.ts @@ -1086,6 +1086,7 @@ export const streamAnswerAPIStateMock: StreamAnswerAPIState = { answerConfigurationId: 'c36fd994-3eb7-4aaf-bfce-2dad4b15a622', cannotAnswer: false, answerGenerationMode: 'automatic', + followUpAnswers: [], }, }; diff --git a/packages/headless/src/features/generated-answer/generated-answer-state.ts b/packages/headless/src/features/generated-answer/generated-answer-state.ts index e88e2b7a329..9bfd7b3d714 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-state.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-state.ts @@ -5,18 +5,15 @@ import type { GeneratedResponseFormat, } from './generated-response-format.js'; -/** - * A scoped and simplified part of the headless state that is relevant to the `GeneratedAnswer` component. - * - * @group Controllers - * @category GeneratedAnswer - */ -export interface GeneratedAnswerState { - id: string; +interface GeneratedAnswerConversationTurn { /** * Determines if the generated answer is visible. */ isVisible: boolean; + /** + * The question that prompted the generated answer. + */ + question: string; /** * Determines if the generated answer is loading. */ @@ -25,10 +22,6 @@ export interface GeneratedAnswerState { * Determines if the generated answer is streaming. */ isStreaming: boolean; - /** - * Determines if the generated answer is enabled. - */ - isEnabled: boolean; /** * The generated answer. */ @@ -51,14 +44,6 @@ export interface GeneratedAnswerState { * Determines if the generated answer is disliked, or downvoted by the end user. */ disliked: boolean; - /** - * The desired format options for the generated answer. - */ - responseFormat: GeneratedResponseFormat; - /** - * Determines if the feedback modal is currently opened. - */ - feedbackModalOpen: boolean; /** * The generated answer error. */ @@ -71,22 +56,47 @@ export interface GeneratedAnswerState { * Determines if the generated answer feedback was submitted. */ feedbackSubmitted: boolean; - /** - * A list of indexed fields to include in the citations returned with the generated answer. - */ - fieldsToIncludeInCitations: string[]; /** * Determines if the answer is generated. */ isAnswerGenerated: boolean; - /** - * Whether the answer is expanded. - */ - expanded: boolean; /** * Whether an answer cannot be generated after a query is executed. */ cannotAnswer: boolean; + /** The unique identifier of the answer returned by the Answer API. */ + answerId?: string; +} + +/** + * A scoped and simplified part of the headless state that is relevant to the `GeneratedAnswer` component. + * + * @group Controllers + * @category GeneratedAnswer + */ +export interface GeneratedAnswerState + extends Omit { + id: string; + /** + * Determines if the feedback modal is currently opened. + */ + feedbackModalOpen: boolean; + /** + * Determines if the generated answer is enabled. + */ + isEnabled: boolean; + /** + * The desired format options for the generated answer. + */ + responseFormat: GeneratedResponseFormat; + /** + * A list of indexed fields to include in the citations returned with the generated answer. + */ + fieldsToIncludeInCitations: string[]; + /** + * Whether the answer is expanded. + */ + expanded: boolean; /** * The answer configuration unique identifier. */ @@ -95,10 +105,10 @@ export interface GeneratedAnswerState { * The query parameters used for the answer API request cache key */ answerApiQueryParams?: AnswerApiQueryParams; - /** The unique identifier of the answer returned by the Answer API. */ - answerId?: string; /** The current mode of answer generation. */ answerGenerationMode: 'automatic' | 'manual'; + /** The list of follow-up answers in a conversational context. */ + followUpAnswers: GeneratedAnswerConversationTurn[]; } export function getGeneratedAnswerInitialState(): GeneratedAnswerState { @@ -123,5 +133,6 @@ export function getGeneratedAnswerInitialState(): GeneratedAnswerState { answerApiQueryParams: undefined, answerId: undefined, answerGenerationMode: 'automatic', + followUpAnswers: [], }; } From a701f9dfb9981d3bc7fad1cca9f796ba9a018d58 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Thu, 25 Dec 2025 12:43:16 -0500 Subject: [PATCH 02/17] created structure of rtk query client for answer generation api including api creation, api client state, shared types, stream executor, endpoints builders, strategies to handle streaming, and first endpoint handled by this api client, the head answer generation endpoint --- .../answer-generation-api-state.ts | 50 ++++++++ .../answer-generation-api.ts | 8 ++ .../endpoints/head-answer.ts | 69 +++++++++++ .../answer-generation/shared-types.ts | 35 ++++++ .../streaming/event-handlers.ts | 68 +++++++++++ .../strategies/head-answer-strategy.ts | 115 ++++++++++++++++++ .../streaming/strategies/strategy-types.ts | 34 ++++++ .../streaming/stream-executor.ts | 65 ++++++++++ .../url-builders/endpoint-url-builder.ts | 20 +++ 9 files changed, 464 insertions(+) create mode 100644 packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts create mode 100644 packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts create mode 100644 packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer.ts create mode 100644 packages/headless/src/api/knowledge/answer-generation/shared-types.ts create mode 100644 packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts create mode 100644 packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts create mode 100644 packages/headless/src/api/knowledge/answer-generation/streaming/strategies/strategy-types.ts create mode 100644 packages/headless/src/api/knowledge/answer-generation/streaming/stream-executor.ts create mode 100644 packages/headless/src/api/knowledge/answer-generation/url-builders/endpoint-url-builder.ts diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts new file mode 100644 index 00000000000..d3f4ceab9ab --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts @@ -0,0 +1,50 @@ +import type { + BaseQueryFn, + CombinedState, + FetchArgs, + FetchBaseQueryError, + QueryDefinition, + RetryOptions, +} from '@reduxjs/toolkit/query'; +import type {SearchAppState} from '../../../state/search-app-state.js'; +import type { + ConfigurationSection, + GeneratedAnswerSection, + TabSection, +} from '../../../state/state-sections.js'; +import type {SearchRequest} from '../../search/search/search-request.js'; +import type {GeneratedAnswerDraft} from './shared-types.js'; + +export interface AnswerGenerationApiSection { + // CombinedState is an internal type from RTK Query that is used directly to break dependency on actual + // use of RTK Query for the Stream Answer API. This exposes the internal state of RTKQ but allows us to + // type this object over using an `unknown` type. + answerGenerationApi: CombinedState< + { + generateHeadAnswer: QueryDefinition< + Partial, + BaseQueryFn< + string | FetchArgs, + unknown, + FetchBaseQueryError, + {} & RetryOptions, + {} + >, + never, + GeneratedAnswerDraft, + 'answerGenerationApi' + >; + }, + never, + 'answerGenerationApi' + >; +} + +export type AnswerGenerationApiState = { + searchHub: string; + pipeline: string; +} & AnswerGenerationApiSection & + ConfigurationSection & + Partial & + GeneratedAnswerSection & + Partial; diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts new file mode 100644 index 00000000000..2aa3a1710e7 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts @@ -0,0 +1,8 @@ +import {createApi, retry} from '@reduxjs/toolkit/query'; +import {dynamicBaseQuery} from '../answer-slice.js'; + +export const answerGenerationApi = createApi({ + reducerPath: 'answerGenerationApi', + baseQuery: retry(dynamicBaseQuery, {maxRetries: 3}), + endpoints: () => ({}), +}); diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer.ts new file mode 100644 index 00000000000..d16d72f29be --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer.ts @@ -0,0 +1,69 @@ +import type {HeadAnswerParams} from '../../../../features/generated-answer/generated-answer-request.js'; +import type {SearchRequest} from '../../../search/search/search-request.js'; +import {answerGenerationApi} from '../answer-generation-api.js'; +import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; +import type {GeneratedAnswerDraft} from '../shared-types.js'; +import {headAnswerStrategy} from '../streaming/strategies/head-answer-strategy.js'; +import {createStreamExecutor} from '../streaming/stream-executor.js'; + +export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ + overrideExisting: true, + endpoints: (builder) => ({ + generateHeadAnswer: builder.query< + GeneratedAnswerDraft, + Partial + >({ + queryFn: () => { + return { + data: { + contentFormat: undefined, + answer: undefined, + citations: undefined, + error: undefined, + generated: false, + isStreaming: false, + isLoading: true, + }, + }; + }, + serializeQueryArgs: ({endpointName, queryArgs}) => { + // RTK Query serialize our endpoints and they're serialized state arguments as the key in the store. + // Keys must match, because if anything in the query changes, it's not the same query anymore. + // Analytics data is excluded entirely as it contains volatile fields that change during streaming. + const {analytics: _analytics, ...queryArgsWithoutAnalytics} = queryArgs; + + // Standard RTK key, with analytics excluded + return `${endpointName}(${JSON.stringify(queryArgsWithoutAnalytics)})`; + }, + async onCacheEntryAdded( + args, + {getState, cacheDataLoaded, updateCachedData, dispatch} + ) { + await cacheDataLoaded; + /** + * createApi has to be called prior to creating the redux store and is used as part of the store setup sequence. + * It cannot use the inferred state used by Redux, thus the casting. + * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-dispatch-and-getstate + */ + const state = getState() as AnswerGenerationApiState; + const streamAnswerWithStrategy = createStreamExecutor< + GeneratedAnswerDraft, + AnswerGenerationApiState + >(headAnswerStrategy); + + await streamAnswerWithStrategy(args, state, dispatch, updateCachedData); + }, + }), + }), +}); + +export const initiateHeadAnswerGeneration = (params: HeadAnswerParams) => { + return headAnswerEndpoint.endpoints.generateHeadAnswer.initiate(params); +}; + +export const selectHeadAnswer = ( + params: HeadAnswerParams, + state: AnswerGenerationApiState +) => { + return headAnswerEndpoint.endpoints.generateHeadAnswer.select(params)(state); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/shared-types.ts b/packages/headless/src/api/knowledge/answer-generation/shared-types.ts new file mode 100644 index 00000000000..b24e7ac5cc8 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/shared-types.ts @@ -0,0 +1,35 @@ +import type {GeneratedContentFormat} from '../../../features/generated-answer/generated-response-format.js'; +import type {GeneratedAnswerCitation} from '../../generated-answer/generated-answer-event-payload.js'; + +export interface GeneratedAnswerDraft { + answerId?: string; + contentFormat?: GeneratedContentFormat; + answer?: string; + citations?: GeneratedAnswerCitation[]; + generated?: boolean; + isStreaming: boolean; + isLoading: boolean; + error?: {message: string; code: number}; +} + +export interface StreamPayload { + textDelta?: string; + padding?: string; + answerGenerated?: boolean; + contentFormat: GeneratedContentFormat; + citations: GeneratedAnswerCitation[]; +} + +export type PayloadType = + | 'genqa.headerMessageType' + | 'genqa.messageType' + | 'genqa.citationsType' + | 'genqa.endOfStreamType'; + +export interface Message { + payloadType: PayloadType; + payload: string; + finishReason?: string; + errorMessage?: string; + code?: number; +} diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts new file mode 100644 index 00000000000..d757f7f2eba --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts @@ -0,0 +1,68 @@ +import type { + GeneratedAnswerDraft, + Message, + StreamPayload, +} from '../shared-types.js'; + +export const handleAnswerId = ( + draft: GeneratedAnswerDraft, + answerId: string +) => { + if (answerId) { + draft.answerId = answerId; + } +}; + +export const handleHeaderMessage = ( + draft: GeneratedAnswerDraft, + payload: Pick +) => { + const {contentFormat} = payload; + draft.contentFormat = contentFormat; + draft.isStreaming = true; + draft.isLoading = false; +}; + +export const handleMessage = ( + draft: GeneratedAnswerDraft, + payload: Pick +) => { + if (draft.answer === undefined) { + draft.answer = payload.textDelta; + } else if (typeof payload.textDelta === 'string') { + draft.answer = draft.answer.concat(payload.textDelta); + } +}; + +export const handleCitations = ( + draft: GeneratedAnswerDraft, + payload: Pick +) => { + draft.citations = payload.citations; +}; + +export const handleEndOfStream = ( + draft: GeneratedAnswerDraft, + payload: Pick +) => { + draft.generated = payload.answerGenerated; + draft.isStreaming = false; +}; + +export const handleError = ( + draft: GeneratedAnswerDraft, + message: Required +) => { + const errorMessage = message.errorMessage || 'Unknown error occurred'; + + draft.error = { + message: errorMessage, + code: message.code!, + }; + draft.isStreaming = false; + draft.isLoading = false; + // Throwing an error here breaks the client and prevents the error from reaching the state. + console.error( + `Generated answer error: ${errorMessage} (code: ${message.code})` + ); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts new file mode 100644 index 00000000000..949d2e27849 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts @@ -0,0 +1,115 @@ +import { + setAnswerContentFormat, + setAnswerId, + setCannotAnswer, + setIsAnswerGenerated, + setIsLoading, + setIsStreaming, + updateCitations, + updateMessage, +} from '../../../../../features/generated-answer/generated-answer-actions.js'; +import { + logGeneratedAnswerResponseLinked, + logGeneratedAnswerStreamEnd, +} from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; +import type {AnswerGenerationApiState} from '../../answer-generation-api-state.js'; +import type {GeneratedAnswerDraft, StreamPayload} from '../../shared-types.js'; +import {buildHeadAnswerEndpointUrl} from '../../url-builders/endpoint-url-builder.js'; +import { + handleAnswerId, + handleCitations, + handleEndOfStream, + handleError, + handleHeaderMessage, + handleMessage, +} from '../event-handlers.js'; +import type {StreamingStrategy} from './strategy-types.js'; + +export const headAnswerStrategy: StreamingStrategy< + GeneratedAnswerDraft, + AnswerGenerationApiState +> = { + buildEndpointUrl: (state) => buildHeadAnswerEndpointUrl(state), + events: { + handleOpen: (response, updateCachedData, dispatch) => { + const answerId = response.headers.get('x-answer-id'); + if (answerId) { + updateCachedData((draft) => { + handleAnswerId(draft, answerId); + }); + dispatch(setAnswerId(answerId)); + } + }, + + handleClose: (updateCachedData, dispatch) => { + updateCachedData((draft) => { + dispatch(setCannotAnswer(!draft.generated)); + }); + }, + + handleError: (error) => { + throw error; + }, + + handleMessage: { + 'genqa.headerMessageType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.contentFormat) { + updateCachedData((draft) => { + handleHeaderMessage(draft, payload); + }); + dispatch(setAnswerContentFormat(payload.contentFormat)); + dispatch(setIsStreaming(true)); + dispatch(setIsLoading(false)); + } + }, + + 'genqa.messageType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.textDelta) { + updateCachedData((draft) => { + handleMessage(draft, payload); + }); + dispatch(updateMessage({textDelta: payload.textDelta})); + } + }, + + 'genqa.citationsType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.citations) { + updateCachedData((draft) => { + handleCitations(draft, payload); + }); + dispatch(updateCitations({citations: payload.citations})); + } + }, + + 'genqa.endOfStreamType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + updateCachedData((draft) => { + handleEndOfStream(draft, payload); + }); + dispatch(setIsAnswerGenerated(!!payload.answerGenerated)); + dispatch(setIsStreaming(false)); + dispatch(setIsLoading(false)); + dispatch(logGeneratedAnswerStreamEnd(payload.answerGenerated ?? false)); + dispatch(logGeneratedAnswerResponseLinked()); + }, + error: (message, updateCachedData) => { + if (message.finishReason === 'ERROR' && message.errorMessage) { + updateCachedData((draft) => { + handleError(draft, message); + }); + } + }, + }, + }, +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/strategy-types.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/strategy-types.ts new file mode 100644 index 00000000000..5e0a92becea --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/strategy-types.ts @@ -0,0 +1,34 @@ +import type {ThunkDispatch, UnknownAction} from '@reduxjs/toolkit'; +import type {Message, PayloadType} from '../../shared-types.js'; + +type EventType = PayloadType | 'error'; + +export interface StreamingStrategy { + buildEndpointUrl: (state: TState) => string; + + events: { + handleOpen: ( + response: Response, + updateCachedData: (updater: (draft: TDraft) => void) => void, + dispatch: ThunkDispatch + ) => void; + + handleClose: ( + updateCachedData: (updater: (draft: TDraft) => void) => void, + dispatch: ThunkDispatch + ) => void; + + handleError: (error: unknown) => void; + + handleMessage: Partial< + Record< + EventType, + ( + message: Required, + updateCachedData: (updater: (draft: TDraft) => void) => void, + dispatch: ThunkDispatch + ) => void + > + >; + }; +} diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/stream-executor.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/stream-executor.ts new file mode 100644 index 00000000000..89ce7a0908b --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/stream-executor.ts @@ -0,0 +1,65 @@ +import type {ThunkDispatch, UnknownAction} from '@reduxjs/toolkit'; +import {fetchEventSource} from '../../../../utils/fetch-event-source/fetch.js'; +import type {Message} from '../shared-types.js'; +import type {StreamingStrategy} from './strategies/strategy-types.js'; + +type StateWithConfiguration = { + configuration: { + accessToken: string; + }; +}; + +export const createStreamExecutor = < + TDraft, + TState extends StateWithConfiguration, +>( + strategy: StreamingStrategy +) => { + return ( + args: {}, + state: TState, + dispatch: ThunkDispatch, + updateCachedData: (updater: (draft: TDraft) => void) => void + ) => { + const endpointUrl = strategy.buildEndpointUrl(state); + const { + configuration: {accessToken}, + } = state; + + return fetchEventSource(endpointUrl, { + method: 'POST', + body: JSON.stringify(args), + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Accept-Encoding': '*', + }, + fetch, + onopen: async (response) => { + strategy.events.handleOpen(response, updateCachedData, dispatch); + }, + onclose: () => { + strategy.events.handleClose(updateCachedData, dispatch); + }, + onerror: (error) => { + strategy.events.handleError(error); + }, + onmessage: (event) => { + const message: Required = JSON.parse(event.data); + strategy.events.handleMessage.error?.( + message, + updateCachedData, + dispatch + ); + + const messageType = message.payloadType; + strategy.events.handleMessage[messageType]?.( + message, + updateCachedData, + dispatch + ); + }, + }); + }; +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/url-builders/endpoint-url-builder.ts b/packages/headless/src/api/knowledge/answer-generation/url-builders/endpoint-url-builder.ts new file mode 100644 index 00000000000..466e5253424 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/url-builders/endpoint-url-builder.ts @@ -0,0 +1,20 @@ +import {getOrganizationEndpoint} from '../../../platform-client.js'; +import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; + +export const buildHeadAnswerEndpointUrl = ( + state: AnswerGenerationApiState +): string => { + const {configuration, generatedAnswer} = state; + const {organizationId, environment} = configuration; + const platformEndpoint = getOrganizationEndpoint(organizationId, environment); + + if ( + !platformEndpoint || + !organizationId || + !generatedAnswer.answerConfigurationId + ) { + throw new Error('Missing required parameters for answer endpoint'); + } + const basePath = `/rest/organizations/${organizationId}/answer/v1/configs`; + return `${platformEndpoint}${basePath}/${generatedAnswer.answerConfigurationId}/generate`; +}; From 070691316f1aaebaa757d1f63893377c836ad170 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Thu, 25 Dec 2025 12:47:38 -0500 Subject: [PATCH 03/17] created actions to interact with the generated answer conversation feature including new generateHeadAnswer action that uses the RTK Query API client after building the parameters and the hydrateAnswerFromCache action with the reducer responsible of handling it --- .../generated-answer-conversation-actions.ts | 45 ++++++++++++++++ .../generated-answer-request.ts | 52 ++++++++++++++++++- .../generated-answer-slice.ts | 17 ++++++ .../src/features/search/search-request.ts | 2 +- 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts diff --git a/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts new file mode 100644 index 00000000000..045b3ef95bf --- /dev/null +++ b/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts @@ -0,0 +1,45 @@ +import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import { + initiateHeadAnswerGeneration, + selectHeadAnswer, +} from '../../api/knowledge/answer-generation/endpoints/head-answer.js'; +import type {GeneratedAnswerStream} from '../../api/knowledge/generated-answer-stream.js'; +import type {AsyncThunkOptions} from '../../app/async-thunk-options.js'; +import type {SearchThunkExtraArguments} from '../../app/search-thunk-extra-arguments.js'; +import {resetAnswer} from './generated-answer-actions.js'; +import {constructGenerateHeadAnswerParams} from './generated-answer-request.js'; + +export const hydrateAnswerFromCache = createAction( + 'generatedAnswer/hydrateFromCache' +); + +export const generateHeadAnswer = createAsyncThunk< + void, + void, + AsyncThunkOptions +>( + 'generatedAnswerConversation/generateHeadAnswer', + async (_, {getState, dispatch, extra: {navigatorContext, logger}}) => { + const state = getState() as AnswerGenerationApiState; + if (!state.generatedAnswer.answerConfigurationId) { + logger.warn( + 'Missing answerConfigurationId in engine configuration. ' + + 'The generateAnswer action requires an answer configuration ID.' + ); + return; + } + + dispatch(resetAnswer()); + const generateHeadAnswerParams = constructGenerateHeadAnswerParams( + state, + navigatorContext + ); + const cachedResponse = selectHeadAnswer(generateHeadAnswerParams, state); + if (cachedResponse.status === 'fulfilled') { + dispatch(hydrateAnswerFromCache(cachedResponse.data)); + return; + } + await dispatch(initiateHeadAnswerGeneration(generateHeadAnswerParams)); + } +); diff --git a/packages/headless/src/features/generated-answer/generated-answer-request.ts b/packages/headless/src/features/generated-answer/generated-answer-request.ts index 074fc15b0df..3c98d378960 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-request.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-request.ts @@ -1,6 +1,7 @@ import type {HistoryElement} from '../../api/analytics/coveo.analytics/history-store.js'; import HistoryStore from '../../api/analytics/coveo.analytics/history-store.js'; import type {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request.js'; +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; import type {StreamAnswerAPIState} from '../../api/knowledge/stream-answer-api-state.js'; import {getOrganizationEndpoint} from '../../api/platform-client.js'; import type {BaseParam} from '../../api/platform-service-params.js'; @@ -31,6 +32,7 @@ import { import {selectSearchActionCause} from '../../features/search/search-selectors.js'; import {selectSearchHub} from '../../features/search-hub/search-hub-selectors.js'; import {selectActiveTab} from '../../features/tab-set/tab-set-selectors.js'; +import type {SearchAppState} from '../../state/search-app-state.js'; import type { ConfigurationSection, GeneratedAnswerSection, @@ -74,7 +76,7 @@ export const buildStreamingRequest = async ( export const constructAnswerAPIQueryParams = ( state: StreamAnswerAPIState, navigatorContext: NavigatorContext -): AnswerApiQueryParams => { +) => { const q = selectQuery(state)?.q; const {aq, cq, dq, lq} = buildAdvancedSearchQueryParams(state); @@ -161,6 +163,52 @@ export const constructAnswerAPIQueryParams = ( }; }; +export type HeadAnswerParams = ReturnType< + typeof constructGenerateHeadAnswerParams +>; + +export const constructGenerateHeadAnswerParams = ( + state: AnswerGenerationApiState, + navigatorContext: NavigatorContext +) => { + const q = selectQuery(state)?.q; + + const {aq, cq, dq, lq} = buildAdvancedSearchQueryParams(state); + + const context = selectContext(state); + + const analyticsParams = fromAnalyticsStateToAnalyticsParams( + state.configuration.analytics, + navigatorContext, + {actionCause: selectSearchActionCause(state)} + ); + + const searchHub = selectSearchHub(state); + const pipeline = selectPipeline(state); + const citationsFieldToInclude = selectFieldsToIncludeInCitation(state) ?? []; + + return { + q, + ...(aq && {aq}), + ...(cq && {cq}), + ...(dq && {dq}), + ...(lq && {lq}), + ...(state.query && {enableQuerySyntax: selectEnableQuerySyntax(state)}), + ...(context?.contextValues && { + context: context.contextValues, + }), + pipelineRuleParameters: { + mlGenerativeQuestionAnswering: { + responseFormat: state.generatedAnswer.responseFormat, + citationsFieldToInclude, + }, + }, + ...(searchHub?.length && {searchHub}), + ...(pipeline?.length && {pipeline}), + ...analyticsParams, + }; +}; + const getGeneratedFacetParams = ( state: StreamAnswerAPIState ): AnyFacetRequest[] => @@ -180,7 +228,7 @@ const getActionsHistory = ( : [], }); -const buildAdvancedSearchQueryParams = (state: StreamAnswerAPIState) => { +const buildAdvancedSearchQueryParams = (state: Partial) => { const advancedSearchQueryParams = selectAdvancedSearchQueries(state); const mergedCq = buildConstantQuery(state); diff --git a/packages/headless/src/features/generated-answer/generated-answer-slice.ts b/packages/headless/src/features/generated-answer/generated-answer-slice.ts index 49d394deedd..40d3b15bde9 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-slice.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-slice.ts @@ -1,5 +1,6 @@ import {createReducer} from '@reduxjs/toolkit'; import {RETRYABLE_STREAM_ERROR_CODE} from '../../api/generated-answer/generated-answer-client.js'; +import type {GeneratedAnswerStream} from '../../api/knowledge/generated-answer-stream.js'; import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; import { closeGeneratedAnswerFeedbackModal, @@ -28,6 +29,7 @@ import { updateMessage, updateResponseFormat, } from './generated-answer-actions.js'; +import {hydrateAnswerFromCache} from './generated-answer-conversation-actions.js'; import {getGeneratedAnswerInitialState} from './generated-answer-state.js'; import {filterOutDuplicatedCitations} from './utils/generated-answer-citation-utils.js'; @@ -143,4 +145,19 @@ export const generatedAnswerReducer = createReducer( .addCase(setAnswerGenerationMode, (state, {payload}) => { state.answerGenerationMode = payload; }) + .addCase(hydrateAnswerFromCache, (state, {payload}) => { + const {answerId, answer, citations, contentFormat, error, generated} = + payload as GeneratedAnswerStream; + state.answerId = answerId; + state.answer = answer || ''; + state.citations = citations || []; + state.answerContentFormat = contentFormat; + state.isLoading = false; + state.isStreaming = false; + state.isAnswerGenerated = generated || false; + state.error = { + ...error, + isRetryable: error?.code === RETRYABLE_STREAM_ERROR_CODE, + }; + }) ); diff --git a/packages/headless/src/features/search/search-request.ts b/packages/headless/src/features/search/search-request.ts index 025846e5edb..a1fdbf4d5ad 100644 --- a/packages/headless/src/features/search/search-request.ts +++ b/packages/headless/src/features/search/search-request.ts @@ -189,7 +189,7 @@ function getRangeFacetRequests(state: T) { }); } -export function buildConstantQuery(state: StateNeededBySearchRequest) { +export function buildConstantQuery(state: Partial) { const cq = state.advancedSearchQueries?.cq.trim() || ''; const activeTab = Object.values(state.tabSet || {}).find( (tab) => tab.isActive From 00023f76fe254b3eef8c425d0edc6d4b1e98f7a3 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Thu, 25 Dec 2025 12:48:52 -0500 Subject: [PATCH 04/17] created new generated answer conversation controller --- .../headless-core-generated-answer.ts | 4 + .../headless-generated-answer.ts | 21 ++-- .../headless-generated-answer-conversation.ts | 110 ++++++++++++++++++ 3 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts diff --git a/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts b/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts index ec1e8a478f4..f609fd709e9 100644 --- a/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts +++ b/packages/headless/src/controllers/core/generated-answer/headless-core-generated-answer.ts @@ -153,6 +153,10 @@ export interface GeneratedAnswerProps extends GeneratedAnswerPropsInitialState { * The answer configuration ID used to leverage coveo answer management capabilities. */ answerConfigurationId?: string; + /** + * The agent identifier to use to generate the answer. + */ + agentId?: string; /** * A list of indexed fields to include in the citations returned with the generated answer. */ diff --git a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts index 88c7b8c8f99..a941cd3ec10 100644 --- a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts +++ b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts @@ -11,6 +11,7 @@ import type { } from '../core/generated-answer/headless-core-generated-answer.js'; import {buildSearchAPIGeneratedAnswer} from '../core/generated-answer/headless-searchapi-generated-answer.js'; import {buildAnswerApiGeneratedAnswer} from '../knowledge/generated-answer/headless-answerapi-generated-answer.js'; +import {buildGeneratedAnswerConversation} from '../knowledge/generated-answer-conversation/headless-generated-answer-conversation.js'; export type { GeneratedAnswerCitation, @@ -38,17 +39,23 @@ export function buildGeneratedAnswer( warnIfUsingNextAnalyticsModeForServiceFeature( engine.state.configuration.analytics.analyticsMode ); - const controller = props.answerConfigurationId - ? buildAnswerApiGeneratedAnswer( + const controller = props.agentId + ? buildGeneratedAnswerConversation( engine, generatedAnswerAnalyticsClient, props ) - : buildSearchAPIGeneratedAnswer( - engine, - generatedAnswerAnalyticsClient, - props - ); + : props.answerConfigurationId + ? buildAnswerApiGeneratedAnswer( + engine, + generatedAnswerAnalyticsClient, + props + ) + : buildSearchAPIGeneratedAnswer( + engine, + generatedAnswerAnalyticsClient, + props + ); return { ...controller, diff --git a/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts b/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts new file mode 100644 index 00000000000..65902530f8f --- /dev/null +++ b/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts @@ -0,0 +1,110 @@ +import {answerGenerationApi} from '../../../api/knowledge/answer-generation/answer-generation-api.js'; +import {warnIfUsingNextAnalyticsModeForServiceFeature} from '../../../app/engine.js'; +import type {InsightEngine} from '../../../app/insight-engine/insight-engine.js'; +import type {SearchEngine} from '../../../app/search-engine/search-engine.js'; +import { + resetAnswer, + updateAnswerConfigurationId, +} from '../../../features/generated-answer/generated-answer-actions.js'; +import type {GeneratedAnswerFeedback} from '../../../features/generated-answer/generated-answer-analytics-actions.js'; +import {queryReducer as query} from '../../../features/query/query-slice.js'; +import type { + GeneratedAnswerSection, + QuerySection, +} from '../../../state/state-sections.js'; +import {loadReducerError} from '../../../utils/errors.js'; +import { + buildCoreGeneratedAnswer, + type GeneratedAnswer, + type GeneratedAnswerAnalyticsClient, + type GeneratedAnswerProps, +} from '../../core/generated-answer/headless-core-generated-answer.js'; + +interface AnswerApiGeneratedAnswer + extends Omit { + /** + * Resets the last answer. + */ + reset(): void; + /** + * Sends feedback about why the generated answer was not relevant. + * @param feedback - The feedback that the end user wishes to send. + */ + sendFeedback(feedback: GeneratedAnswerFeedback): void; +} + +interface AnswerApiGeneratedAnswerProps extends GeneratedAnswerProps {} + +interface SearchAPIGeneratedAnswerAnalyticsClient + extends GeneratedAnswerAnalyticsClient {} + +/** + * + * @internal + * + * Creates a `AnswerApiGeneratedAnswer` controller instance using the Answer API stream pattern. + * + * @param engine - The headless engine. + * @param props - The configurable `AnswerApiGeneratedAnswer` properties. + * @returns A `AnswerApiGeneratedAnswer` controller instance. + */ +export function buildGeneratedAnswerConversation( + engine: SearchEngine | InsightEngine, + analyticsClient: SearchAPIGeneratedAnswerAnalyticsClient, + props: AnswerApiGeneratedAnswerProps = {} +): AnswerApiGeneratedAnswer { + if (!loadAnswerApiReducers(engine)) { + throw loadReducerError; + } + warnIfUsingNextAnalyticsModeForServiceFeature( + engine.state.configuration.analytics.analyticsMode + ); + + const {...controller} = buildCoreGeneratedAnswer( + engine, + analyticsClient, + props + ); + const getState = () => engine.state; + engine.dispatch(updateAnswerConfigurationId(props.agentId!)); + + return { + ...controller, + get state() { + const state = getState().generatedAnswer; + return state; + }, + // retry() { + // const answerApiQueryParams = selectAnswerApiQueryParams(getState()); + // engine.dispatch(fetchAnswer(answerApiQueryParams)); + // }, + reset() { + engine.dispatch(resetAnswer()); + }, + async sendFeedback(feedback) { + engine.dispatch(analyticsClient.logGeneratedAnswerFeedback(feedback)); + // const args = parseEvaluationArguments({ + // query: getState().query.q, + // feedback, + // answerApiState: selectAnswer(engine.state).data!, + // }); + // engine.dispatch(answerEvaluation.endpoints.post.initiate(args)); + // engine.dispatch(sendGeneratedAnswerFeedback()); + }, + }; +} + +function loadAnswerApiReducers( + engine: SearchEngine | InsightEngine +): engine is SearchEngine< + GeneratedAnswerSection & + QuerySection & { + answerGenerationApi: ReturnType; + } +> { + engine.addReducers({ + [answerGenerationApi.reducerPath]: answerGenerationApi.reducer, + query, + }); + return true; +} From f8f6ad14b7273d77eca2493883c5202ad698f600 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Thu, 25 Dec 2025 12:50:39 -0500 Subject: [PATCH 05/17] created a listener middleware that listens to search request in order to trigger head answer generation and also linking the engine to this middleware and also the answer generation api middleware --- packages/headless/src/app/engine.ts | 7 +++- .../generate-answer-listener-middleware.ts | 39 +++++++++++++++++++ packages/headless/src/app/store.ts | 3 ++ .../generated-answer-selectors.ts | 11 +++++- 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 packages/headless/src/app/middleware/generate-answer-listener-middleware.ts diff --git a/packages/headless/src/app/engine.ts b/packages/headless/src/app/engine.ts index fb099d366bf..85f785deb65 100644 --- a/packages/headless/src/app/engine.ts +++ b/packages/headless/src/app/engine.ts @@ -12,6 +12,7 @@ import type { } from '@reduxjs/toolkit'; import type {Logger} from 'pino'; import {getRelayInstanceFromState} from '../api/analytics/analytics-relay-client.js'; +import {answerGenerationApi} from '../api/knowledge/answer-generation/answer-generation-api.js'; import {answerApi} from '../api/knowledge/stream-answer-api.js'; import { disableAnalytics, @@ -33,6 +34,7 @@ import type {EngineConfiguration} from './engine-configuration.js'; import {instantlyCallableThunkActionMiddleware} from './instantly-callable-middleware.js'; import type {LoggerOptions} from './logger.js'; import {logActionErrorMiddleware} from './logger-middlewares.js'; +import {generateAnswerListener} from './middleware/generate-answer-listener-middleware.js'; import { getNavigatorContext, type NavigatorContext, @@ -372,7 +374,10 @@ function createMiddleware( renewTokenMiddleware, logActionErrorMiddleware(logger), analyticsMiddleware, - ].concat(answerApi.middleware, options.middlewares || []); + ] + .concat(answerApi.middleware, options.middlewares || []) + .concat(answerGenerationApi.middleware, options.middlewares || []) + .concat(generateAnswerListener.middleware); } export const nextAnalyticsUsageWithServiceFeatureWarning = diff --git a/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts b/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts new file mode 100644 index 00000000000..66d50bf36d7 --- /dev/null +++ b/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts @@ -0,0 +1,39 @@ +import { + createListenerMiddleware, + type ThunkDispatch, + type UnknownAction, +} from '@reduxjs/toolkit'; +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import {generateHeadAnswer} from '../../features/generated-answer/generated-answer-conversation-actions.js'; +import {isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI} from '../../features/generated-answer/generated-answer-selectors.js'; +import {selectQuery} from '../../features/query/query-selectors.js'; +import {executeSearch} from '../../features/search/search-actions.js'; +import type {SearchThunkExtraArguments} from '../search-thunk-extra-arguments.js'; +import type {RootState} from '../store.js'; + +export const generateAnswerListener = createListenerMiddleware< + RootState, + ThunkDispatch< + AnswerGenerationApiState, + SearchThunkExtraArguments, + UnknownAction + > +>(); + +generateAnswerListener.startListening({ + actionCreator: executeSearch.pending, + + effect: async (_action, listenerApi) => { + const state = listenerApi.getState(); + + const q = selectQuery(state)?.q; + const queryIsEmpty = !q || q.trim() === ''; + const isGeneratedAnswerFeatureEnabled = + isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI(state); + + if (!isGeneratedAnswerFeatureEnabled || queryIsEmpty) { + return; + } + listenerApi.dispatch(generateHeadAnswer()); + }, +}); diff --git a/packages/headless/src/app/store.ts b/packages/headless/src/app/store.ts index 53fb60db29e..0ff2efe97cc 100644 --- a/packages/headless/src/app/store.ts +++ b/packages/headless/src/app/store.ts @@ -53,3 +53,6 @@ export function configureStore({ } export type Store = ReturnType; +export type RootState = ReturnType< + ReturnType['getState'] +>; diff --git a/packages/headless/src/features/generated-answer/generated-answer-selectors.ts b/packages/headless/src/features/generated-answer/generated-answer-selectors.ts index 515b9a41038..3aa6d8f4fd5 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-selectors.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-selectors.ts @@ -12,7 +12,7 @@ export const generativeQuestionAnsweringIdSelector = ( state: Partial ): string | undefined => { // If using the AnswerApi, we return the answerId first. - if (isGeneratedAnswerSection(state)) { + if (isGeneratedAnswerFeatureEnabledWithAnswerAPI(state)) { return state.generatedAnswer?.answerId; } @@ -25,13 +25,20 @@ export const generativeQuestionAnsweringIdSelector = ( return undefined; }; -const isGeneratedAnswerSection = ( +export const isGeneratedAnswerFeatureEnabledWithAnswerAPI = ( state: Partial ): state is StreamAnswerAPIState => 'answer' in state && 'generatedAnswer' in state && !isNullOrUndefined(state.generatedAnswer?.answerConfigurationId); +export const isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI = ( + state: Partial +): state is StreamAnswerAPIState => + 'answerGenerationApi' in state && + 'generatedAnswer' in state && + !isNullOrUndefined(state.generatedAnswer?.answerConfigurationId); + const isSearchSection = ( state: Partial | StreamAnswerAPIState ): state is SearchSection => From ad5dca1612fa5161729344861d2aa032f2c1093d Mon Sep 17 00:00:00 2001 From: mmitiche Date: Thu, 25 Dec 2025 12:51:21 -0500 Subject: [PATCH 06/17] exported dynamicBaseQuery to reuse it in the RTK Query API client created for answer generation api --- packages/headless/src/api/knowledge/answer-slice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/headless/src/api/knowledge/answer-slice.ts b/packages/headless/src/api/knowledge/answer-slice.ts index 695972207a9..028ad4b7565 100644 --- a/packages/headless/src/api/knowledge/answer-slice.ts +++ b/packages/headless/src/api/knowledge/answer-slice.ts @@ -18,7 +18,7 @@ type StateNeededByAnswerSlice = ConfigurationSection & GeneratedAnswerSection; * `dynamicBaseQuery` is passed to the baseQuery of the createApi, * but note that the baseQuery will not be used if a queryFn is provided in the createApi endpoint */ -const dynamicBaseQuery: BaseQueryFn< +export const dynamicBaseQuery: BaseQueryFn< string | FetchArgs, unknown, FetchBaseQueryError From 15590d7cdbe4f55be9993a6589197ba711f79e07 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Mon, 29 Dec 2025 15:45:20 -0500 Subject: [PATCH 07/17] conversationId added to state --- .../src/features/generated-answer/generated-answer-mocks.ts | 1 + .../src/features/generated-answer/generated-answer-state.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/headless/src/features/generated-answer/generated-answer-mocks.ts b/packages/headless/src/features/generated-answer/generated-answer-mocks.ts index fa0ebc2be7e..d6febc55d83 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-mocks.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-mocks.ts @@ -1086,6 +1086,7 @@ export const streamAnswerAPIStateMock: StreamAnswerAPIState = { answerConfigurationId: 'c36fd994-3eb7-4aaf-bfce-2dad4b15a622', cannotAnswer: false, answerGenerationMode: 'automatic', + conversationId: '', followUpAnswers: [], }, }; diff --git a/packages/headless/src/features/generated-answer/generated-answer-state.ts b/packages/headless/src/features/generated-answer/generated-answer-state.ts index 9bfd7b3d714..9b5a420efff 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-state.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-state.ts @@ -5,7 +5,7 @@ import type { GeneratedResponseFormat, } from './generated-response-format.js'; -interface GeneratedAnswerConversationTurn { +export interface GeneratedAnswerConversationTurn { /** * Determines if the generated answer is visible. */ @@ -107,6 +107,8 @@ export interface GeneratedAnswerState answerApiQueryParams?: AnswerApiQueryParams; /** The current mode of answer generation. */ answerGenerationMode: 'automatic' | 'manual'; + /** The unique identifier of the current conversation. */ + conversationId: string; /** The list of follow-up answers in a conversational context. */ followUpAnswers: GeneratedAnswerConversationTurn[]; } @@ -133,6 +135,7 @@ export function getGeneratedAnswerInitialState(): GeneratedAnswerState { answerApiQueryParams: undefined, answerId: undefined, answerGenerationMode: 'automatic', + conversationId: '', followUpAnswers: [], }; } From 27982f8a1b4a9f7270ed66c7da8f89e57477813d Mon Sep 17 00:00:00 2001 From: mmitiche Date: Mon, 29 Dec 2025 15:46:27 -0500 Subject: [PATCH 08/17] follow up endpoint added to answer generation api client --- .../answer-generation-api-state.ts | 13 ++ .../endpoints/follow-up-answer-endpoint.ts | 75 +++++++++++ ...head-answer.ts => head-answer-endpoint.ts} | 0 .../strategies/follow-up-answer-strategy.ts | 119 ++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts rename packages/headless/src/api/knowledge/answer-generation/endpoints/{head-answer.ts => head-answer-endpoint.ts} (100%) create mode 100644 packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts index d3f4ceab9ab..0b33421373d 100644 --- a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts @@ -34,6 +34,19 @@ export interface AnswerGenerationApiSection { GeneratedAnswerDraft, 'answerGenerationApi' >; + generateFollowUpAnswer: QueryDefinition< + Partial, + BaseQueryFn< + string | FetchArgs, + unknown, + FetchBaseQueryError, + {} & RetryOptions, + {} + >, + never, + GeneratedAnswerDraft, + 'answerGenerationApi' + >; }, never, 'answerGenerationApi' diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts new file mode 100644 index 00000000000..2aa43478e36 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts @@ -0,0 +1,75 @@ +import type {FollowUpAnswerParams} from '../../../../features/generated-answer/generated-answer-request.js'; +import type {SearchRequest} from '../../../search/search/search-request.js'; +import {answerGenerationApi} from '../answer-generation-api.js'; +import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; +import type {GeneratedAnswerDraft} from '../shared-types.js'; +import {followUpAnswerStrategy} from '../streaming/strategies/follow-up-answer-strategy.js'; +import {createStreamExecutor} from '../streaming/stream-executor.js'; + +export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ + overrideExisting: true, + endpoints: (builder) => ({ + generateFollowUpAnswer: builder.query< + GeneratedAnswerDraft, + Partial + >({ + queryFn: () => { + return { + data: { + contentFormat: undefined, + answer: undefined, + citations: undefined, + error: undefined, + generated: false, + isStreaming: false, + isLoading: true, + }, + }; + }, + serializeQueryArgs: ({endpointName, queryArgs}) => { + // RTK Query serialize our endpoints and they're serialized state arguments as the key in the store. + // Keys must match, because if anything in the query changes, it's not the same query anymore. + // Analytics data is excluded entirely as it contains volatile fields that change during streaming. + const {analytics: _analytics, ...queryArgsWithoutAnalytics} = queryArgs; + + // Standard RTK key, with analytics excluded + return `${endpointName}(${JSON.stringify(queryArgsWithoutAnalytics)})`; + }, + async onCacheEntryAdded( + args, + {getState, cacheDataLoaded, updateCachedData, dispatch} + ) { + await cacheDataLoaded; + /** + * createApi has to be called prior to creating the redux store and is used as part of the store setup sequence. + * It cannot use the inferred state used by Redux, thus the casting. + * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-dispatch-and-getstate + */ + const state = getState() as AnswerGenerationApiState; + const streamAnswerWithStrategy = createStreamExecutor< + GeneratedAnswerDraft, + AnswerGenerationApiState + >(followUpAnswerStrategy); + + await streamAnswerWithStrategy(args, state, dispatch, updateCachedData); + }, + }), + }), +}); + +export const initiateFollowUpAnswerGeneration = ( + params: FollowUpAnswerParams +) => { + return followUpAnswerEndpoint.endpoints.generateFollowUpAnswer.initiate( + params + ); +}; + +export const selectFollowUpAnswer = ( + params: FollowUpAnswerParams, + state: AnswerGenerationApiState +) => { + return followUpAnswerEndpoint.endpoints.generateFollowUpAnswer.select(params)( + state + ); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts similarity index 100% rename from packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer.ts rename to packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts new file mode 100644 index 00000000000..174ac1c0555 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts @@ -0,0 +1,119 @@ +import { + logGeneratedAnswerResponseLinked, + logGeneratedAnswerStreamEnd, +} from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; +import { + setActiveFollowUpAnswerContentFormat, + setActiveFollowUpAnswerId, + setActiveFollowUpCannotAnswer, + setActiveFollowUpIsAnswerGenerated, + setActiveFollowUpIsLoading, + setActiveFollowUpIsStreaming, + updateActiveFollowUpAnswerCitations, + updateActiveFollowUpAnswerMessage, +} from '../../../../../features/generated-answer/generated-answer-conversation-actions.js'; +import type {AnswerGenerationApiState} from '../../answer-generation-api-state.js'; +import type {GeneratedAnswerDraft, StreamPayload} from '../../shared-types.js'; +import {buildHeadAnswerEndpointUrl} from '../../url-builders/endpoint-url-builder.js'; +import { + handleAnswerId, + handleCitations, + handleEndOfStream, + handleError, + handleHeaderMessage, + handleMessage, +} from '../event-handlers.js'; +import type {StreamingStrategy} from './strategy-types.js'; + +export const followUpAnswerStrategy: StreamingStrategy< + GeneratedAnswerDraft, + AnswerGenerationApiState +> = { + buildEndpointUrl: (state) => buildHeadAnswerEndpointUrl(state), + events: { + handleOpen: (response, updateCachedData, dispatch) => { + const answerId = response.headers.get('x-answer-id'); + if (answerId) { + updateCachedData((draft) => { + handleAnswerId(draft, answerId); + }); + dispatch(setActiveFollowUpAnswerId(answerId)); + } + }, + + handleClose: (updateCachedData, dispatch) => { + updateCachedData((draft) => { + dispatch(setActiveFollowUpCannotAnswer(!draft.generated)); + }); + }, + + handleError: (error) => { + throw error; + }, + + handleMessage: { + 'genqa.headerMessageType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.contentFormat) { + updateCachedData((draft) => { + handleHeaderMessage(draft, payload); + }); + dispatch(setActiveFollowUpAnswerContentFormat(payload.contentFormat)); + dispatch(setActiveFollowUpIsStreaming(true)); + dispatch(setActiveFollowUpIsLoading(false)); + } + }, + + 'genqa.messageType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.textDelta) { + updateCachedData((draft) => { + handleMessage(draft, payload); + }); + dispatch( + updateActiveFollowUpAnswerMessage({textDelta: payload.textDelta}) + ); + } + }, + + 'genqa.citationsType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + if (payload.citations) { + updateCachedData((draft) => { + handleCitations(draft, payload); + }); + dispatch( + updateActiveFollowUpAnswerCitations({citations: payload.citations}) + ); + } + }, + + 'genqa.endOfStreamType': (message, updateCachedData, dispatch) => { + const payload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + updateCachedData((draft) => { + handleEndOfStream(draft, payload); + }); + dispatch(setActiveFollowUpIsAnswerGenerated(!!payload.answerGenerated)); + dispatch(setActiveFollowUpIsStreaming(false)); + dispatch(setActiveFollowUpIsLoading(false)); + dispatch(logGeneratedAnswerStreamEnd(payload.answerGenerated ?? false)); + dispatch(logGeneratedAnswerResponseLinked()); + }, + error: (message, updateCachedData) => { + if (message.finishReason === 'ERROR' && message.errorMessage) { + updateCachedData((draft) => { + handleError(draft, message); + }); + } + }, + }, + }, +}; From 73db1a99c23ea897a0decea451b177822986a2bc Mon Sep 17 00:00:00 2001 From: mmitiche Date: Mon, 29 Dec 2025 15:48:01 -0500 Subject: [PATCH 09/17] implemented new actions to mutate follow up answers state and new cases in reducer to apply state modifications --- .../generated-answer-actions.ts | 11 +- .../generated-answer-conversation-actions.ts | 132 +++++++++++++++++- .../generated-answer-request.ts | 48 +++++++ .../generated-answer-slice.ts | 130 ++++++++++++++++- 4 files changed, 311 insertions(+), 10 deletions(-) diff --git a/packages/headless/src/features/generated-answer/generated-answer-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-actions.ts index 2388c2d982a..af18bbb52e9 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-actions.ts @@ -54,7 +54,7 @@ type StateNeededByGeneratedAnswerStream = ConfigurationSection & const stringValue = new StringValue({required: true}); const optionalStringValue = new StringValue(); const booleanValue = new BooleanValue({required: true}); -const citationSchema = { +export const citationSchema = { id: stringValue, title: stringValue, uri: stringValue, @@ -62,10 +62,11 @@ const citationSchema = { clickUri: optionalStringValue, }; -const answerContentFormatSchema = new StringValue({ - required: true, - constrainTo: generatedContentFormat, -}); +export const answerContentFormatSchema = + new StringValue({ + required: true, + constrainTo: generatedContentFormat, + }); export interface GeneratedAnswerErrorPayload { message?: string; diff --git a/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts index 045b3ef95bf..d1e43dc49b5 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts @@ -1,19 +1,115 @@ +import { + ArrayValue, + BooleanValue, + NumberValue, + RecordValue, + StringValue, +} from '@coveo/bueno'; import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; +import type { + GeneratedAnswerCitationsPayload, + GeneratedAnswerMessagePayload, +} from '../../api/generated-answer/generated-answer-event-payload.js'; import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import {initiateFollowUpAnswerGeneration} from '../../api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.js'; import { initiateHeadAnswerGeneration, selectHeadAnswer, -} from '../../api/knowledge/answer-generation/endpoints/head-answer.js'; +} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import type {GeneratedAnswerStream} from '../../api/knowledge/generated-answer-stream.js'; import type {AsyncThunkOptions} from '../../app/async-thunk-options.js'; import type {SearchThunkExtraArguments} from '../../app/search-thunk-extra-arguments.js'; -import {resetAnswer} from './generated-answer-actions.js'; -import {constructGenerateHeadAnswerParams} from './generated-answer-request.js'; +import { + requiredNonEmptyString, + validatePayload, +} from '../../utils/validate-payload.js'; +import { + answerContentFormatSchema, + citationSchema, + type GeneratedAnswerErrorPayload, + resetAnswer, +} from './generated-answer-actions.js'; +import { + constructGenerateFollowUpAnswerParams, + constructGenerateHeadAnswerParams, +} from './generated-answer-request.js'; +import type {GeneratedContentFormat} from './generated-response-format.js'; + +const stringValue = new StringValue({required: true}); export const hydrateAnswerFromCache = createAction( 'generatedAnswer/hydrateFromCache' ); +export const addFollowUpAnswer = createAction( + 'generatedAnswer/addFollowUpAnswer' +); + +export const updateActiveFollowUpAnswerMessage = createAction( + 'generatedAnswer/updateActiveFollowUpMessage', + (payload: GeneratedAnswerMessagePayload) => + validatePayload(payload, { + textDelta: stringValue, + }) +); + +export const updateActiveFollowUpAnswerCitations = createAction( + 'generatedAnswer/updateActiveFollowUpCitations', + (payload: GeneratedAnswerCitationsPayload) => + validatePayload(payload, { + citations: new ArrayValue({ + required: true, + each: new RecordValue({ + values: citationSchema, + }), + }), + }) +); + +export const updateActiveFollowUpError = createAction( + 'generatedAnswer/updateActiveFollowUpError', + (payload: GeneratedAnswerErrorPayload) => + validatePayload(payload, { + message: new StringValue(), + code: new NumberValue({min: 0}), + }) +); + +export const setActiveFollowUpIsLoading = createAction( + 'generatedAnswer/setActiveFollowUpIsLoading', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + +export const setActiveFollowUpIsStreaming = createAction( + 'generatedAnswer/setActiveFollowUpIsStreaming', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + +export const setActiveFollowUpAnswerContentFormat = createAction( + 'generatedAnswer/setActiveFollowUpAnswerContentFormat', + (payload: GeneratedContentFormat) => + validatePayload(payload, answerContentFormatSchema) +); + +export const setActiveFollowUpAnswerId = createAction( + 'generatedAnswer/setActiveFollowUpAnswerId', + (payload: string) => validatePayload(payload, requiredNonEmptyString) +); + +export const setActiveFollowUpIsAnswerGenerated = createAction( + 'generatedAnswer/setActiveFollowUpIsAnswerGenerated', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + +export const setActiveFollowUpCannotAnswer = createAction( + 'generatedAnswer/setActiveFollowUpCannotAnswer', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) +); + export const generateHeadAnswer = createAsyncThunk< void, void, @@ -43,3 +139,33 @@ export const generateHeadAnswer = createAsyncThunk< await dispatch(initiateHeadAnswerGeneration(generateHeadAnswerParams)); } ); + +export const generateFollowUpAnswer = createAsyncThunk< + void, + string, + AsyncThunkOptions +>( + 'generatedAnswerConversation/generateFollowUpAnswer', + async (question, {getState, dispatch, extra: {navigatorContext, logger}}) => { + const state = getState() as AnswerGenerationApiState; + if (!state.generatedAnswer.answerConfigurationId) { + logger.warn( + 'Missing answerConfigurationId in engine configuration. ' + + 'The generateAnswer action requires an answer configuration ID.' + ); + return; + } + + dispatch(addFollowUpAnswer(question)); + const generateFollowUpAnswerParams = constructGenerateFollowUpAnswerParams( + state, + navigatorContext + ); + await dispatch( + initiateFollowUpAnswerGeneration({ + ...generateFollowUpAnswerParams, + q: question, + }) + ); + } +); diff --git a/packages/headless/src/features/generated-answer/generated-answer-request.ts b/packages/headless/src/features/generated-answer/generated-answer-request.ts index 3c98d378960..0d9534db0ad 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-request.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-request.ts @@ -209,6 +209,54 @@ export const constructGenerateHeadAnswerParams = ( }; }; +export type FollowUpAnswerParams = ReturnType< + typeof constructGenerateFollowUpAnswerParams +>; + +export const constructGenerateFollowUpAnswerParams = ( + state: AnswerGenerationApiState, + navigatorContext: NavigatorContext +) => { + const conversationId = state.generatedAnswer.conversationId; + const q = selectQuery(state)?.q; + + const {aq, cq, dq, lq} = buildAdvancedSearchQueryParams(state); + + const context = selectContext(state); + + const analyticsParams = fromAnalyticsStateToAnalyticsParams( + state.configuration.analytics, + navigatorContext, + {actionCause: selectSearchActionCause(state)} + ); + + const searchHub = selectSearchHub(state); + const pipeline = selectPipeline(state); + const citationsFieldToInclude = selectFieldsToIncludeInCitation(state) ?? []; + + return { + conversationId, + q, + ...(aq && {aq}), + ...(cq && {cq}), + ...(dq && {dq}), + ...(lq && {lq}), + ...(state.query && {enableQuerySyntax: selectEnableQuerySyntax(state)}), + ...(context?.contextValues && { + context: context.contextValues, + }), + pipelineRuleParameters: { + mlGenerativeQuestionAnswering: { + responseFormat: state.generatedAnswer.responseFormat, + citationsFieldToInclude, + }, + }, + ...(searchHub?.length && {searchHub}), + ...(pipeline?.length && {pipeline}), + ...analyticsParams, + }; +}; + const getGeneratedFacetParams = ( state: StreamAnswerAPIState ): AnyFacetRequest[] => diff --git a/packages/headless/src/features/generated-answer/generated-answer-slice.ts b/packages/headless/src/features/generated-answer/generated-answer-slice.ts index 40d3b15bde9..e84d606cb9c 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-slice.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-slice.ts @@ -29,10 +29,47 @@ import { updateMessage, updateResponseFormat, } from './generated-answer-actions.js'; -import {hydrateAnswerFromCache} from './generated-answer-conversation-actions.js'; -import {getGeneratedAnswerInitialState} from './generated-answer-state.js'; +import { + addFollowUpAnswer, + hydrateAnswerFromCache, + setActiveFollowUpAnswerContentFormat, + setActiveFollowUpAnswerId, + setActiveFollowUpCannotAnswer, + setActiveFollowUpIsAnswerGenerated, + setActiveFollowUpIsLoading, + setActiveFollowUpIsStreaming, + updateActiveFollowUpAnswerCitations, + updateActiveFollowUpAnswerMessage, + updateActiveFollowUpError, +} from './generated-answer-conversation-actions.js'; +import { + type GeneratedAnswerConversationTurn, + type GeneratedAnswerState, + getGeneratedAnswerInitialState, +} from './generated-answer-state.js'; import {filterOutDuplicatedCitations} from './utils/generated-answer-citation-utils.js'; +const createInitialFollowUpTurn = ( + question: string +): GeneratedAnswerConversationTurn => ({ + question: question, + isVisible: true, + isLoading: false, + isStreaming: false, + citations: [], + liked: false, + disliked: false, + feedbackSubmitted: false, + isAnswerGenerated: false, + cannotAnswer: false, +}); + +function getActiveFollowUp( + state: GeneratedAnswerState +): GeneratedAnswerConversationTurn | undefined { + return state.followUpAnswers[state.followUpAnswers.length - 1]; +} + export const generatedAnswerReducer = createReducer( getGeneratedAnswerInitialState(), (builder) => @@ -160,4 +197,93 @@ export const generatedAnswerReducer = createReducer( isRetryable: error?.code === RETRYABLE_STREAM_ERROR_CODE, }; }) + .addCase(addFollowUpAnswer, (state, {payload}) => { + state.followUpAnswers.push(createInitialFollowUpTurn(payload)); + }) + .addCase(updateActiveFollowUpAnswerMessage, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + + followUp.isLoading = false; + followUp.isStreaming = true; + if (!state.answer) { + state.answer = ''; + } + + followUp.answer += payload.textDelta; + delete followUp.error; + }) + .addCase(updateActiveFollowUpAnswerCitations, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + + followUp.isLoading = false; + followUp.isStreaming = true; + followUp.citations = filterOutDuplicatedCitations([ + ...followUp.citations, + ...payload.citations, + ]); + delete followUp.error; + }) + .addCase(updateActiveFollowUpError, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + + followUp.isLoading = false; + followUp.isStreaming = false; + followUp.error = { + ...payload, + isRetryable: payload.code === RETRYABLE_STREAM_ERROR_CODE, + }; + followUp.citations = []; + delete followUp.answer; + }) + .addCase(setActiveFollowUpIsLoading, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + followUp.isLoading = payload; + }) + .addCase(setActiveFollowUpIsStreaming, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + followUp.isStreaming = payload; + }) + .addCase(setActiveFollowUpAnswerContentFormat, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + followUp.answerContentFormat = payload; + }) + .addCase(setActiveFollowUpAnswerId, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + followUp.answerId = payload; + }) + .addCase(setActiveFollowUpIsAnswerGenerated, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + followUp.isAnswerGenerated = payload; + }) + .addCase(setActiveFollowUpCannotAnswer, (state, {payload}) => { + const followUp = getActiveFollowUp(state); + if (!followUp) { + return; + } + followUp.cannotAnswer = payload; + }) ); From c37a88b1419842e512ac75df603f941ec989725e Mon Sep 17 00:00:00 2001 From: mmitiche Date: Mon, 29 Dec 2025 15:49:05 -0500 Subject: [PATCH 10/17] augmenting the new generated answer conversation controller with new ask method for asking follow up questions --- .../headless-generated-answer-conversation.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts b/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts index 65902530f8f..8b31a6fbbfe 100644 --- a/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts +++ b/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts @@ -6,7 +6,10 @@ import { resetAnswer, updateAnswerConfigurationId, } from '../../../features/generated-answer/generated-answer-actions.js'; -import type {GeneratedAnswerFeedback} from '../../../features/generated-answer/generated-answer-analytics-actions.js'; +import { + generateFollowUpAnswer, + generateHeadAnswer, +} from '../../../features/generated-answer/generated-answer-conversation-actions.js'; import {queryReducer as query} from '../../../features/query/query-slice.js'; import type { GeneratedAnswerSection, @@ -20,17 +23,16 @@ import { type GeneratedAnswerProps, } from '../../core/generated-answer/headless-core-generated-answer.js'; -interface AnswerApiGeneratedAnswer - extends Omit { +interface GeneratedAnswerConversation extends GeneratedAnswer { /** * Resets the last answer. */ reset(): void; /** - * Sends feedback about why the generated answer was not relevant. - * @param feedback - The feedback that the end user wishes to send. + * Asks a follow-up question. + * @param question - The follow-up question to ask. */ - sendFeedback(feedback: GeneratedAnswerFeedback): void; + ask(question: string): void; } interface AnswerApiGeneratedAnswerProps extends GeneratedAnswerProps {} @@ -52,7 +54,7 @@ export function buildGeneratedAnswerConversation( engine: SearchEngine | InsightEngine, analyticsClient: SearchAPIGeneratedAnswerAnalyticsClient, props: AnswerApiGeneratedAnswerProps = {} -): AnswerApiGeneratedAnswer { +): GeneratedAnswerConversation { if (!loadAnswerApiReducers(engine)) { throw loadReducerError; } @@ -74,10 +76,9 @@ export function buildGeneratedAnswerConversation( const state = getState().generatedAnswer; return state; }, - // retry() { - // const answerApiQueryParams = selectAnswerApiQueryParams(getState()); - // engine.dispatch(fetchAnswer(answerApiQueryParams)); - // }, + retry() { + engine.dispatch(generateHeadAnswer()); + }, reset() { engine.dispatch(resetAnswer()); }, @@ -91,6 +92,9 @@ export function buildGeneratedAnswerConversation( // engine.dispatch(answerEvaluation.endpoints.post.initiate(args)); // engine.dispatch(sendGeneratedAnswerFeedback()); }, + ask(question: string) { + engine.dispatch(generateFollowUpAnswer(question)); + }, }; } From 017f47a6e069d61c1934cff2ac6ae3d86977f185 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Mon, 12 Jan 2026 01:39:33 -0500 Subject: [PATCH 11/17] simplified state and stream runners --- .../answer-generation-api-state.ts | 9 ++- .../answer-generation-api.ts | 1 + .../endpoints/follow-up-answer-endpoint.ts | 24 ++----- .../endpoints/head-answer-endpoint.ts | 32 +++------ .../streaming/answer-streaming-runner.ts | 65 +++++++++++++++++++ .../streaming/stream-executor.ts | 65 ------------------- .../generated-answer-conversation-actions.ts | 10 +-- .../generated-answer-mocks.ts | 3 +- .../generated-answer-slice.ts | 11 ++-- .../generated-answer-state.ts | 64 ++++++++++++------ 10 files changed, 138 insertions(+), 146 deletions(-) create mode 100644 packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts delete mode 100644 packages/headless/src/api/knowledge/answer-generation/streaming/stream-executor.ts diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts index 0b33421373d..7f7f0183056 100644 --- a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts @@ -6,13 +6,16 @@ import type { QueryDefinition, RetryOptions, } from '@reduxjs/toolkit/query'; +import type { + FollowUpAnswerParams, + HeadAnswerParams, +} from '../../../features/generated-answer/generated-answer-request.js'; import type {SearchAppState} from '../../../state/search-app-state.js'; import type { ConfigurationSection, GeneratedAnswerSection, TabSection, } from '../../../state/state-sections.js'; -import type {SearchRequest} from '../../search/search/search-request.js'; import type {GeneratedAnswerDraft} from './shared-types.js'; export interface AnswerGenerationApiSection { @@ -22,7 +25,7 @@ export interface AnswerGenerationApiSection { answerGenerationApi: CombinedState< { generateHeadAnswer: QueryDefinition< - Partial, + HeadAnswerParams, BaseQueryFn< string | FetchArgs, unknown, @@ -35,7 +38,7 @@ export interface AnswerGenerationApiSection { 'answerGenerationApi' >; generateFollowUpAnswer: QueryDefinition< - Partial, + FollowUpAnswerParams, BaseQueryFn< string | FetchArgs, unknown, diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts index 2aa3a1710e7..0ce6e464727 100644 --- a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api.ts @@ -3,6 +3,7 @@ import {dynamicBaseQuery} from '../answer-slice.js'; export const answerGenerationApi = createApi({ reducerPath: 'answerGenerationApi', + refetchOnMountOrArgChange: true, baseQuery: retry(dynamicBaseQuery, {maxRetries: 3}), endpoints: () => ({}), }); diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts index 2aa43478e36..824322c7e41 100644 --- a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts @@ -1,17 +1,16 @@ import type {FollowUpAnswerParams} from '../../../../features/generated-answer/generated-answer-request.js'; -import type {SearchRequest} from '../../../search/search/search-request.js'; import {answerGenerationApi} from '../answer-generation-api.js'; import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; import type {GeneratedAnswerDraft} from '../shared-types.js'; +import {streamAnswerWithStrategy} from '../streaming/answer-streaming-runner.js'; import {followUpAnswerStrategy} from '../streaming/strategies/follow-up-answer-strategy.js'; -import {createStreamExecutor} from '../streaming/stream-executor.js'; export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ overrideExisting: true, endpoints: (builder) => ({ generateFollowUpAnswer: builder.query< GeneratedAnswerDraft, - Partial + FollowUpAnswerParams >({ queryFn: () => { return { @@ -46,12 +45,12 @@ export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-dispatch-and-getstate */ const state = getState() as AnswerGenerationApiState; - const streamAnswerWithStrategy = createStreamExecutor< - GeneratedAnswerDraft, - AnswerGenerationApiState - >(followUpAnswerStrategy); - await streamAnswerWithStrategy(args, state, dispatch, updateCachedData); + await streamAnswerWithStrategy< + FollowUpAnswerParams, + AnswerGenerationApiState, + GeneratedAnswerDraft + >(args, {state, updateCachedData, dispatch}, followUpAnswerStrategy); }, }), }), @@ -64,12 +63,3 @@ export const initiateFollowUpAnswerGeneration = ( params ); }; - -export const selectFollowUpAnswer = ( - params: FollowUpAnswerParams, - state: AnswerGenerationApiState -) => { - return followUpAnswerEndpoint.endpoints.generateFollowUpAnswer.select(params)( - state - ); -}; diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts index d16d72f29be..44cca420a98 100644 --- a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts @@ -1,18 +1,14 @@ import type {HeadAnswerParams} from '../../../../features/generated-answer/generated-answer-request.js'; -import type {SearchRequest} from '../../../search/search/search-request.js'; import {answerGenerationApi} from '../answer-generation-api.js'; import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; import type {GeneratedAnswerDraft} from '../shared-types.js'; +import {streamAnswerWithStrategy} from '../streaming/answer-streaming-runner.js'; import {headAnswerStrategy} from '../streaming/strategies/head-answer-strategy.js'; -import {createStreamExecutor} from '../streaming/stream-executor.js'; export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ overrideExisting: true, endpoints: (builder) => ({ - generateHeadAnswer: builder.query< - GeneratedAnswerDraft, - Partial - >({ + generateHeadAnswer: builder.query({ queryFn: () => { return { data: { @@ -35,23 +31,18 @@ export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ // Standard RTK key, with analytics excluded return `${endpointName}(${JSON.stringify(queryArgsWithoutAnalytics)})`; }, - async onCacheEntryAdded( - args, - {getState, cacheDataLoaded, updateCachedData, dispatch} - ) { - await cacheDataLoaded; + async onQueryStarted(args, {getState, updateCachedData, dispatch}) { /** * createApi has to be called prior to creating the redux store and is used as part of the store setup sequence. * It cannot use the inferred state used by Redux, thus the casting. * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-dispatch-and-getstate */ const state = getState() as AnswerGenerationApiState; - const streamAnswerWithStrategy = createStreamExecutor< - GeneratedAnswerDraft, - AnswerGenerationApiState - >(headAnswerStrategy); - - await streamAnswerWithStrategy(args, state, dispatch, updateCachedData); + await streamAnswerWithStrategy< + HeadAnswerParams, + AnswerGenerationApiState, + GeneratedAnswerDraft + >(args, {state, updateCachedData, dispatch}, headAnswerStrategy); }, }), }), @@ -60,10 +51,3 @@ export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ export const initiateHeadAnswerGeneration = (params: HeadAnswerParams) => { return headAnswerEndpoint.endpoints.generateHeadAnswer.initiate(params); }; - -export const selectHeadAnswer = ( - params: HeadAnswerParams, - state: AnswerGenerationApiState -) => { - return headAnswerEndpoint.endpoints.generateHeadAnswer.select(params)(state); -}; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts new file mode 100644 index 00000000000..e1f08fec976 --- /dev/null +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts @@ -0,0 +1,65 @@ +import type {ThunkDispatch, UnknownAction} from '@reduxjs/toolkit'; +import {fetchEventSource} from '../../../../utils/fetch-event-source/fetch.js'; +import type {Message} from '../shared-types.js'; +import type {StreamingStrategy} from './strategies/strategy-types.js'; + +type StateWithConfiguration = { + configuration: { + accessToken: string; + }; +}; +export const streamAnswerWithStrategy = < + TArgs, + TState extends StateWithConfiguration, + TDraft, +>( + args: TArgs, + api: { + state: TState; + dispatch: ThunkDispatch; + updateCachedData: (updater: (draft: TDraft) => void) => void; + }, + strategy: StreamingStrategy +) => { + const {state, dispatch, updateCachedData} = api; + const endpointUrl = strategy.buildEndpointUrl(state); + const { + configuration: {accessToken}, + } = state; + + return fetchEventSource(endpointUrl, { + method: 'POST', + body: JSON.stringify(args), + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Accept-Encoding': '*', + }, + fetch, + onopen: async (response) => { + strategy.events.handleOpen(response, updateCachedData, dispatch); + }, + onclose: () => { + strategy.events.handleClose(updateCachedData, dispatch); + }, + onerror: (error) => { + strategy.events.handleError(error); + }, + onmessage: (event) => { + const message: Required = JSON.parse(event.data); + strategy.events.handleMessage.error?.( + message, + updateCachedData, + dispatch + ); + + const messageType = message.payloadType; + strategy.events.handleMessage[messageType]?.( + message, + updateCachedData, + dispatch + ); + }, + }); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/stream-executor.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/stream-executor.ts deleted file mode 100644 index 89ce7a0908b..00000000000 --- a/packages/headless/src/api/knowledge/answer-generation/streaming/stream-executor.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type {ThunkDispatch, UnknownAction} from '@reduxjs/toolkit'; -import {fetchEventSource} from '../../../../utils/fetch-event-source/fetch.js'; -import type {Message} from '../shared-types.js'; -import type {StreamingStrategy} from './strategies/strategy-types.js'; - -type StateWithConfiguration = { - configuration: { - accessToken: string; - }; -}; - -export const createStreamExecutor = < - TDraft, - TState extends StateWithConfiguration, ->( - strategy: StreamingStrategy -) => { - return ( - args: {}, - state: TState, - dispatch: ThunkDispatch, - updateCachedData: (updater: (draft: TDraft) => void) => void - ) => { - const endpointUrl = strategy.buildEndpointUrl(state); - const { - configuration: {accessToken}, - } = state; - - return fetchEventSource(endpointUrl, { - method: 'POST', - body: JSON.stringify(args), - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - 'Content-Type': 'application/json', - 'Accept-Encoding': '*', - }, - fetch, - onopen: async (response) => { - strategy.events.handleOpen(response, updateCachedData, dispatch); - }, - onclose: () => { - strategy.events.handleClose(updateCachedData, dispatch); - }, - onerror: (error) => { - strategy.events.handleError(error); - }, - onmessage: (event) => { - const message: Required = JSON.parse(event.data); - strategy.events.handleMessage.error?.( - message, - updateCachedData, - dispatch - ); - - const messageType = message.payloadType; - strategy.events.handleMessage[messageType]?.( - message, - updateCachedData, - dispatch - ); - }, - }); - }; -}; diff --git a/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts index d1e43dc49b5..b8c80bb09e1 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts @@ -12,10 +12,7 @@ import type { } from '../../api/generated-answer/generated-answer-event-payload.js'; import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; import {initiateFollowUpAnswerGeneration} from '../../api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.js'; -import { - initiateHeadAnswerGeneration, - selectHeadAnswer, -} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; +import {initiateHeadAnswerGeneration} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import type {GeneratedAnswerStream} from '../../api/knowledge/generated-answer-stream.js'; import type {AsyncThunkOptions} from '../../app/async-thunk-options.js'; import type {SearchThunkExtraArguments} from '../../app/search-thunk-extra-arguments.js'; @@ -131,11 +128,6 @@ export const generateHeadAnswer = createAsyncThunk< state, navigatorContext ); - const cachedResponse = selectHeadAnswer(generateHeadAnswerParams, state); - if (cachedResponse.status === 'fulfilled') { - dispatch(hydrateAnswerFromCache(cachedResponse.data)); - return; - } await dispatch(initiateHeadAnswerGeneration(generateHeadAnswerParams)); } ); diff --git a/packages/headless/src/features/generated-answer/generated-answer-mocks.ts b/packages/headless/src/features/generated-answer/generated-answer-mocks.ts index d6febc55d83..9e38f130f22 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-mocks.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-mocks.ts @@ -1086,8 +1086,9 @@ export const streamAnswerAPIStateMock: StreamAnswerAPIState = { answerConfigurationId: 'c36fd994-3eb7-4aaf-bfce-2dad4b15a622', cannotAnswer: false, answerGenerationMode: 'automatic', - conversationId: '', followUpAnswers: [], + canAskFollowUp: false, + followUpAnswerEnabled: false, }, }; diff --git a/packages/headless/src/features/generated-answer/generated-answer-slice.ts b/packages/headless/src/features/generated-answer/generated-answer-slice.ts index e84d606cb9c..3675b7a807a 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-slice.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-slice.ts @@ -43,17 +43,14 @@ import { updateActiveFollowUpError, } from './generated-answer-conversation-actions.js'; import { - type GeneratedAnswerConversationTurn, + type FollowUpAnswer, type GeneratedAnswerState, getGeneratedAnswerInitialState, } from './generated-answer-state.js'; import {filterOutDuplicatedCitations} from './utils/generated-answer-citation-utils.js'; -const createInitialFollowUpTurn = ( - question: string -): GeneratedAnswerConversationTurn => ({ +const createInitialFollowUpAnswer = (question: string): FollowUpAnswer => ({ question: question, - isVisible: true, isLoading: false, isStreaming: false, citations: [], @@ -66,7 +63,7 @@ const createInitialFollowUpTurn = ( function getActiveFollowUp( state: GeneratedAnswerState -): GeneratedAnswerConversationTurn | undefined { +): FollowUpAnswer | undefined { return state.followUpAnswers[state.followUpAnswers.length - 1]; } @@ -198,7 +195,7 @@ export const generatedAnswerReducer = createReducer( }; }) .addCase(addFollowUpAnswer, (state, {payload}) => { - state.followUpAnswers.push(createInitialFollowUpTurn(payload)); + state.followUpAnswers.push(createInitialFollowUpAnswer(payload)); }) .addCase(updateActiveFollowUpAnswerMessage, (state, {payload}) => { const followUp = getActiveFollowUp(state); diff --git a/packages/headless/src/features/generated-answer/generated-answer-state.ts b/packages/headless/src/features/generated-answer/generated-answer-state.ts index 9b5a420efff..cdfa852e7fd 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-state.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-state.ts @@ -5,15 +5,7 @@ import type { GeneratedResponseFormat, } from './generated-response-format.js'; -export interface GeneratedAnswerConversationTurn { - /** - * Determines if the generated answer is visible. - */ - isVisible: boolean; - /** - * The question that prompted the generated answer. - */ - question: string; +interface GeneratedAnswerBase { /** * Determines if the generated answer is loading. */ @@ -68,14 +60,28 @@ export interface GeneratedAnswerConversationTurn { answerId?: string; } +export interface FollowUpAnswer extends GeneratedAnswerBase { + /** + * The question that prompted the generated answer. + */ + question: string; +} + /** * A scoped and simplified part of the headless state that is relevant to the `GeneratedAnswer` component. * * @group Controllers * @category GeneratedAnswer */ -export interface GeneratedAnswerState - extends Omit { +export interface GeneratedAnswerState extends GeneratedAnswerBase { + /** + * Determines if the generated answer is visible. + */ + isVisible: boolean; + /** + * Whether the answer is expanded. + */ + expanded: boolean; id: string; /** * Determines if the feedback modal is currently opened. @@ -93,10 +99,6 @@ export interface GeneratedAnswerState * A list of indexed fields to include in the citations returned with the generated answer. */ fieldsToIncludeInCitations: string[]; - /** - * Whether the answer is expanded. - */ - expanded: boolean; /** * The answer configuration unique identifier. */ @@ -107,10 +109,31 @@ export interface GeneratedAnswerState answerApiQueryParams?: AnswerApiQueryParams; /** The current mode of answer generation. */ answerGenerationMode: 'automatic' | 'manual'; - /** The unique identifier of the current conversation. */ - conversationId: string; - /** The list of follow-up answers in a conversational context. */ - followUpAnswers: GeneratedAnswerConversationTurn[]; + + /** NEW: Follow-up capability */ + + /** + * Whether follow-up answers are enabled for this generated answer + * via agent configuration. + */ + followUpAnswerEnabled: boolean; + + /** + * Identifier used to scope follow-up generated answers to the + * current generated answer. + */ + conversationId?: string; + + /** + * Follow-up generated answers produced from follow-up questions + */ + followUpAnswers: FollowUpAnswer[]; + + /** + * Indicates whether more follow-up questions can currently be asked, + * based on backend capabilities or limits. + */ + canAskFollowUp: boolean; } export function getGeneratedAnswerInitialState(): GeneratedAnswerState { @@ -135,7 +158,8 @@ export function getGeneratedAnswerInitialState(): GeneratedAnswerState { answerApiQueryParams: undefined, answerId: undefined, answerGenerationMode: 'automatic', - conversationId: '', + followUpAnswerEnabled: false, followUpAnswers: [], + canAskFollowUp: false, }; } From bbb0b718ff568135975c4db7812f6e3ef5da44a4 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Mon, 12 Jan 2026 13:34:50 -0500 Subject: [PATCH 12/17] isolated follow up in their own redux slice and exposed it with the new generated answer controller --- .../answer-generation-api-state.ts | 14 +- .../endpoints/follow-up-answer-endpoint.ts | 23 +-- .../endpoints/head-answer-endpoint.ts | 15 +- .../answer-generation/shared-types.ts | 2 +- .../streaming/event-handlers.ts | 16 +- .../strategies/follow-up-answer-strategy.ts | 17 +- .../strategies/head-answer-strategy.ts | 7 +- .../generate-answer-listener-middleware.ts | 2 +- .../headless-generated-answer.ts | 4 +- .../headless-generated-answer-conversation.ts | 114 ------------- ...adless-generated-answer-with-follow-ups.ts | 133 +++++++++++++++ .../follow-up-answer-request.ts | 32 ++++ .../follow-up-answers-actions.ts} | 49 ++---- .../follow-up-answers-slice.ts | 126 +++++++++++++++ .../follow-up-answers-state.ts | 49 ++++++ .../generated-answer/answer-api-selectors.ts | 11 ++ .../generated-answer-actions.ts | 39 ++++- .../generated-answer-mocks.ts | 3 - .../generated-answer-request.ts | 52 +----- .../generated-answer-slice.ts | 151 ++---------------- .../generated-answer-state.ts | 46 ++---- packages/headless/src/state/state-sections.ts | 8 + 22 files changed, 479 insertions(+), 434 deletions(-) delete mode 100644 packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts create mode 100644 packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-with-follow-ups.ts create mode 100644 packages/headless/src/features/follow-up-answers/follow-up-answer-request.ts rename packages/headless/src/features/{generated-answer/generated-answer-conversation-actions.ts => follow-up-answers/follow-up-answers-actions.ts} (72%) create mode 100644 packages/headless/src/features/follow-up-answers/follow-up-answers-slice.ts create mode 100644 packages/headless/src/features/follow-up-answers/follow-up-answers-state.ts diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts index 7f7f0183056..8dc7891a530 100644 --- a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts @@ -6,17 +6,16 @@ import type { QueryDefinition, RetryOptions, } from '@reduxjs/toolkit/query'; -import type { - FollowUpAnswerParams, - HeadAnswerParams, -} from '../../../features/generated-answer/generated-answer-request.js'; +import type {FollowUpAnswerParams} from '../../../features/follow-up-answers/follow-up-answer-request.js'; +import type {HeadAnswerParams} from '../../../features/generated-answer/generated-answer-request.js'; import type {SearchAppState} from '../../../state/search-app-state.js'; import type { ConfigurationSection, + FollowUpAnswersSection, GeneratedAnswerSection, TabSection, } from '../../../state/state-sections.js'; -import type {GeneratedAnswerDraft} from './shared-types.js'; +import type {GeneratedAnswerServerState} from './shared-types.js'; export interface AnswerGenerationApiSection { // CombinedState is an internal type from RTK Query that is used directly to break dependency on actual @@ -34,7 +33,7 @@ export interface AnswerGenerationApiSection { {} >, never, - GeneratedAnswerDraft, + GeneratedAnswerServerState, 'answerGenerationApi' >; generateFollowUpAnswer: QueryDefinition< @@ -47,7 +46,7 @@ export interface AnswerGenerationApiSection { {} >, never, - GeneratedAnswerDraft, + GeneratedAnswerServerState, 'answerGenerationApi' >; }, @@ -63,4 +62,5 @@ export type AnswerGenerationApiState = { ConfigurationSection & Partial & GeneratedAnswerSection & + FollowUpAnswersSection & Partial; diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts index 824322c7e41..4130f4b216c 100644 --- a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts @@ -1,7 +1,7 @@ -import type {FollowUpAnswerParams} from '../../../../features/generated-answer/generated-answer-request.js'; +import type {FollowUpAnswerParams} from '../../../../features/follow-up-answers/follow-up-answer-request.js'; import {answerGenerationApi} from '../answer-generation-api.js'; import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; -import type {GeneratedAnswerDraft} from '../shared-types.js'; +import type {GeneratedAnswerServerState} from '../shared-types.js'; import {streamAnswerWithStrategy} from '../streaming/answer-streaming-runner.js'; import {followUpAnswerStrategy} from '../streaming/strategies/follow-up-answer-strategy.js'; @@ -9,7 +9,7 @@ export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ overrideExisting: true, endpoints: (builder) => ({ generateFollowUpAnswer: builder.query< - GeneratedAnswerDraft, + GeneratedAnswerServerState, FollowUpAnswerParams >({ queryFn: () => { @@ -25,20 +25,7 @@ export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ }, }; }, - serializeQueryArgs: ({endpointName, queryArgs}) => { - // RTK Query serialize our endpoints and they're serialized state arguments as the key in the store. - // Keys must match, because if anything in the query changes, it's not the same query anymore. - // Analytics data is excluded entirely as it contains volatile fields that change during streaming. - const {analytics: _analytics, ...queryArgsWithoutAnalytics} = queryArgs; - - // Standard RTK key, with analytics excluded - return `${endpointName}(${JSON.stringify(queryArgsWithoutAnalytics)})`; - }, - async onCacheEntryAdded( - args, - {getState, cacheDataLoaded, updateCachedData, dispatch} - ) { - await cacheDataLoaded; + async onQueryStarted(args, {getState, updateCachedData, dispatch}) { /** * createApi has to be called prior to creating the redux store and is used as part of the store setup sequence. * It cannot use the inferred state used by Redux, thus the casting. @@ -49,7 +36,7 @@ export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ await streamAnswerWithStrategy< FollowUpAnswerParams, AnswerGenerationApiState, - GeneratedAnswerDraft + GeneratedAnswerServerState >(args, {state, updateCachedData, dispatch}, followUpAnswerStrategy); }, }), diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts index 44cca420a98..0181375dbc3 100644 --- a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts @@ -1,14 +1,18 @@ +import {selectHeadAnswerApiQueryParams} from '../../../../features/generated-answer/answer-api-selectors.js'; import type {HeadAnswerParams} from '../../../../features/generated-answer/generated-answer-request.js'; import {answerGenerationApi} from '../answer-generation-api.js'; import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; -import type {GeneratedAnswerDraft} from '../shared-types.js'; +import type {GeneratedAnswerServerState} from '../shared-types.js'; import {streamAnswerWithStrategy} from '../streaming/answer-streaming-runner.js'; import {headAnswerStrategy} from '../streaming/strategies/head-answer-strategy.js'; export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ overrideExisting: true, endpoints: (builder) => ({ - generateHeadAnswer: builder.query({ + generateHeadAnswer: builder.query< + GeneratedAnswerServerState, + HeadAnswerParams + >({ queryFn: () => { return { data: { @@ -41,7 +45,7 @@ export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ await streamAnswerWithStrategy< HeadAnswerParams, AnswerGenerationApiState, - GeneratedAnswerDraft + GeneratedAnswerServerState >(args, {state, updateCachedData, dispatch}, headAnswerStrategy); }, }), @@ -51,3 +55,8 @@ export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ export const initiateHeadAnswerGeneration = (params: HeadAnswerParams) => { return headAnswerEndpoint.endpoints.generateHeadAnswer.initiate(params); }; + +export const selectHeadAnswer = (state: AnswerGenerationApiState) => { + const params = selectHeadAnswerApiQueryParams(state); + return headAnswerEndpoint.endpoints.generateHeadAnswer.select(params)(state); +}; diff --git a/packages/headless/src/api/knowledge/answer-generation/shared-types.ts b/packages/headless/src/api/knowledge/answer-generation/shared-types.ts index b24e7ac5cc8..bce372058b4 100644 --- a/packages/headless/src/api/knowledge/answer-generation/shared-types.ts +++ b/packages/headless/src/api/knowledge/answer-generation/shared-types.ts @@ -1,7 +1,7 @@ import type {GeneratedContentFormat} from '../../../features/generated-answer/generated-response-format.js'; import type {GeneratedAnswerCitation} from '../../generated-answer/generated-answer-event-payload.js'; -export interface GeneratedAnswerDraft { +export interface GeneratedAnswerServerState { answerId?: string; contentFormat?: GeneratedContentFormat; answer?: string; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts index d757f7f2eba..83b70cd9fcb 100644 --- a/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/event-handlers.ts @@ -1,11 +1,11 @@ import type { - GeneratedAnswerDraft, + GeneratedAnswerServerState, Message, StreamPayload, } from '../shared-types.js'; export const handleAnswerId = ( - draft: GeneratedAnswerDraft, + draft: GeneratedAnswerServerState, answerId: string ) => { if (answerId) { @@ -14,8 +14,8 @@ export const handleAnswerId = ( }; export const handleHeaderMessage = ( - draft: GeneratedAnswerDraft, - payload: Pick + draft: GeneratedAnswerServerState, + payload: Pick ) => { const {contentFormat} = payload; draft.contentFormat = contentFormat; @@ -24,7 +24,7 @@ export const handleHeaderMessage = ( }; export const handleMessage = ( - draft: GeneratedAnswerDraft, + draft: GeneratedAnswerServerState, payload: Pick ) => { if (draft.answer === undefined) { @@ -35,14 +35,14 @@ export const handleMessage = ( }; export const handleCitations = ( - draft: GeneratedAnswerDraft, + draft: GeneratedAnswerServerState, payload: Pick ) => { draft.citations = payload.citations; }; export const handleEndOfStream = ( - draft: GeneratedAnswerDraft, + draft: GeneratedAnswerServerState, payload: Pick ) => { draft.generated = payload.answerGenerated; @@ -50,7 +50,7 @@ export const handleEndOfStream = ( }; export const handleError = ( - draft: GeneratedAnswerDraft, + draft: GeneratedAnswerServerState, message: Required ) => { const errorMessage = message.errorMessage || 'Unknown error occurred'; diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts index 174ac1c0555..8d1bdfe2eb1 100644 --- a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/follow-up-answer-strategy.ts @@ -1,7 +1,3 @@ -import { - logGeneratedAnswerResponseLinked, - logGeneratedAnswerStreamEnd, -} from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; import { setActiveFollowUpAnswerContentFormat, setActiveFollowUpAnswerId, @@ -11,9 +7,16 @@ import { setActiveFollowUpIsStreaming, updateActiveFollowUpAnswerCitations, updateActiveFollowUpAnswerMessage, -} from '../../../../../features/generated-answer/generated-answer-conversation-actions.js'; +} from '../../../../../features/follow-up-answers/follow-up-answers-actions.js'; +import { + logGeneratedAnswerResponseLinked, + logGeneratedAnswerStreamEnd, +} from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; import type {AnswerGenerationApiState} from '../../answer-generation-api-state.js'; -import type {GeneratedAnswerDraft, StreamPayload} from '../../shared-types.js'; +import type { + GeneratedAnswerServerState, + StreamPayload, +} from '../../shared-types.js'; import {buildHeadAnswerEndpointUrl} from '../../url-builders/endpoint-url-builder.js'; import { handleAnswerId, @@ -26,7 +29,7 @@ import { import type {StreamingStrategy} from './strategy-types.js'; export const followUpAnswerStrategy: StreamingStrategy< - GeneratedAnswerDraft, + GeneratedAnswerServerState, AnswerGenerationApiState > = { buildEndpointUrl: (state) => buildHeadAnswerEndpointUrl(state), diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts index 949d2e27849..2dd94ba7893 100644 --- a/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/strategies/head-answer-strategy.ts @@ -13,7 +13,10 @@ import { logGeneratedAnswerStreamEnd, } from '../../../../../features/generated-answer/generated-answer-analytics-actions.js'; import type {AnswerGenerationApiState} from '../../answer-generation-api-state.js'; -import type {GeneratedAnswerDraft, StreamPayload} from '../../shared-types.js'; +import type { + GeneratedAnswerServerState, + StreamPayload, +} from '../../shared-types.js'; import {buildHeadAnswerEndpointUrl} from '../../url-builders/endpoint-url-builder.js'; import { handleAnswerId, @@ -26,7 +29,7 @@ import { import type {StreamingStrategy} from './strategy-types.js'; export const headAnswerStrategy: StreamingStrategy< - GeneratedAnswerDraft, + GeneratedAnswerServerState, AnswerGenerationApiState > = { buildEndpointUrl: (state) => buildHeadAnswerEndpointUrl(state), diff --git a/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts b/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts index 66d50bf36d7..fe0e3590f48 100644 --- a/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts +++ b/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts @@ -4,7 +4,7 @@ import { type UnknownAction, } from '@reduxjs/toolkit'; import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; -import {generateHeadAnswer} from '../../features/generated-answer/generated-answer-conversation-actions.js'; +import {generateHeadAnswer} from '../../features/generated-answer/generated-answer-actions.js'; import {isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI} from '../../features/generated-answer/generated-answer-selectors.js'; import {selectQuery} from '../../features/query/query-selectors.js'; import {executeSearch} from '../../features/search/search-actions.js'; diff --git a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts index a941cd3ec10..e9ed87de1fb 100644 --- a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts +++ b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts @@ -11,7 +11,7 @@ import type { } from '../core/generated-answer/headless-core-generated-answer.js'; import {buildSearchAPIGeneratedAnswer} from '../core/generated-answer/headless-searchapi-generated-answer.js'; import {buildAnswerApiGeneratedAnswer} from '../knowledge/generated-answer/headless-answerapi-generated-answer.js'; -import {buildGeneratedAnswerConversation} from '../knowledge/generated-answer-conversation/headless-generated-answer-conversation.js'; +import {buildGeneratedAnswerWithFollowUps} from '../knowledge/generated-answer-conversation/headless-generated-answer-with-follow-ups.js'; export type { GeneratedAnswerCitation, @@ -40,7 +40,7 @@ export function buildGeneratedAnswer( engine.state.configuration.analytics.analyticsMode ); const controller = props.agentId - ? buildGeneratedAnswerConversation( + ? buildGeneratedAnswerWithFollowUps( engine, generatedAnswerAnalyticsClient, props diff --git a/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts b/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts deleted file mode 100644 index 8b31a6fbbfe..00000000000 --- a/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-conversation.ts +++ /dev/null @@ -1,114 +0,0 @@ -import {answerGenerationApi} from '../../../api/knowledge/answer-generation/answer-generation-api.js'; -import {warnIfUsingNextAnalyticsModeForServiceFeature} from '../../../app/engine.js'; -import type {InsightEngine} from '../../../app/insight-engine/insight-engine.js'; -import type {SearchEngine} from '../../../app/search-engine/search-engine.js'; -import { - resetAnswer, - updateAnswerConfigurationId, -} from '../../../features/generated-answer/generated-answer-actions.js'; -import { - generateFollowUpAnswer, - generateHeadAnswer, -} from '../../../features/generated-answer/generated-answer-conversation-actions.js'; -import {queryReducer as query} from '../../../features/query/query-slice.js'; -import type { - GeneratedAnswerSection, - QuerySection, -} from '../../../state/state-sections.js'; -import {loadReducerError} from '../../../utils/errors.js'; -import { - buildCoreGeneratedAnswer, - type GeneratedAnswer, - type GeneratedAnswerAnalyticsClient, - type GeneratedAnswerProps, -} from '../../core/generated-answer/headless-core-generated-answer.js'; - -interface GeneratedAnswerConversation extends GeneratedAnswer { - /** - * Resets the last answer. - */ - reset(): void; - /** - * Asks a follow-up question. - * @param question - The follow-up question to ask. - */ - ask(question: string): void; -} - -interface AnswerApiGeneratedAnswerProps extends GeneratedAnswerProps {} - -interface SearchAPIGeneratedAnswerAnalyticsClient - extends GeneratedAnswerAnalyticsClient {} - -/** - * - * @internal - * - * Creates a `AnswerApiGeneratedAnswer` controller instance using the Answer API stream pattern. - * - * @param engine - The headless engine. - * @param props - The configurable `AnswerApiGeneratedAnswer` properties. - * @returns A `AnswerApiGeneratedAnswer` controller instance. - */ -export function buildGeneratedAnswerConversation( - engine: SearchEngine | InsightEngine, - analyticsClient: SearchAPIGeneratedAnswerAnalyticsClient, - props: AnswerApiGeneratedAnswerProps = {} -): GeneratedAnswerConversation { - if (!loadAnswerApiReducers(engine)) { - throw loadReducerError; - } - warnIfUsingNextAnalyticsModeForServiceFeature( - engine.state.configuration.analytics.analyticsMode - ); - - const {...controller} = buildCoreGeneratedAnswer( - engine, - analyticsClient, - props - ); - const getState = () => engine.state; - engine.dispatch(updateAnswerConfigurationId(props.agentId!)); - - return { - ...controller, - get state() { - const state = getState().generatedAnswer; - return state; - }, - retry() { - engine.dispatch(generateHeadAnswer()); - }, - reset() { - engine.dispatch(resetAnswer()); - }, - async sendFeedback(feedback) { - engine.dispatch(analyticsClient.logGeneratedAnswerFeedback(feedback)); - // const args = parseEvaluationArguments({ - // query: getState().query.q, - // feedback, - // answerApiState: selectAnswer(engine.state).data!, - // }); - // engine.dispatch(answerEvaluation.endpoints.post.initiate(args)); - // engine.dispatch(sendGeneratedAnswerFeedback()); - }, - ask(question: string) { - engine.dispatch(generateFollowUpAnswer(question)); - }, - }; -} - -function loadAnswerApiReducers( - engine: SearchEngine | InsightEngine -): engine is SearchEngine< - GeneratedAnswerSection & - QuerySection & { - answerGenerationApi: ReturnType; - } -> { - engine.addReducers({ - [answerGenerationApi.reducerPath]: answerGenerationApi.reducer, - query, - }); - return true; -} diff --git a/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-with-follow-ups.ts b/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-with-follow-ups.ts new file mode 100644 index 00000000000..e53812cd727 --- /dev/null +++ b/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-with-follow-ups.ts @@ -0,0 +1,133 @@ +import {answerGenerationApi} from '../../../api/knowledge/answer-generation/answer-generation-api.js'; +import {selectHeadAnswer} from '../../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; +import type {InsightEngine} from '../../../app/insight-engine/insight-engine.js'; +import type {SearchEngine} from '../../../app/search-engine/search-engine.js'; +import {generateFollowUpAnswer} from '../../../features/follow-up-answers/follow-up-answers-actions.js'; +import {followUpAnswersReducer as followUpAnswers} from '../../../features/follow-up-answers/follow-up-answers-slice.js'; +import type {FollowUpAnswersState} from '../../../features/follow-up-answers/follow-up-answers-state.js'; +import { + generateHeadAnswer, + updateAnswerConfigurationId, +} from '../../../features/generated-answer/generated-answer-actions.js'; +import {queryReducer as query} from '../../../features/query/query-slice.js'; +import type {GeneratedAnswerState} from '../../../index.js'; +import type { + FollowUpAnswersSection, + GeneratedAnswerSection, + QuerySection, +} from '../../../state/state-sections.js'; +import {loadReducerError} from '../../../utils/errors.js'; +import { + buildCoreGeneratedAnswer, + type GeneratedAnswer, + type GeneratedAnswerAnalyticsClient, + type GeneratedAnswerProps, +} from '../../core/generated-answer/headless-core-generated-answer.js'; + +interface GeneratedAnswerWithFollowUpsState extends GeneratedAnswerState { + followUpAnswers: FollowUpAnswersState; +} + +interface GeneratedAnswerWithFollowUps extends GeneratedAnswer { + /** + * The state of the GeneratedAnswer controller. + */ + state: GeneratedAnswerWithFollowUpsState; + /** + * Asks a follow-up question. + * @param question - The follow-up question to ask. + */ + askFollowUp(question: string): void; +} + +/** + * + * @internal + * + * Creates a `GeneratedAnswerWithFollowUps` controller instance using the Answer API stream pattern. + * + * @param engine - The headless engine. + * @param props - The configurable `GeneratedAnswerWithFollowUps` properties. + * @returns A `GeneratedAnswerWithFollowUps` controller instance. + */ +export function buildGeneratedAnswerWithFollowUps( + engine: SearchEngine | InsightEngine, + analyticsClient: GeneratedAnswerAnalyticsClient, + props: GeneratedAnswerProps = {} +): GeneratedAnswerWithFollowUps { + if (!loadReducers(engine)) { + throw loadReducerError; + } + + const {...controller} = buildCoreGeneratedAnswer( + engine, + analyticsClient, + props + ); + const getState = () => engine.state; + engine.dispatch(updateAnswerConfigurationId(props.agentId!)); + + return { + ...controller, + get state() { + const clientState = getState().generatedAnswer; + const serverState = selectHeadAnswer(engine.state)?.data; + const followUpAnswersState = getState().followUpAnswers; + + return { + /** Server-owned (RTK Query) */ + answer: serverState?.answer, + answerContentFormat: serverState?.contentFormat, + citations: serverState?.citations ?? [], + isLoading: serverState?.isLoading ?? false, + isStreaming: serverState?.isStreaming ?? false, + error: serverState?.error, + answerId: serverState?.answerId, + isAnswerGenerated: Boolean(serverState?.generated), + cannotAnswer: + !serverState?.isLoading && + !serverState?.isStreaming && + !serverState?.answer, + + /** Client-owned (Redux) */ + isVisible: clientState.isVisible, + expanded: clientState.expanded, + liked: clientState.liked, + disliked: clientState.disliked, + feedbackSubmitted: clientState.feedbackSubmitted, + feedbackModalOpen: clientState.feedbackModalOpen, + isEnabled: clientState.isEnabled, + responseFormat: clientState.responseFormat, + fieldsToIncludeInCitations: clientState.fieldsToIncludeInCitations, + answerGenerationMode: clientState.answerGenerationMode, + id: clientState.id, + + /** Follow-up answers state */ + followUpAnswers: followUpAnswersState, + }; + }, + retry() { + engine.dispatch(generateHeadAnswer()); + }, + askFollowUp(question: string) { + engine.dispatch(generateFollowUpAnswer(question)); + }, + }; +} + +function loadReducers( + engine: SearchEngine | InsightEngine +): engine is SearchEngine< + GeneratedAnswerSection & + FollowUpAnswersSection & + QuerySection & { + answerGenerationApi: ReturnType; + } +> { + engine.addReducers({ + [answerGenerationApi.reducerPath]: answerGenerationApi.reducer, + query, + followUpAnswers, + }); + return true; +} diff --git a/packages/headless/src/features/follow-up-answers/follow-up-answer-request.ts b/packages/headless/src/features/follow-up-answers/follow-up-answer-request.ts new file mode 100644 index 00000000000..17c4948b310 --- /dev/null +++ b/packages/headless/src/features/follow-up-answers/follow-up-answer-request.ts @@ -0,0 +1,32 @@ +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import {selectFieldsToIncludeInCitation} from '../generated-answer/generated-answer-selectors.js'; +import {selectPipeline} from '../pipeline/select-pipeline.js'; +import {selectSearchHub} from '../search-hub/search-hub-selectors.js'; + +export type FollowUpAnswerParams = ReturnType< + typeof constructGenerateFollowUpAnswerParams +>; + +export const constructGenerateFollowUpAnswerParams = ( + followUpQuestion: string, + state: AnswerGenerationApiState +) => { + const conversationId = state.followUpAnswers.id; + + const searchHub = selectSearchHub(state); + const pipeline = selectPipeline(state); + const citationsFieldToInclude = selectFieldsToIncludeInCitation(state) ?? []; + + return { + conversationId, + q: followUpQuestion, + pipelineRuleParameters: { + mlGenerativeQuestionAnswering: { + responseFormat: state.generatedAnswer.responseFormat, + citationsFieldToInclude, + }, + }, + ...(searchHub?.length && {searchHub}), + ...(pipeline?.length && {pipeline}), + }; +}; diff --git a/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts b/packages/headless/src/features/follow-up-answers/follow-up-answers-actions.ts similarity index 72% rename from packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts rename to packages/headless/src/features/follow-up-answers/follow-up-answers-actions.ts index b8c80bb09e1..6940a987c6d 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-conversation-actions.ts +++ b/packages/headless/src/features/follow-up-answers/follow-up-answers-actions.ts @@ -12,8 +12,6 @@ import type { } from '../../api/generated-answer/generated-answer-event-payload.js'; import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; import {initiateFollowUpAnswerGeneration} from '../../api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.js'; -import {initiateHeadAnswerGeneration} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; -import type {GeneratedAnswerStream} from '../../api/knowledge/generated-answer-stream.js'; import type {AsyncThunkOptions} from '../../app/async-thunk-options.js'; import type {SearchThunkExtraArguments} from '../../app/search-thunk-extra-arguments.js'; import { @@ -24,18 +22,16 @@ import { answerContentFormatSchema, citationSchema, type GeneratedAnswerErrorPayload, - resetAnswer, -} from './generated-answer-actions.js'; -import { - constructGenerateFollowUpAnswerParams, - constructGenerateHeadAnswerParams, -} from './generated-answer-request.js'; -import type {GeneratedContentFormat} from './generated-response-format.js'; +} from '../generated-answer/generated-answer-actions.js'; +import type {GeneratedContentFormat} from '../generated-answer/generated-response-format.js'; +import {constructGenerateFollowUpAnswerParams} from './follow-up-answer-request.js'; const stringValue = new StringValue({required: true}); -export const hydrateAnswerFromCache = createAction( - 'generatedAnswer/hydrateFromCache' +export const setIsEnabled = createAction( + 'generatedAnswer/setIsEnabled', + (payload: boolean) => + validatePayload(payload, new BooleanValue({required: true})) ); export const addFollowUpAnswer = createAction( @@ -107,38 +103,13 @@ export const setActiveFollowUpCannotAnswer = createAction( validatePayload(payload, new BooleanValue({required: true})) ); -export const generateHeadAnswer = createAsyncThunk< - void, - void, - AsyncThunkOptions ->( - 'generatedAnswerConversation/generateHeadAnswer', - async (_, {getState, dispatch, extra: {navigatorContext, logger}}) => { - const state = getState() as AnswerGenerationApiState; - if (!state.generatedAnswer.answerConfigurationId) { - logger.warn( - 'Missing answerConfigurationId in engine configuration. ' + - 'The generateAnswer action requires an answer configuration ID.' - ); - return; - } - - dispatch(resetAnswer()); - const generateHeadAnswerParams = constructGenerateHeadAnswerParams( - state, - navigatorContext - ); - await dispatch(initiateHeadAnswerGeneration(generateHeadAnswerParams)); - } -); - export const generateFollowUpAnswer = createAsyncThunk< void, string, AsyncThunkOptions >( 'generatedAnswerConversation/generateFollowUpAnswer', - async (question, {getState, dispatch, extra: {navigatorContext, logger}}) => { + async (question, {getState, dispatch, extra: {logger}}) => { const state = getState() as AnswerGenerationApiState; if (!state.generatedAnswer.answerConfigurationId) { logger.warn( @@ -150,8 +121,8 @@ export const generateFollowUpAnswer = createAsyncThunk< dispatch(addFollowUpAnswer(question)); const generateFollowUpAnswerParams = constructGenerateFollowUpAnswerParams( - state, - navigatorContext + question, + state ); await dispatch( initiateFollowUpAnswerGeneration({ diff --git a/packages/headless/src/features/follow-up-answers/follow-up-answers-slice.ts b/packages/headless/src/features/follow-up-answers/follow-up-answers-slice.ts new file mode 100644 index 00000000000..c00ee2ed114 --- /dev/null +++ b/packages/headless/src/features/follow-up-answers/follow-up-answers-slice.ts @@ -0,0 +1,126 @@ +import {createReducer} from '@reduxjs/toolkit'; +import {RETRYABLE_STREAM_ERROR_CODE} from '../../api/generated-answer/generated-answer-client.js'; +import {filterOutDuplicatedCitations} from '../generated-answer/utils/generated-answer-citation-utils.js'; +import { + addFollowUpAnswer, + setActiveFollowUpAnswerContentFormat, + setActiveFollowUpAnswerId, + setActiveFollowUpCannotAnswer, + setActiveFollowUpIsAnswerGenerated, + setActiveFollowUpIsLoading, + setActiveFollowUpIsStreaming, + setIsEnabled, + updateActiveFollowUpAnswerCitations, + updateActiveFollowUpAnswerMessage, + updateActiveFollowUpError, +} from './follow-up-answers-actions.js'; +import { + createInitialFollowUpAnswer, + type FollowUpAnswer, + type FollowUpAnswersState, + getFollowUpAnswersInitialState, +} from './follow-up-answers-state.js'; + +function getActiveFollowUp( + state: FollowUpAnswersState +): FollowUpAnswer | undefined { + return state.answers[state.answers.length - 1]; +} + +export const followUpAnswersReducer = createReducer( + getFollowUpAnswersInitialState(), + (builder) => + builder + .addCase(setIsEnabled, (state, {payload}) => { + state.isEnabled = payload; + }) + .addCase(addFollowUpAnswer, (state, {payload}) => { + state.answers.push(createInitialFollowUpAnswer(payload)); + }) + .addCase(updateActiveFollowUpAnswerMessage, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + + followUpAnswer.isLoading = false; + followUpAnswer.isStreaming = true; + if (!followUpAnswer.answer) { + followUpAnswer.answer = ''; + } + + followUpAnswer.answer += payload.textDelta; + delete followUpAnswer.error; + }) + .addCase(updateActiveFollowUpAnswerCitations, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + + followUpAnswer.isLoading = false; + followUpAnswer.isStreaming = true; + followUpAnswer.citations = filterOutDuplicatedCitations([ + ...followUpAnswer.citations, + ...payload.citations, + ]); + delete followUpAnswer.error; + }) + .addCase(updateActiveFollowUpError, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + + followUpAnswer.isLoading = false; + followUpAnswer.isStreaming = false; + followUpAnswer.error = { + ...payload, + isRetryable: payload.code === RETRYABLE_STREAM_ERROR_CODE, + }; + followUpAnswer.citations = []; + delete followUpAnswer.answer; + }) + .addCase(setActiveFollowUpIsLoading, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.isLoading = payload; + }) + .addCase(setActiveFollowUpIsStreaming, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.isStreaming = payload; + }) + .addCase(setActiveFollowUpAnswerContentFormat, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.answerContentFormat = payload; + }) + .addCase(setActiveFollowUpAnswerId, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.answerId = payload; + }) + .addCase(setActiveFollowUpIsAnswerGenerated, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.isAnswerGenerated = payload; + }) + .addCase(setActiveFollowUpCannotAnswer, (state, {payload}) => { + const followUpAnswer = getActiveFollowUp(state); + if (!followUpAnswer) { + return; + } + followUpAnswer.cannotAnswer = payload; + }) +); diff --git a/packages/headless/src/features/follow-up-answers/follow-up-answers-state.ts b/packages/headless/src/features/follow-up-answers/follow-up-answers-state.ts new file mode 100644 index 00000000000..49e23540eea --- /dev/null +++ b/packages/headless/src/features/follow-up-answers/follow-up-answers-state.ts @@ -0,0 +1,49 @@ +import type {GeneratedAnswerBase} from '../generated-answer/generated-answer-state.js'; + +export interface FollowUpAnswer extends GeneratedAnswerBase { + /** The question asked for this follow-up answer. */ + question: string; +} + +/** + * The follow-up answers state. + */ +export interface FollowUpAnswersState { + /** The unique identifier of the follow-up answer session. */ + id: string; + /** + * Determines if the follow-up answer feature is enabled. + */ + isEnabled: boolean; + /** + * The follow-up answers. + */ + answers: FollowUpAnswer[]; + /** + * Can ask more follow-up answers. + */ + canAskMore: boolean; +} + +export function getFollowUpAnswersInitialState(): FollowUpAnswersState { + return { + id: '', + isEnabled: false, + answers: [], + canAskMore: false, + }; +} + +export const createInitialFollowUpAnswer = ( + question: string +): FollowUpAnswer => ({ + question: question, + isLoading: false, + isStreaming: false, + citations: [], + liked: false, + disliked: false, + feedbackSubmitted: false, + isAnswerGenerated: false, + cannotAnswer: false, +}); diff --git a/packages/headless/src/features/generated-answer/answer-api-selectors.ts b/packages/headless/src/features/generated-answer/answer-api-selectors.ts index 169bf073af3..a42d5a4c530 100644 --- a/packages/headless/src/features/generated-answer/answer-api-selectors.ts +++ b/packages/headless/src/features/generated-answer/answer-api-selectors.ts @@ -27,3 +27,14 @@ export const selectAnswerApiQueryParams = createSelector( (state) => state.generatedAnswer?.answerApiQueryParams, (answerApiQueryParams) => answerApiQueryParams ?? skipToken ); + +/** + * If answer params are not available, returns `skipToken`, a special value from RTK Query + * that tells RTK Query to "skip" running a query or selector until the params are ready. + * + * @see https://redux-toolkit.js.org/rtk-query/usage-with-typescript#skipping-queries-with-typescript-using-skiptoken + */ +export const selectHeadAnswerApiQueryParams = createSelector( + (state) => state.generatedAnswer?.headAnswerApiQueryParams, + (headAnswerApiQueryParams) => headAnswerApiQueryParams ?? skipToken +); diff --git a/packages/headless/src/features/generated-answer/generated-answer-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-actions.ts index af18bbb52e9..9979eb73cee 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-actions.ts @@ -16,11 +16,16 @@ import type { GeneratedAnswerStreamEventData, } from '../../api/generated-answer/generated-answer-event-payload.js'; import type {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request.js'; +import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import {initiateHeadAnswerGeneration} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import {fetchAnswer} from '../../api/knowledge/stream-answer-api.js'; import type {StreamAnswerAPIState} from '../../api/knowledge/stream-answer-api-state.js'; import type {AsyncThunkOptions} from '../../app/async-thunk-options.js'; import type {SearchThunkExtraArguments} from '../../app/search-thunk-extra-arguments.js'; -import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; +import type { + AnswerApiQueryParams, + HeadAnswerParams, +} from '../../features/generated-answer/generated-answer-request.js'; import type { ConfigurationSection, DebugSection, @@ -39,6 +44,7 @@ import { import { buildStreamingRequest, constructAnswerAPIQueryParams, + constructGenerateHeadAnswerParams, } from './generated-answer-request.js'; import { type GeneratedContentFormat, @@ -216,6 +222,11 @@ export const setAnswerApiQueryParams = createAction( validatePayload(payload, new RecordValue({})) ); +export const setHeadAnswerApiQueryParams = createAction( + 'generatedAnswer/setHeadAnswerApiQueryParams', + (payload: HeadAnswerParams) => validatePayload(payload, new RecordValue({})) +); + interface StreamAnswerArgs { setAbortControllerRef: (ref: AbortController) => void; } @@ -362,3 +373,29 @@ export const generateAnswer = createAsyncThunk< } } ); + +export const generateHeadAnswer = createAsyncThunk< + void, + void, + AsyncThunkOptions +>( + 'generatedAnswerConversation/generateHeadAnswer', + async (_, {getState, dispatch, extra: {navigatorContext, logger}}) => { + const state = getState() as AnswerGenerationApiState; + if (!state.generatedAnswer.answerConfigurationId) { + logger.warn( + 'Missing answerConfigurationId in engine configuration. ' + + 'The generateAnswer action requires an answer configuration ID.' + ); + return; + } + + dispatch(resetAnswer()); + const generateHeadAnswerParams = constructGenerateHeadAnswerParams( + state, + navigatorContext + ); + dispatch(setHeadAnswerApiQueryParams(generateHeadAnswerParams)); + await dispatch(initiateHeadAnswerGeneration(generateHeadAnswerParams)); + } +); diff --git a/packages/headless/src/features/generated-answer/generated-answer-mocks.ts b/packages/headless/src/features/generated-answer/generated-answer-mocks.ts index 9e38f130f22..c46e5ae674e 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-mocks.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-mocks.ts @@ -1086,9 +1086,6 @@ export const streamAnswerAPIStateMock: StreamAnswerAPIState = { answerConfigurationId: 'c36fd994-3eb7-4aaf-bfce-2dad4b15a622', cannotAnswer: false, answerGenerationMode: 'automatic', - followUpAnswers: [], - canAskFollowUp: false, - followUpAnswerEnabled: false, }, }; diff --git a/packages/headless/src/features/generated-answer/generated-answer-request.ts b/packages/headless/src/features/generated-answer/generated-answer-request.ts index 0d9534db0ad..67cce57412a 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-request.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-request.ts @@ -209,54 +209,6 @@ export const constructGenerateHeadAnswerParams = ( }; }; -export type FollowUpAnswerParams = ReturnType< - typeof constructGenerateFollowUpAnswerParams ->; - -export const constructGenerateFollowUpAnswerParams = ( - state: AnswerGenerationApiState, - navigatorContext: NavigatorContext -) => { - const conversationId = state.generatedAnswer.conversationId; - const q = selectQuery(state)?.q; - - const {aq, cq, dq, lq} = buildAdvancedSearchQueryParams(state); - - const context = selectContext(state); - - const analyticsParams = fromAnalyticsStateToAnalyticsParams( - state.configuration.analytics, - navigatorContext, - {actionCause: selectSearchActionCause(state)} - ); - - const searchHub = selectSearchHub(state); - const pipeline = selectPipeline(state); - const citationsFieldToInclude = selectFieldsToIncludeInCitation(state) ?? []; - - return { - conversationId, - q, - ...(aq && {aq}), - ...(cq && {cq}), - ...(dq && {dq}), - ...(lq && {lq}), - ...(state.query && {enableQuerySyntax: selectEnableQuerySyntax(state)}), - ...(context?.contextValues && { - context: context.contextValues, - }), - pipelineRuleParameters: { - mlGenerativeQuestionAnswering: { - responseFormat: state.generatedAnswer.responseFormat, - citationsFieldToInclude, - }, - }, - ...(searchHub?.length && {searchHub}), - ...(pipeline?.length && {pipeline}), - ...analyticsParams, - }; -}; - const getGeneratedFacetParams = ( state: StreamAnswerAPIState ): AnyFacetRequest[] => @@ -276,7 +228,9 @@ const getActionsHistory = ( : [], }); -const buildAdvancedSearchQueryParams = (state: Partial) => { +export const buildAdvancedSearchQueryParams = ( + state: Partial +) => { const advancedSearchQueryParams = selectAdvancedSearchQueries(state); const mergedCq = buildConstantQuery(state); diff --git a/packages/headless/src/features/generated-answer/generated-answer-slice.ts b/packages/headless/src/features/generated-answer/generated-answer-slice.ts index 3675b7a807a..d18902f7832 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-slice.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-slice.ts @@ -1,7 +1,9 @@ import {createReducer} from '@reduxjs/toolkit'; import {RETRYABLE_STREAM_ERROR_CODE} from '../../api/generated-answer/generated-answer-client.js'; -import type {GeneratedAnswerStream} from '../../api/knowledge/generated-answer-stream.js'; -import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; +import type { + AnswerApiQueryParams, + HeadAnswerParams, +} from '../../features/generated-answer/generated-answer-request.js'; import { closeGeneratedAnswerFeedbackModal, collapseGeneratedAnswer, @@ -17,6 +19,7 @@ import { setAnswerGenerationMode, setAnswerId, setCannotAnswer, + setHeadAnswerApiQueryParams, setId, setIsAnswerGenerated, setIsEnabled, @@ -29,44 +32,9 @@ import { updateMessage, updateResponseFormat, } from './generated-answer-actions.js'; -import { - addFollowUpAnswer, - hydrateAnswerFromCache, - setActiveFollowUpAnswerContentFormat, - setActiveFollowUpAnswerId, - setActiveFollowUpCannotAnswer, - setActiveFollowUpIsAnswerGenerated, - setActiveFollowUpIsLoading, - setActiveFollowUpIsStreaming, - updateActiveFollowUpAnswerCitations, - updateActiveFollowUpAnswerMessage, - updateActiveFollowUpError, -} from './generated-answer-conversation-actions.js'; -import { - type FollowUpAnswer, - type GeneratedAnswerState, - getGeneratedAnswerInitialState, -} from './generated-answer-state.js'; +import {getGeneratedAnswerInitialState} from './generated-answer-state.js'; import {filterOutDuplicatedCitations} from './utils/generated-answer-citation-utils.js'; -const createInitialFollowUpAnswer = (question: string): FollowUpAnswer => ({ - question: question, - isLoading: false, - isStreaming: false, - citations: [], - liked: false, - disliked: false, - feedbackSubmitted: false, - isAnswerGenerated: false, - cannotAnswer: false, -}); - -function getActiveFollowUp( - state: GeneratedAnswerState -): FollowUpAnswer | undefined { - return state.followUpAnswers[state.followUpAnswers.length - 1]; -} - export const generatedAnswerReducer = createReducer( getGeneratedAnswerInitialState(), (builder) => @@ -173,114 +141,13 @@ export const generatedAnswerReducer = createReducer( .addCase(setAnswerApiQueryParams, (state, {payload}) => { state.answerApiQueryParams = payload as AnswerApiQueryParams; }) + .addCase(setHeadAnswerApiQueryParams, (state, {payload}) => { + state.headAnswerApiQueryParams = payload as HeadAnswerParams; + }) .addCase(setAnswerId, (state, {payload}) => { state.answerId = payload; }) .addCase(setAnswerGenerationMode, (state, {payload}) => { state.answerGenerationMode = payload; }) - .addCase(hydrateAnswerFromCache, (state, {payload}) => { - const {answerId, answer, citations, contentFormat, error, generated} = - payload as GeneratedAnswerStream; - state.answerId = answerId; - state.answer = answer || ''; - state.citations = citations || []; - state.answerContentFormat = contentFormat; - state.isLoading = false; - state.isStreaming = false; - state.isAnswerGenerated = generated || false; - state.error = { - ...error, - isRetryable: error?.code === RETRYABLE_STREAM_ERROR_CODE, - }; - }) - .addCase(addFollowUpAnswer, (state, {payload}) => { - state.followUpAnswers.push(createInitialFollowUpAnswer(payload)); - }) - .addCase(updateActiveFollowUpAnswerMessage, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - - followUp.isLoading = false; - followUp.isStreaming = true; - if (!state.answer) { - state.answer = ''; - } - - followUp.answer += payload.textDelta; - delete followUp.error; - }) - .addCase(updateActiveFollowUpAnswerCitations, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - - followUp.isLoading = false; - followUp.isStreaming = true; - followUp.citations = filterOutDuplicatedCitations([ - ...followUp.citations, - ...payload.citations, - ]); - delete followUp.error; - }) - .addCase(updateActiveFollowUpError, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - - followUp.isLoading = false; - followUp.isStreaming = false; - followUp.error = { - ...payload, - isRetryable: payload.code === RETRYABLE_STREAM_ERROR_CODE, - }; - followUp.citations = []; - delete followUp.answer; - }) - .addCase(setActiveFollowUpIsLoading, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - followUp.isLoading = payload; - }) - .addCase(setActiveFollowUpIsStreaming, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - followUp.isStreaming = payload; - }) - .addCase(setActiveFollowUpAnswerContentFormat, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - followUp.answerContentFormat = payload; - }) - .addCase(setActiveFollowUpAnswerId, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - followUp.answerId = payload; - }) - .addCase(setActiveFollowUpIsAnswerGenerated, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - followUp.isAnswerGenerated = payload; - }) - .addCase(setActiveFollowUpCannotAnswer, (state, {payload}) => { - const followUp = getActiveFollowUp(state); - if (!followUp) { - return; - } - followUp.cannotAnswer = payload; - }) ); diff --git a/packages/headless/src/features/generated-answer/generated-answer-state.ts b/packages/headless/src/features/generated-answer/generated-answer-state.ts index cdfa852e7fd..d6be09864de 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-state.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-state.ts @@ -1,11 +1,14 @@ import type {GeneratedAnswerCitation} from '../../api/generated-answer/generated-answer-event-payload.js'; -import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; +import type { + AnswerApiQueryParams, + HeadAnswerParams, +} from '../../features/generated-answer/generated-answer-request.js'; import type { GeneratedContentFormat, GeneratedResponseFormat, } from './generated-response-format.js'; -interface GeneratedAnswerBase { +export interface GeneratedAnswerBase { /** * Determines if the generated answer is loading. */ @@ -60,13 +63,6 @@ interface GeneratedAnswerBase { answerId?: string; } -export interface FollowUpAnswer extends GeneratedAnswerBase { - /** - * The question that prompted the generated answer. - */ - question: string; -} - /** * A scoped and simplified part of the headless state that is relevant to the `GeneratedAnswer` component. * @@ -107,33 +103,12 @@ export interface GeneratedAnswerState extends GeneratedAnswerBase { * The query parameters used for the answer API request cache key */ answerApiQueryParams?: AnswerApiQueryParams; - /** The current mode of answer generation. */ - answerGenerationMode: 'automatic' | 'manual'; - - /** NEW: Follow-up capability */ - - /** - * Whether follow-up answers are enabled for this generated answer - * via agent configuration. - */ - followUpAnswerEnabled: boolean; - - /** - * Identifier used to scope follow-up generated answers to the - * current generated answer. - */ - conversationId?: string; - /** - * Follow-up generated answers produced from follow-up questions + * The query parameters used for the head answer API request */ - followUpAnswers: FollowUpAnswer[]; - - /** - * Indicates whether more follow-up questions can currently be asked, - * based on backend capabilities or limits. - */ - canAskFollowUp: boolean; + headAnswerApiQueryParams?: HeadAnswerParams; + /** The current mode of answer generation. */ + answerGenerationMode: 'automatic' | 'manual'; } export function getGeneratedAnswerInitialState(): GeneratedAnswerState { @@ -158,8 +133,5 @@ export function getGeneratedAnswerInitialState(): GeneratedAnswerState { answerApiQueryParams: undefined, answerId: undefined, answerGenerationMode: 'automatic', - followUpAnswerEnabled: false, - followUpAnswers: [], - canAskFollowUp: false, }; } diff --git a/packages/headless/src/state/state-sections.ts b/packages/headless/src/state/state-sections.ts index 1e07ea8107b..83d9563ce79 100644 --- a/packages/headless/src/state/state-sections.ts +++ b/packages/headless/src/state/state-sections.ts @@ -48,6 +48,7 @@ import type {DateFacetSetState} from '../features/facets/range-facets/date-facet import type {NumericFacetSetState} from '../features/facets/range-facets/numeric-facet-set/numeric-facet-set-state.js'; import type {FieldsState} from '../features/fields/fields-state.js'; import type {FoldingState} from '../features/folding/folding-state.js'; +import type {FollowUpAnswersState} from '../features/follow-up-answers/follow-up-answers-state.js'; import type {AnswerApiQueryParams} from '../features/generated-answer/generated-answer-request.js'; import type {GeneratedAnswerState} from '../features/generated-answer/generated-answer-state.js'; import type {HistoryState} from '../features/history/history-state.js'; @@ -509,6 +510,13 @@ export interface GeneratedAnswerSection { generatedAnswer: GeneratedAnswerState; } +export interface FollowUpAnswersSection { + /** + * The properties related to generative question answering. + */ + followUpAnswers: FollowUpAnswersState; +} + export interface InsightUserActionsSection { /** * The insight user actions state. From 19a71571b9785a0f86be12a7f656315ccea0b7fa Mon Sep 17 00:00:00 2001 From: mmitiche Date: Tue, 13 Jan 2026 08:32:14 -0500 Subject: [PATCH 13/17] moved file --- .../controllers/generated-answer/headless-generated-answer.ts | 2 +- .../headless-generated-answer-with-follow-ups.ts | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/headless/src/controllers/knowledge/{generated-answer-conversation => generated-answer}/headless-generated-answer-with-follow-ups.ts (100%) diff --git a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts index e9ed87de1fb..39991391e57 100644 --- a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts +++ b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts @@ -11,7 +11,7 @@ import type { } from '../core/generated-answer/headless-core-generated-answer.js'; import {buildSearchAPIGeneratedAnswer} from '../core/generated-answer/headless-searchapi-generated-answer.js'; import {buildAnswerApiGeneratedAnswer} from '../knowledge/generated-answer/headless-answerapi-generated-answer.js'; -import {buildGeneratedAnswerWithFollowUps} from '../knowledge/generated-answer-conversation/headless-generated-answer-with-follow-ups.js'; +import {buildGeneratedAnswerWithFollowUps} from '../knowledge/generated-answer/headless-generated-answer-with-follow-ups.js'; export type { GeneratedAnswerCitation, diff --git a/packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-with-follow-ups.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts similarity index 100% rename from packages/headless/src/controllers/knowledge/generated-answer-conversation/headless-generated-answer-with-follow-ups.ts rename to packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts From 62c02306c6b4923d4d8cbe4d05ce44a3c2afe517 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Tue, 13 Jan 2026 15:31:50 -0500 Subject: [PATCH 14/17] serializeQueryArgs added --- .../endpoints/follow-up-answer-endpoint.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts index 4130f4b216c..a3eafeef7c0 100644 --- a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts @@ -25,6 +25,15 @@ export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ }, }; }, + serializeQueryArgs: ({endpointName, queryArgs}) => { + // RTK Query serialize our endpoints and they're serialized state arguments as the key in the store. + // Keys must match, because if anything in the query changes, it's not the same query anymore. + // Analytics data is excluded entirely as it contains volatile fields that change during streaming. + const {q} = queryArgs; + + // Standard RTK key, with analytics excluded + return `${endpointName}_${JSON.stringify(q)}`; + }, async onQueryStarted(args, {getState, updateCachedData, dispatch}) { /** * createApi has to be called prior to creating the redux store and is used as part of the store setup sequence. From f45592ce2ecc4879bdcaa6b82939df27a69968bd Mon Sep 17 00:00:00 2001 From: mmitiche Date: Thu, 15 Jan 2026 12:35:12 -0500 Subject: [PATCH 15/17] introduced HeadAnswerEndpointArgs type --- .../answer-generation-api-state.ts | 4 +-- .../endpoints/head-answer-endpoint.ts | 23 +++++++++++--- .../src/api/search/search-api-params.ts | 2 +- .../generated-answer-actions.ts | 13 ++++---- .../generated-answer-request.ts | 30 +++++++------------ .../generated-answer-slice.ts | 8 ++--- .../generated-answer-state.ts | 8 ++--- packages/headless/src/utils/facet-utils.ts | 4 +-- 8 files changed, 47 insertions(+), 45 deletions(-) diff --git a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts index 8dc7891a530..872277e0dac 100644 --- a/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts +++ b/packages/headless/src/api/knowledge/answer-generation/answer-generation-api-state.ts @@ -7,7 +7,6 @@ import type { RetryOptions, } from '@reduxjs/toolkit/query'; import type {FollowUpAnswerParams} from '../../../features/follow-up-answers/follow-up-answer-request.js'; -import type {HeadAnswerParams} from '../../../features/generated-answer/generated-answer-request.js'; import type {SearchAppState} from '../../../state/search-app-state.js'; import type { ConfigurationSection, @@ -15,6 +14,7 @@ import type { GeneratedAnswerSection, TabSection, } from '../../../state/state-sections.js'; +import type {HeadAnswerEndpointArgs} from './endpoints/head-answer-endpoint.js'; import type {GeneratedAnswerServerState} from './shared-types.js'; export interface AnswerGenerationApiSection { @@ -24,7 +24,7 @@ export interface AnswerGenerationApiSection { answerGenerationApi: CombinedState< { generateHeadAnswer: QueryDefinition< - HeadAnswerParams, + HeadAnswerEndpointArgs, BaseQueryFn< string | FetchArgs, unknown, diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts index 0181375dbc3..918c43a10a1 100644 --- a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts @@ -1,17 +1,30 @@ +import type {AnyFacetRequest} from '../../../../features/facets/generic/interfaces/generic-facet-request.js'; import {selectHeadAnswerApiQueryParams} from '../../../../features/generated-answer/answer-api-selectors.js'; -import type {HeadAnswerParams} from '../../../../features/generated-answer/generated-answer-request.js'; +import type { + AnalyticsParam, + PipelineRuleParameters, +} from '../../../search/search-api-params.js'; import {answerGenerationApi} from '../answer-generation-api.js'; import type {AnswerGenerationApiState} from '../answer-generation-api-state.js'; import type {GeneratedAnswerServerState} from '../shared-types.js'; import {streamAnswerWithStrategy} from '../streaming/answer-streaming-runner.js'; import {headAnswerStrategy} from '../streaming/strategies/head-answer-strategy.js'; +export type HeadAnswerEndpointArgs = { + q: string; + facets?: AnyFacetRequest[]; + searchHub?: string; + pipeline?: string; + pipelineRuleParameters: PipelineRuleParameters; + locale: string; +} & AnalyticsParam; + export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ overrideExisting: true, endpoints: (builder) => ({ generateHeadAnswer: builder.query< GeneratedAnswerServerState, - HeadAnswerParams + HeadAnswerEndpointArgs >({ queryFn: () => { return { @@ -43,7 +56,7 @@ export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ */ const state = getState() as AnswerGenerationApiState; await streamAnswerWithStrategy< - HeadAnswerParams, + HeadAnswerEndpointArgs, AnswerGenerationApiState, GeneratedAnswerServerState >(args, {state, updateCachedData, dispatch}, headAnswerStrategy); @@ -52,7 +65,9 @@ export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ }), }); -export const initiateHeadAnswerGeneration = (params: HeadAnswerParams) => { +export const initiateHeadAnswerGeneration = ( + params: HeadAnswerEndpointArgs +) => { return headAnswerEndpoint.endpoints.generateHeadAnswer.initiate(params); }; diff --git a/packages/headless/src/api/search/search-api-params.ts b/packages/headless/src/api/search/search-api-params.ts index 6dbcffcb309..acc4cc2db79 100644 --- a/packages/headless/src/api/search/search-api-params.ts +++ b/packages/headless/src/api/search/search-api-params.ts @@ -131,7 +131,7 @@ export interface PipelineRuleParams { pipelineRuleParameters?: PipelineRuleParameters; } -type PipelineRuleParameters = { +export type PipelineRuleParameters = { mlGenerativeQuestionAnswering?: GenQAParameters; }; diff --git a/packages/headless/src/features/generated-answer/generated-answer-actions.ts b/packages/headless/src/features/generated-answer/generated-answer-actions.ts index 9979eb73cee..fd2255b7849 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-actions.ts @@ -17,15 +17,15 @@ import type { } from '../../api/generated-answer/generated-answer-event-payload.js'; import type {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request.js'; import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; -import {initiateHeadAnswerGeneration} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; +import { + type HeadAnswerEndpointArgs, + initiateHeadAnswerGeneration, +} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import {fetchAnswer} from '../../api/knowledge/stream-answer-api.js'; import type {StreamAnswerAPIState} from '../../api/knowledge/stream-answer-api-state.js'; import type {AsyncThunkOptions} from '../../app/async-thunk-options.js'; import type {SearchThunkExtraArguments} from '../../app/search-thunk-extra-arguments.js'; -import type { - AnswerApiQueryParams, - HeadAnswerParams, -} from '../../features/generated-answer/generated-answer-request.js'; +import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; import type { ConfigurationSection, DebugSection, @@ -224,7 +224,8 @@ export const setAnswerApiQueryParams = createAction( export const setHeadAnswerApiQueryParams = createAction( 'generatedAnswer/setHeadAnswerApiQueryParams', - (payload: HeadAnswerParams) => validatePayload(payload, new RecordValue({})) + (payload: HeadAnswerEndpointArgs) => + validatePayload(payload, new RecordValue({})) ); interface StreamAnswerArgs { diff --git a/packages/headless/src/features/generated-answer/generated-answer-request.ts b/packages/headless/src/features/generated-answer/generated-answer-request.ts index 67cce57412a..791f2ace9b8 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-request.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-request.ts @@ -2,6 +2,7 @@ import type {HistoryElement} from '../../api/analytics/coveo.analytics/history-s import HistoryStore from '../../api/analytics/coveo.analytics/history-store.js'; import type {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request.js'; import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; +import type {HeadAnswerEndpointArgs} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; import type {StreamAnswerAPIState} from '../../api/knowledge/stream-answer-api-state.js'; import {getOrganizationEndpoint} from '../../api/platform-client.js'; import type {BaseParam} from '../../api/platform-service-params.js'; @@ -163,40 +164,30 @@ export const constructAnswerAPIQueryParams = ( }; }; -export type HeadAnswerParams = ReturnType< - typeof constructGenerateHeadAnswerParams ->; +// export type HeadAnswerParams = ReturnType< +// typeof constructGenerateHeadAnswerParams +// >; export const constructGenerateHeadAnswerParams = ( state: AnswerGenerationApiState, navigatorContext: NavigatorContext -) => { +): HeadAnswerEndpointArgs => { const q = selectQuery(state)?.q; - - const {aq, cq, dq, lq} = buildAdvancedSearchQueryParams(state); - - const context = selectContext(state); - + const facetParams = getGeneratedFacetParams(state); const analyticsParams = fromAnalyticsStateToAnalyticsParams( state.configuration.analytics, navigatorContext, {actionCause: selectSearchActionCause(state)} ); + const locale = selectLocale(state); const searchHub = selectSearchHub(state); const pipeline = selectPipeline(state); const citationsFieldToInclude = selectFieldsToIncludeInCitation(state) ?? []; return { - q, - ...(aq && {aq}), - ...(cq && {cq}), - ...(dq && {dq}), - ...(lq && {lq}), - ...(state.query && {enableQuerySyntax: selectEnableQuerySyntax(state)}), - ...(context?.contextValues && { - context: context.contextValues, - }), + q: q || '', + ...(facetParams.length && {facets: facetParams}), pipelineRuleParameters: { mlGenerativeQuestionAnswering: { responseFormat: state.generatedAnswer.responseFormat, @@ -206,11 +197,12 @@ export const constructGenerateHeadAnswerParams = ( ...(searchHub?.length && {searchHub}), ...(pipeline?.length && {pipeline}), ...analyticsParams, + locale, }; }; const getGeneratedFacetParams = ( - state: StreamAnswerAPIState + state: Partial ): AnyFacetRequest[] => getFacets(state) ?.map((facetRequest) => diff --git a/packages/headless/src/features/generated-answer/generated-answer-slice.ts b/packages/headless/src/features/generated-answer/generated-answer-slice.ts index d18902f7832..4e7466c8139 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-slice.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-slice.ts @@ -1,9 +1,7 @@ import {createReducer} from '@reduxjs/toolkit'; import {RETRYABLE_STREAM_ERROR_CODE} from '../../api/generated-answer/generated-answer-client.js'; -import type { - AnswerApiQueryParams, - HeadAnswerParams, -} from '../../features/generated-answer/generated-answer-request.js'; +import type {HeadAnswerEndpointArgs} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; +import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; import { closeGeneratedAnswerFeedbackModal, collapseGeneratedAnswer, @@ -142,7 +140,7 @@ export const generatedAnswerReducer = createReducer( state.answerApiQueryParams = payload as AnswerApiQueryParams; }) .addCase(setHeadAnswerApiQueryParams, (state, {payload}) => { - state.headAnswerApiQueryParams = payload as HeadAnswerParams; + state.headAnswerApiQueryParams = payload as HeadAnswerEndpointArgs; }) .addCase(setAnswerId, (state, {payload}) => { state.answerId = payload; diff --git a/packages/headless/src/features/generated-answer/generated-answer-state.ts b/packages/headless/src/features/generated-answer/generated-answer-state.ts index d6be09864de..5fa7d5b87ce 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-state.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-state.ts @@ -1,8 +1,6 @@ import type {GeneratedAnswerCitation} from '../../api/generated-answer/generated-answer-event-payload.js'; -import type { - AnswerApiQueryParams, - HeadAnswerParams, -} from '../../features/generated-answer/generated-answer-request.js'; +import type {HeadAnswerEndpointArgs} from '../../api/knowledge/answer-generation/endpoints/head-answer-endpoint.js'; +import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; import type { GeneratedContentFormat, GeneratedResponseFormat, @@ -106,7 +104,7 @@ export interface GeneratedAnswerState extends GeneratedAnswerBase { /** * The query parameters used for the head answer API request */ - headAnswerApiQueryParams?: HeadAnswerParams; + headAnswerApiQueryParams?: HeadAnswerEndpointArgs; /** The current mode of answer generation. */ answerGenerationMode: 'automatic' | 'manual'; } diff --git a/packages/headless/src/utils/facet-utils.ts b/packages/headless/src/utils/facet-utils.ts index b26c11b4474..ff2b84bcf0b 100644 --- a/packages/headless/src/utils/facet-utils.ts +++ b/packages/headless/src/utils/facet-utils.ts @@ -3,10 +3,8 @@ import {getFacetRequests} from '../features/facets/generic/interfaces/generic-fa import type {AnyFacetValue} from '../features/facets/generic/interfaces/generic-facet-response.js'; import type {RangeFacetSetState} from '../features/facets/range-facets/generic/interfaces/range-facet.js'; import type {SearchAppState} from '../state/search-app-state.js'; -import type {ConfigurationSection} from '../state/state-sections.js'; -type StateNeededBySearchRequest = ConfigurationSection & - Partial; +type StateNeededBySearchRequest = Partial; type SortCriteria = { type: string; From 21b1f4457240d32e6586ff1e7f1bca7303530c73 Mon Sep 17 00:00:00 2001 From: mmitiche Date: Thu, 15 Jan 2026 20:01:49 -0500 Subject: [PATCH 16/17] improve getState() param and fixed analytics issue --- .../endpoints/follow-up-answer-endpoint.ts | 17 +++++++------ .../endpoints/head-answer-endpoint.ts | 25 +++++++------------ .../streaming/answer-streaming-runner.ts | 8 +++--- .../generate-answer-listener-middleware.ts | 6 ++++- .../generated-answer-selectors.ts | 5 +++- 5 files changed, 31 insertions(+), 30 deletions(-) diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts index a3eafeef7c0..de50ccddbf3 100644 --- a/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/follow-up-answer-endpoint.ts @@ -35,18 +35,19 @@ export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({ return `${endpointName}_${JSON.stringify(q)}`; }, async onQueryStarted(args, {getState, updateCachedData, dispatch}) { - /** - * createApi has to be called prior to creating the redux store and is used as part of the store setup sequence. - * It cannot use the inferred state used by Redux, thus the casting. - * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-dispatch-and-getstate - */ - const state = getState() as AnswerGenerationApiState; - await streamAnswerWithStrategy< FollowUpAnswerParams, AnswerGenerationApiState, GeneratedAnswerServerState - >(args, {state, updateCachedData, dispatch}, followUpAnswerStrategy); + >( + args, + { + getState: getState as () => AnswerGenerationApiState, + updateCachedData, + dispatch, + }, + followUpAnswerStrategy + ); }, }), }), diff --git a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts index 918c43a10a1..c68934211bd 100644 --- a/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts +++ b/packages/headless/src/api/knowledge/answer-generation/endpoints/head-answer-endpoint.ts @@ -39,27 +39,20 @@ export const headAnswerEndpoint = answerGenerationApi.injectEndpoints({ }, }; }, - serializeQueryArgs: ({endpointName, queryArgs}) => { - // RTK Query serialize our endpoints and they're serialized state arguments as the key in the store. - // Keys must match, because if anything in the query changes, it's not the same query anymore. - // Analytics data is excluded entirely as it contains volatile fields that change during streaming. - const {analytics: _analytics, ...queryArgsWithoutAnalytics} = queryArgs; - - // Standard RTK key, with analytics excluded - return `${endpointName}(${JSON.stringify(queryArgsWithoutAnalytics)})`; - }, async onQueryStarted(args, {getState, updateCachedData, dispatch}) { - /** - * createApi has to be called prior to creating the redux store and is used as part of the store setup sequence. - * It cannot use the inferred state used by Redux, thus the casting. - * https://redux-toolkit.js.org/rtk-query/usage-with-typescript#typing-dispatch-and-getstate - */ - const state = getState() as AnswerGenerationApiState; await streamAnswerWithStrategy< HeadAnswerEndpointArgs, AnswerGenerationApiState, GeneratedAnswerServerState - >(args, {state, updateCachedData, dispatch}, headAnswerStrategy); + >( + args, + { + getState: getState as () => AnswerGenerationApiState, + updateCachedData, + dispatch, + }, + headAnswerStrategy + ); }, }), }), diff --git a/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts b/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts index e1f08fec976..129e48c51e1 100644 --- a/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts +++ b/packages/headless/src/api/knowledge/answer-generation/streaming/answer-streaming-runner.ts @@ -15,17 +15,17 @@ export const streamAnswerWithStrategy = < >( args: TArgs, api: { - state: TState; + getState: () => TState; dispatch: ThunkDispatch; updateCachedData: (updater: (draft: TDraft) => void) => void; }, strategy: StreamingStrategy ) => { - const {state, dispatch, updateCachedData} = api; - const endpointUrl = strategy.buildEndpointUrl(state); + const {dispatch, updateCachedData, getState} = api; + const endpointUrl = strategy.buildEndpointUrl(getState()); const { configuration: {accessToken}, - } = state; + } = getState(); return fetchEventSource(endpointUrl, { method: 'POST', diff --git a/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts b/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts index fe0e3590f48..495ba5dba83 100644 --- a/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts +++ b/packages/headless/src/app/middleware/generate-answer-listener-middleware.ts @@ -4,7 +4,10 @@ import { type UnknownAction, } from '@reduxjs/toolkit'; import type {AnswerGenerationApiState} from '../../api/knowledge/answer-generation/answer-generation-api-state.js'; -import {generateHeadAnswer} from '../../features/generated-answer/generated-answer-actions.js'; +import { + generateHeadAnswer, + resetAnswer, +} from '../../features/generated-answer/generated-answer-actions.js'; import {isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI} from '../../features/generated-answer/generated-answer-selectors.js'; import {selectQuery} from '../../features/query/query-selectors.js'; import {executeSearch} from '../../features/search/search-actions.js'; @@ -24,6 +27,7 @@ generateAnswerListener.startListening({ actionCreator: executeSearch.pending, effect: async (_action, listenerApi) => { + listenerApi.dispatch(resetAnswer()); const state = listenerApi.getState(); const q = selectQuery(state)?.q; diff --git a/packages/headless/src/features/generated-answer/generated-answer-selectors.ts b/packages/headless/src/features/generated-answer/generated-answer-selectors.ts index 3aa6d8f4fd5..c33da8502e5 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-selectors.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-selectors.ts @@ -12,7 +12,10 @@ export const generativeQuestionAnsweringIdSelector = ( state: Partial ): string | undefined => { // If using the AnswerApi, we return the answerId first. - if (isGeneratedAnswerFeatureEnabledWithAnswerAPI(state)) { + if ( + isGeneratedAnswerFeatureEnabledWithAnswerAPI(state) || + isGeneratedAnswerFeatureEnabledWithAnswerGenerationAPI(state) + ) { return state.generatedAnswer?.answerId; } From cb74aa9028ec195c1a93646f8bf2a1d966e8dd7e Mon Sep 17 00:00:00 2001 From: mmitiche Date: Wed, 21 Jan 2026 11:49:44 -0500 Subject: [PATCH 17/17] new controller integrated with atomic --- packages/atomic/dev/examples/genqa.html | 2 +- .../atomic-generated-answer.ts | 26 ++++++++++++++++++- .../headless-generated-answer.ts | 7 +++-- ...adless-generated-answer-with-follow-ups.ts | 3 ++- packages/headless/src/index.ts | 1 + 5 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/atomic/dev/examples/genqa.html b/packages/atomic/dev/examples/genqa.html index 561a74803c3..f3a9665e8ca 100644 --- a/packages/atomic/dev/examples/genqa.html +++ b/packages/atomic/dev/examples/genqa.html @@ -115,7 +115,7 @@ - + diff --git a/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.ts b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.ts index a1e54ac9a6f..638b2098ad3 100644 --- a/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.ts +++ b/packages/atomic/src/components/search/atomic-generated-answer/atomic-generated-answer.ts @@ -6,6 +6,7 @@ import { buildTabManager, type GeneratedAnswer, type GeneratedAnswerState, + type GeneratedAnswerWithFollowUps, type SearchStatus, type SearchStatusState, type TabManager, @@ -134,6 +135,12 @@ export class AtomicGeneratedAnswer @property({type: String, attribute: 'answer-configuration-id'}) answerConfigurationId?: string; + /** + * The unique identifier of the agent configuration to use to generate the answer. + */ + @property({type: String, attribute: 'agent-id'}) + agentId?: string; + /** * A list of fields to include with the citations used to generate the answer. * @@ -202,7 +209,7 @@ export class AtomicGeneratedAnswer }) @state() private generatedAnswerState!: GeneratedAnswerState; - public generatedAnswer!: GeneratedAnswer; + public generatedAnswer!: GeneratedAnswer | GeneratedAnswerWithFollowUps; @bindStateToController('searchStatus') @state() @@ -259,6 +266,9 @@ export class AtomicGeneratedAnswer ...(this.answerConfigurationId && { answerConfigurationId: this.answerConfigurationId, }), + ...(this.agentId && { + agentId: this.agentId, + }), fieldsToIncludeInCitations: this.getCitationFields(), }); this.searchStatus = buildSearchStatus(this.bindings.engine); @@ -321,6 +331,12 @@ export class AtomicGeneratedAnswer } } + private canAskFollowUp(): this is { + generatedAnswer: GeneratedAnswerWithFollowUps; + } { + return 'askFollowUp' in this.generatedAnswer; + } + @bindingGuard() @errorGuard() render() { @@ -365,6 +381,13 @@ export class AtomicGeneratedAnswer aria-label=${this.bindings.i18n.t('generated-answer-title')} >
${this.renderContent()}
+ `; @@ -372,6 +395,7 @@ export class AtomicGeneratedAnswer // Used by bindStateToController decorator via onUpdateCallbackMethod option public onGeneratedAnswerStateUpdate = () => { + console.log(this.generatedAnswerState); if ( this.generatedAnswerState.isVisible !== this.controller.data.isVisible ) { diff --git a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts index 39991391e57..57205802df8 100644 --- a/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts +++ b/packages/headless/src/controllers/generated-answer/headless-generated-answer.ts @@ -11,7 +11,10 @@ import type { } from '../core/generated-answer/headless-core-generated-answer.js'; import {buildSearchAPIGeneratedAnswer} from '../core/generated-answer/headless-searchapi-generated-answer.js'; import {buildAnswerApiGeneratedAnswer} from '../knowledge/generated-answer/headless-answerapi-generated-answer.js'; -import {buildGeneratedAnswerWithFollowUps} from '../knowledge/generated-answer/headless-generated-answer-with-follow-ups.js'; +import { + buildGeneratedAnswerWithFollowUps, + type GeneratedAnswerWithFollowUps, +} from '../knowledge/generated-answer/headless-generated-answer-with-follow-ups.js'; export type { GeneratedAnswerCitation, @@ -35,7 +38,7 @@ export type { export function buildGeneratedAnswer( engine: SearchEngine, props: GeneratedAnswerProps = {} -): GeneratedAnswer { +): GeneratedAnswer | GeneratedAnswerWithFollowUps { warnIfUsingNextAnalyticsModeForServiceFeature( engine.state.configuration.analytics.analyticsMode ); diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts index e53812cd727..b3a714d54e3 100644 --- a/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts +++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.ts @@ -28,7 +28,7 @@ interface GeneratedAnswerWithFollowUpsState extends GeneratedAnswerState { followUpAnswers: FollowUpAnswersState; } -interface GeneratedAnswerWithFollowUps extends GeneratedAnswer { +export interface GeneratedAnswerWithFollowUps extends GeneratedAnswer { /** * The state of the GeneratedAnswer controller. */ @@ -110,6 +110,7 @@ export function buildGeneratedAnswerWithFollowUps( engine.dispatch(generateHeadAnswer()); }, askFollowUp(question: string) { + console.log('Asking follow-up question:', question); engine.dispatch(generateFollowUpAnswer(question)); }, }; diff --git a/packages/headless/src/index.ts b/packages/headless/src/index.ts index b8f15d65256..cd32e2654fc 100644 --- a/packages/headless/src/index.ts +++ b/packages/headless/src/index.ts @@ -251,6 +251,7 @@ export type { InstantResultsState, } from './controllers/instant-results/instant-results.js'; export {buildInstantResults} from './controllers/instant-results/instant-results.js'; +export type {GeneratedAnswerWithFollowUps} from './controllers/knowledge/generated-answer/headless-generated-answer-with-follow-ups.js'; export type { Pager, PagerInitialState,