Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
6c091a9
state schema adapted to generated answer conversation
mmitiche Dec 25, 2025
a701f9d
created structure of rtk query client for answer generation api inclu…
mmitiche Dec 25, 2025
0706913
created actions to interact with the generated answer conversation fe…
mmitiche Dec 25, 2025
00023f7
created new generated answer conversation controller
mmitiche Dec 25, 2025
f8f6ad1
created a listener middleware that listens to search request in order…
mmitiche Dec 25, 2025
ad5dca1
exported dynamicBaseQuery to reuse it in the RTK Query API client cre…
mmitiche Dec 25, 2025
15590d7
conversationId added to state
mmitiche Dec 29, 2025
27982f8
follow up endpoint added to answer generation api client
mmitiche Dec 29, 2025
73db1a9
implemented new actions to mutate follow up answers state and new cas…
mmitiche Dec 29, 2025
c37a88b
augmenting the new generated answer conversation controller with new …
mmitiche Dec 29, 2025
d62af0a
Merge branch 'main' of https://github.com/coveo/ui-kit into milestone-0
mmitiche Jan 2, 2026
017f47a
simplified state and stream runners
mmitiche Jan 12, 2026
bbb0b71
isolated follow up in their own redux slice and exposed it with the n…
mmitiche Jan 12, 2026
8340dd9
Merge branch 'main' of https://github.com/coveo/ui-kit into milestone-0
mmitiche Jan 12, 2026
19a7157
moved file
mmitiche Jan 13, 2026
62c0230
serializeQueryArgs added
mmitiche Jan 13, 2026
f45592c
introduced HeadAnswerEndpointArgs type
mmitiche Jan 15, 2026
21b1f44
improve getState() param and fixed analytics issue
mmitiche Jan 16, 2026
4b31859
Merge branch 'main' of https://github.com/coveo/ui-kit into milestone-0
mmitiche Jan 21, 2026
cb74aa9
new controller integrated with atomic
mmitiche Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/atomic/dev/examples/genqa.html
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
</atomic-facet-manager>
</atomic-layout-section>
<atomic-layout-section section="main">
<atomic-generated-answer></atomic-generated-answer>
<atomic-generated-answer agent-id="fc581be0-6e61-4039-ab26-a3f2f52f308f"></atomic-generated-answer>
<atomic-layout-section section="status">
<atomic-breadbox></atomic-breadbox>
<atomic-query-summary></atomic-query-summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildTabManager,
type GeneratedAnswer,
type GeneratedAnswerState,
type GeneratedAnswerWithFollowUps,
type SearchStatus,
type SearchStatusState,
type TabManager,
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -202,7 +209,7 @@ export class AtomicGeneratedAnswer
})
@state()
private generatedAnswerState!: GeneratedAnswerState;
public generatedAnswer!: GeneratedAnswer;
public generatedAnswer!: GeneratedAnswer | GeneratedAnswerWithFollowUps;

@bindStateToController('searchStatus')
@state()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -321,6 +331,12 @@ export class AtomicGeneratedAnswer
}
}

private canAskFollowUp(): this is {
generatedAnswer: GeneratedAnswerWithFollowUps;
} {
return 'askFollowUp' in this.generatedAnswer;
}

@bindingGuard()
@errorGuard()
render() {
Expand Down Expand Up @@ -365,13 +381,21 @@ export class AtomicGeneratedAnswer
aria-label=${this.bindings.i18n.t('generated-answer-title')}
>
<article>${this.renderContent()}</article>
<button class="btn-outline-primary p-2" aria-live="polite" @click=${() => {
if (this.canAskFollowUp()) {
this.generatedAnswer.askFollowUp('What is Coveo?');
}
}}>
Ask Follow Up
</button>
</aside>
</div>
`;
}

// Used by bindStateToController decorator via onUpdateCallbackMethod option
public onGeneratedAnswerStateUpdate = () => {
console.log(this.generatedAnswerState);
if (
this.generatedAnswerState.isVisible !== this.controller.data.isVisible
) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type {
BaseQueryFn,
CombinedState,
FetchArgs,
FetchBaseQueryError,
QueryDefinition,
RetryOptions,
} from '@reduxjs/toolkit/query';
import type {FollowUpAnswerParams} from '../../../features/follow-up-answers/follow-up-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 {HeadAnswerEndpointArgs} from './endpoints/head-answer-endpoint.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
// 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<
HeadAnswerEndpointArgs,
BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError,
{} & RetryOptions,
{}
>,
never,
GeneratedAnswerServerState,
'answerGenerationApi'
>;
generateFollowUpAnswer: QueryDefinition<
FollowUpAnswerParams,
BaseQueryFn<
string | FetchArgs,
unknown,
FetchBaseQueryError,
{} & RetryOptions,
{}
>,
never,
GeneratedAnswerServerState,
'answerGenerationApi'
>;
},
never,
'answerGenerationApi'
>;
}

export type AnswerGenerationApiState = {
searchHub: string;
pipeline: string;
} & AnswerGenerationApiSection &
ConfigurationSection &
Partial<SearchAppState> &
GeneratedAnswerSection &
FollowUpAnswersSection &
Partial<TabSection>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {createApi, retry} from '@reduxjs/toolkit/query';
import {dynamicBaseQuery} from '../answer-slice.js';

export const answerGenerationApi = createApi({
reducerPath: 'answerGenerationApi',
refetchOnMountOrArgChange: true,
baseQuery: retry(dynamicBaseQuery, {maxRetries: 3}),
endpoints: () => ({}),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 {GeneratedAnswerServerState} from '../shared-types.js';
import {streamAnswerWithStrategy} from '../streaming/answer-streaming-runner.js';
import {followUpAnswerStrategy} from '../streaming/strategies/follow-up-answer-strategy.js';

export const followUpAnswerEndpoint = answerGenerationApi.injectEndpoints({
overrideExisting: true,
endpoints: (builder) => ({
generateFollowUpAnswer: builder.query<
GeneratedAnswerServerState,
FollowUpAnswerParams
>({
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 {q} = queryArgs;

// Standard RTK key, with analytics excluded
return `${endpointName}_${JSON.stringify(q)}`;
},
async onQueryStarted(args, {getState, updateCachedData, dispatch}) {
await streamAnswerWithStrategy<
FollowUpAnswerParams,
AnswerGenerationApiState,
GeneratedAnswerServerState
>(
args,
{
getState: getState as () => AnswerGenerationApiState,
updateCachedData,
dispatch,
},
followUpAnswerStrategy
);
},
}),
}),
});

export const initiateFollowUpAnswerGeneration = (
params: FollowUpAnswerParams
) => {
return followUpAnswerEndpoint.endpoints.generateFollowUpAnswer.initiate(
params
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type {AnyFacetRequest} from '../../../../features/facets/generic/interfaces/generic-facet-request.js';
import {selectHeadAnswerApiQueryParams} from '../../../../features/generated-answer/answer-api-selectors.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,
HeadAnswerEndpointArgs
>({
queryFn: () => {
return {
data: {
contentFormat: undefined,
answer: undefined,
citations: undefined,
error: undefined,
generated: false,
isStreaming: false,
isLoading: true,
},
};
},
async onQueryStarted(args, {getState, updateCachedData, dispatch}) {
await streamAnswerWithStrategy<
HeadAnswerEndpointArgs,
AnswerGenerationApiState,
GeneratedAnswerServerState
>(
args,
{
getState: getState as () => AnswerGenerationApiState,
updateCachedData,
dispatch,
},
headAnswerStrategy
);
},
}),
}),
});

export const initiateHeadAnswerGeneration = (
params: HeadAnswerEndpointArgs
) => {
return headAnswerEndpoint.endpoints.generateHeadAnswer.initiate(params);
};

export const selectHeadAnswer = (state: AnswerGenerationApiState) => {
const params = selectHeadAnswerApiQueryParams(state);
return headAnswerEndpoint.endpoints.generateHeadAnswer.select(params)(state);
};
Original file line number Diff line number Diff line change
@@ -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 GeneratedAnswerServerState {
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;
}
Original file line number Diff line number Diff line change
@@ -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: {
getState: () => TState;
dispatch: ThunkDispatch<TState, unknown, UnknownAction>;
updateCachedData: (updater: (draft: TDraft) => void) => void;
},
strategy: StreamingStrategy<TDraft, TState>
) => {
const {dispatch, updateCachedData, getState} = api;
const endpointUrl = strategy.buildEndpointUrl(getState());
const {
configuration: {accessToken},
} = getState();

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<Message> = JSON.parse(event.data);
strategy.events.handleMessage.error?.(
message,
updateCachedData,
dispatch
);

const messageType = message.payloadType;
strategy.events.handleMessage[messageType]?.(
message,
updateCachedData,
dispatch
);
},
});
};
Loading
Loading