diff --git a/packages/atomic-react/src/components/search/components.ts b/packages/atomic-react/src/components/search/components.ts index bad5cb1176b..409f046f5f0 100644 --- a/packages/atomic-react/src/components/search/components.ts +++ b/packages/atomic-react/src/components/search/components.ts @@ -1,4 +1,5 @@ import { + AtomicAiConversationToggle as LitAtomicAiConversationToggle, AtomicAriaLive as LitAtomicAriaLive, AtomicComponentError as LitAtomicComponentError, AtomicExternal as LitAtomicExternal, @@ -41,6 +42,12 @@ import { import {createComponent} from '@lit/react'; import React from 'react'; +export const AtomicAiConversationToggle = createComponent({ + tagName: 'atomic-ai-conversation-toggle', + react: React, + elementClass: LitAtomicAiConversationToggle, +}); + export const AtomicAriaLive = createComponent({ tagName: 'atomic-aria-live', react: React, diff --git a/packages/atomic/dev/examples/multiturn-conversation.html b/packages/atomic/dev/examples/multiturn-conversation.html new file mode 100644 index 00000000000..e78c28ef3d0 --- /dev/null +++ b/packages/atomic/dev/examples/multiturn-conversation.html @@ -0,0 +1,58 @@ + + + + + + Coveo Multi-turn conversation + + + + + + + + + + Multi-turn conversation example coming soon! + + diff --git a/packages/atomic/dev/header.js b/packages/atomic/dev/header.js index a7f0601d434..5dc69ee13f4 100644 --- a/packages/atomic/dev/header.js +++ b/packages/atomic/dev/header.js @@ -19,6 +19,10 @@ const links = [ {href: '/examples/genqa.html', label: 'Gen Q&A'}, {href: '/examples/tabs.html', label: 'Tabs'}, {href: '/examples/commerce-website/homepage.html', label: 'Commerce Website'}, + { + href: '/examples/multiturn-conversation.html', + label: 'Multi-turn conversation', + }, ]; const header = document.createElement('header'); diff --git a/packages/atomic/scripts/generate-component.mjs b/packages/atomic/scripts/generate-component.mjs index 7bf86d08e4a..04ee9e4100c 100644 --- a/packages/atomic/scripts/generate-component.mjs +++ b/packages/atomic/scripts/generate-component.mjs @@ -1,6 +1,7 @@ import {execSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {mkdir, readFile, writeFile} from 'node:fs/promises'; import path from 'node:path'; -import fs from 'fs-extra'; import handlebars from 'handlebars'; const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1); @@ -48,12 +49,12 @@ async function generateFiles(name, outputDir) { outputPaths.push(outputPath); // Does not overwrite existing files - if (await fs.pathExists(outputPath)) { + if (existsSync(outputPath)) { console.log(`Skipped (already exists): ${outputPath}`); continue; } - const templateContent = await fs.readFile(templatePath, 'utf8'); + const templateContent = await readFile(templatePath, 'utf8'); const compiled = handlebars.compile(templateContent); const content = compiled({ name, @@ -63,8 +64,8 @@ async function generateFiles(name, outputDir) { githubPath, }); - await fs.ensureDir(path.dirname(outputPath)); - await fs.writeFile(outputPath, content, 'utf8'); + await mkdir(path.dirname(outputPath), {recursive: true}); + await writeFile(outputPath, content, 'utf8'); console.log(`Created: ${outputPath}`); } execSync(`npx @biomejs/biome check --write ${outputPaths.join(' ')}`); diff --git a/packages/atomic/src/components/common/ai-conversation-modal/modal.ts b/packages/atomic/src/components/common/ai-conversation-modal/modal.ts new file mode 100644 index 00000000000..f54cef61a94 --- /dev/null +++ b/packages/atomic/src/components/common/ai-conversation-modal/modal.ts @@ -0,0 +1,85 @@ +import type {i18n} from 'i18next'; +import {html} from 'lit'; +import {localizedString} from '@/src/directives/localized-string'; +import type {FunctionalComponentWithChildren} from '@/src/utils/functional-component-utils'; +import CloseIcon from '../../../images/close.svg'; +import {ATOMIC_MODAL_EXPORT_PARTS} from '../atomic-modal/export-parts'; +import '../atomic-modal/atomic-modal'; +import {renderButton} from '../button'; +import '../atomic-icon/atomic-icon'; + +interface AIConversationModalProps { + host: HTMLElement; + i18n: i18n; + onClose(): void; + title: string; + isOpen: boolean; + openButton?: HTMLElement; + boundary?: 'page' | 'element'; + scope?: HTMLElement; +} + +export const renderAIConversationModal: FunctionalComponentWithChildren< + AIConversationModalProps +> = + ({props}) => + (children) => { + + const renderHeader = () => { + return html` +
+

${props.title}

+ ${renderButton({ + props: { + style: 'text-transparent', + class: 'grid place-items-center', + part: 'close-button', + onClick: props.onClose, + ariaLabel: props.i18n.t('close'), + }, + })( + html`` + )} +
+ `; + }; + + const renderFooter = () => { + return html` +
+ ${renderButton({ + props: { + style: 'primary', + part: 'footer-button', + class: 'flex w-full justify-center p-3 text-lg', + onClick: props.onClose, + }, + })(html` + + ${'Close'} + + `)} +
+ `; + }; + + return html` + {}} + exportparts=${ATOMIC_MODAL_EXPORT_PARTS} + .boundary=${props.boundary ?? 'page'} + .scope=${props.scope} + > + ${renderHeader()} ${children} ${renderFooter()} + + `; + }; diff --git a/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.mdx b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.mdx new file mode 100644 index 00000000000..991e103a7cc --- /dev/null +++ b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.mdx @@ -0,0 +1,22 @@ +import {Meta} from '@storybook/addon-docs/blocks'; +import * as AtomicCitationListStories from './atomic-citation-container.new.stories'; +import {AtomicDocTemplate} from '../../../../../storybook-utils/documentation/atomic-doc-template'; + + + + + +The `atomic-citation-list` component displays a list of citations for a generated answer. + +## Usage + +```html + +``` + + \ No newline at end of file diff --git a/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.new.stories.tsx b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.new.stories.tsx new file mode 100644 index 00000000000..a9d60253011 --- /dev/null +++ b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.new.stories.tsx @@ -0,0 +1,53 @@ +import type {Meta, StoryObj as Story} from '@storybook/web-components-vite'; +import {html} from 'lit'; +import {parameters} from '@/storybook-utils/common/common-meta-parameters'; +import './atomic-citation-container.js'; +import type {GeneratedAnswerCitation} from '@coveo/headless'; +import type {AtomicCitationContainer} from './atomic-citation-container.js'; + +const sampleCitations: GeneratedAnswerCitation[] = [ + { + id: '1', + title: "What's a query pipeline? | Coveo Platform", + uri: 'https://docs.coveo.com/en/query-pipeline', + permanentid: 'doc-query-pipeline-123', + source: 'Coveo Documentation', + clickUri: 'https://docs.coveo.com/en/query-pipeline', + }, + { + id: '2', + title: + 'Manage the basic configuration of a query pipeline | Coveo Platform', + uri: 'https://docs.coveo.com/en/manage-query-pipeline', + permanentid: 'doc-manage-pipeline-456', + source: 'Coveo Training', + clickUri: 'https://docs.coveo.com/en/manage-query-pipeline', + }, +]; + +const meta: Meta = { + component: 'atomic-citation-container', + title: 'Common/Citation Container', + id: 'atomic-citation-container', + + render: (args) => { + const element = document.createElement( + 'atomic-citation-container' + ) as AtomicCitationContainer; + element.citations = args.citations; + return element; + }, + parameters: { + ...parameters, + }, + args: { + citations: sampleCitations, + }, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-citation-container', + decorators: [(story) => html`${story()}`], +}; diff --git a/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.ts b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.ts new file mode 100644 index 00000000000..4cae7e5033a --- /dev/null +++ b/packages/atomic/src/components/common/atomic-citation-container/atomic-citation-container.ts @@ -0,0 +1,41 @@ +import type {GeneratedAnswerCitation} from '@coveo/headless'; +import {html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles.js'; +import '../atomic-citation-list/atomic-citation-list'; + +/** + * The `atomic-citation-container` component displays a list of citations for a generated answer in the context of a multiturn conversation. + * + * @part citation-list - The container for the citation list + * @part citation-item - Each individual citation item + * @part citation-title - The title of the citation + * @part citation-source - The source label of the citation + * @part citation-link - The clickable link element + */ +@customElement('atomic-citation-container') +@withTailwindStyles +export class AtomicCitationContainer extends LitElement { + /** + * The array of citations to display + */ + @property({type: Array, attribute: false}) + citations: GeneratedAnswerCitation[] = []; + + render() { + // Guard against null/undefined citations + + return html` +
+

Citations list:

+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-citation-container': AtomicCitationContainer; + } +} diff --git a/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.mdx b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.mdx new file mode 100644 index 00000000000..eb7076f92f8 --- /dev/null +++ b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.mdx @@ -0,0 +1,22 @@ +import {Meta} from '@storybook/addon-docs/blocks'; +import * as AtomicCitationListStories from './atomic-citation-list.new.stories'; +import {AtomicDocTemplate} from '../../../../../storybook-utils/documentation/atomic-doc-template'; + + + + + +The `atomic-citation-list` component displays a list of citations for a generated answer. + +## Usage + +```html + +``` + + \ No newline at end of file diff --git a/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.new.stories.tsx b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.new.stories.tsx new file mode 100644 index 00000000000..c1ad2d7420d --- /dev/null +++ b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.new.stories.tsx @@ -0,0 +1,53 @@ +import type {Meta, StoryObj as Story} from '@storybook/web-components-vite'; +import {html} from 'lit'; +import {parameters} from '@/storybook-utils/common/common-meta-parameters'; +import type {AtomicCitationList} from './atomic-citation-list'; +import './atomic-citation-list'; +import type {GeneratedAnswerCitation} from '@coveo/headless'; + +const sampleCitations: GeneratedAnswerCitation[] = [ + { + id: '1', + title: "What's a query pipeline? | Coveo Platform", + uri: 'https://docs.coveo.com/en/query-pipeline', + permanentid: 'doc-query-pipeline-123', + source: 'Coveo Documentation', + clickUri: 'https://docs.coveo.com/en/query-pipeline', + }, + { + id: '2', + title: + 'Manage the basic configuration of a query pipeline | Coveo Platform', + uri: 'https://docs.coveo.com/en/manage-query-pipeline', + permanentid: 'doc-manage-pipeline-456', + source: 'Coveo Training', + clickUri: 'https://docs.coveo.com/en/manage-query-pipeline', + }, +]; + +const meta: Meta = { + component: 'atomic-citation-list', + title: 'Common/Citation List', + id: 'atomic-citation-list', + + render: (args) => { + const element = document.createElement( + 'atomic-citation-list' + ) as AtomicCitationList; + element.citations = args.citations; + return element; + }, + parameters: { + ...parameters, + }, + args: { + citations: sampleCitations, + }, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-citation-list', + decorators: [(story) => html`${story()}`], +}; diff --git a/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.ts b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.ts new file mode 100644 index 00000000000..558ef036aea --- /dev/null +++ b/packages/atomic/src/components/common/atomic-citation-list/atomic-citation-list.ts @@ -0,0 +1,128 @@ +import type {GeneratedAnswerCitation} from '@coveo/headless'; +import {css, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles.js'; + +/** + * The `atomic-citation-list` component displays a list of citations for a generated answer in the context of a multiturn conversation. + * + * @part citation-list - The container for the citation list + * @part citation-item - Each individual citation item + * @part citation-title - The title of the citation + * @part citation-source - The source label of the citation + * @part citation-link - The clickable link element + */ +@customElement('atomic-citation-list') +@withTailwindStyles +export class AtomicCitationList extends LitElement { + static styles = css` + :host { + display: block; + font-family: + system-ui, + -apple-system, + sans-serif; + } + + .citation-list { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.5rem; + background-color: #efeff9; + border-radius: 1rem; + } + + .citation-list-title { + font-size: 1.125rem; + font-weight: 600; + color: #2e2e2e; + margin: 0 0 1rem 0; + } + + .citation-items { + display: flex; + flex-direction: column; + gap: 0.75rem; + list-style: none; + padding: 0; + margin: 0; + } + + .citation-item { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .citation-link { + text-decoration: none; + color: inherit; + display: block; + } + + .citation-link:hover .citation-title { + text-decoration: underline; + } + + .citation-source { + font-size: 0.875rem; + color: #828282; + margin-bottom: 0.25rem; + } + + .citation-title { + font-size: 1rem; + font-weight: 500; + color: #2e2e2e; + } + `; + + /** + * The array of citations to display + */ + @property({type: Array, attribute: false}) + citations: GeneratedAnswerCitation[] = []; + + render() { + // Guard against null/undefined citations + const citationsToRender = this.citations || []; + + return html` +
+

Citations

+ +
+ `; + } + + private renderCitation(citation: GeneratedAnswerCitation) { + const citationUrl = citation.clickUri || citation.uri; + + return html` +
  • + +
    + ${citation.source} +
    +
    + ${citation.title} +
    +
    +
  • + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-citation-list': AtomicCitationList; + } +} diff --git a/packages/atomic/src/components/common/generated-answer/atomic-generated-content.tsx b/packages/atomic/src/components/common/generated-answer/atomic-generated-content.tsx new file mode 100644 index 00000000000..865bdb24e18 --- /dev/null +++ b/packages/atomic/src/components/common/generated-answer/atomic-generated-content.tsx @@ -0,0 +1,40 @@ +import { FunctionalComponent } from "@/src/utils/functional-component-utils"; +import { html } from "lit"; +import { when } from "lit/directives/when.js"; +import { renderGeneratedTextContent } from "./generated-content/generated-text-content"; + +interface GeneratedContentContainerProps { + answer?: string; + answerContentFormat?: string; + isStreaming: boolean; +} + +export const renderAtomicGeneratedContent: FunctionalComponent< + GeneratedContentContainerProps +> = ({ props }) => { + const isMarkdown = props.answerContentFormat === "text/markdown"; + return html` +
    + ${when( + isMarkdown, + () => html` + + `, + + () => + renderGeneratedTextContent({ + props: { + answer: props.answer, + isStreaming: props.isStreaming, + }, + }) + )} + +
    + `; +}; diff --git a/packages/atomic/src/components/common/generated-answer/generated-content-container.tsx b/packages/atomic/src/components/common/generated-answer/generated-content-container.tsx index a08f38db169..525968e328b 100644 --- a/packages/atomic/src/components/common/generated-answer/generated-content-container.tsx +++ b/packages/atomic/src/components/common/generated-answer/generated-content-container.tsx @@ -1,6 +1,6 @@ -import {FunctionalComponent, h} from '@stencil/core'; -import {GeneratedMarkdownContent} from './generated-content/generated-markdown-content'; -import {GeneratedTextContent} from './generated-content/generated-text-content'; +import { FunctionalComponent, h } from "@stencil/core"; +import { GeneratedMarkdownContent } from "./generated-content/stencil-generated-markdown-content"; +import { GeneratedTextContent } from "./generated-content/stencil-generated-text-content"; interface GeneratedContentContainerProps { answer?: string; @@ -16,7 +16,7 @@ export const GeneratedContentContainer: FunctionalComponent< > = (props, children) => { return (
    - {props.answerContentFormat === 'text/markdown' ? ( + {props.answerContentFormat === "text/markdown" ? (
    + `; + } +} diff --git a/packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.ts b/packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.ts new file mode 100644 index 00000000000..b08a80afca0 --- /dev/null +++ b/packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.ts @@ -0,0 +1,22 @@ +import {html} from 'lit'; +import type {FunctionalComponent} from '@/src/utils/functional-component-utils'; + +interface GeneratedTextContentProps { + answer?: string; + isStreaming: boolean; +} + +export const renderGeneratedTextContent: FunctionalComponent< + GeneratedTextContentProps +> = ({props}) => { + return html` +

    + ${props.answer} +

    + `; +}; diff --git a/packages/atomic/src/components/common/generated-answer/generated-content/generated-markdown-content.tsx b/packages/atomic/src/components/common/generated-answer/generated-content/stencil-generated-markdown-content.tsx similarity index 100% rename from packages/atomic/src/components/common/generated-answer/generated-content/generated-markdown-content.tsx rename to packages/atomic/src/components/common/generated-answer/generated-content/stencil-generated-markdown-content.tsx diff --git a/packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.tsx b/packages/atomic/src/components/common/generated-answer/generated-content/stencil-generated-text-content.tsx similarity index 100% rename from packages/atomic/src/components/common/generated-answer/generated-content/generated-text-content.tsx rename to packages/atomic/src/components/common/generated-answer/generated-content/stencil-generated-text-content.tsx diff --git a/packages/atomic/src/components/common/index.ts b/packages/atomic/src/components/common/index.ts index 6653e078a2b..f8cf380c190 100644 --- a/packages/atomic/src/components/common/index.ts +++ b/packages/atomic/src/components/common/index.ts @@ -1,5 +1,7 @@ // Auto-generated file export {AtomicAriaLive} from './atomic-aria-live/atomic-aria-live.js'; +export {AtomicCitationContainer} from './atomic-citation-container/atomic-citation-container.js'; +export {AtomicCitationList} from './atomic-citation-list/atomic-citation-list.js'; export {AtomicComponentError} from './atomic-component-error/atomic-component-error.js'; export {AtomicFacetDateInput} from './atomic-facet-date-input/atomic-facet-date-input.js'; export {AtomicFacetPlaceholder} from './atomic-facet-placeholder/atomic-facet-placeholder.js'; diff --git a/packages/atomic/src/components/common/lazy-index.ts b/packages/atomic/src/components/common/lazy-index.ts index 47a94102b45..52a7a4a10c6 100644 --- a/packages/atomic/src/components/common/lazy-index.ts +++ b/packages/atomic/src/components/common/lazy-index.ts @@ -2,6 +2,10 @@ export default { 'atomic-aria-live': async () => await import('./atomic-aria-live/atomic-aria-live.js'), + 'atomic-citation-container': async () => + await import('./atomic-citation-container/atomic-citation-container.js'), + 'atomic-citation-list': async () => + await import('./atomic-citation-list/atomic-citation-list.js'), 'atomic-component-error': async () => await import('./atomic-component-error/atomic-component-error.js'), 'atomic-facet-date-input': async () => diff --git a/packages/atomic/src/components/common/result-children/show-hide-button.tsx b/packages/atomic/src/components/common/result-children/show-hide-button.tsx index 026c13e668a..0dc04300b1f 100644 --- a/packages/atomic/src/components/common/result-children/show-hide-button.tsx +++ b/packages/atomic/src/components/common/result-children/show-hide-button.tsx @@ -29,9 +29,7 @@ export const ShowHideButton: FunctionalComponent = ({ onClick={() => { if (moreResultsAvailable) { loadFullCollection(); - toggleShowInitialChildren(); } - toggleShowInitialChildren(); }} > diff --git a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx index 06d62aa461e..762c4263335 100644 --- a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx +++ b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children/atomic-insight-result-children.tsx @@ -64,7 +64,7 @@ export class AtomicResultChildren @State() private foldedResultListState!: InsightFoldedResultListState; @State() - private showInitialChildren = false; + private showInitialChildren = true; /** * Whether to inherit templates defined in a parent atomic-result-children. Only works for the second level of child nesting. diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.mdx b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.mdx new file mode 100644 index 00000000000..47703668fb2 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.mdx @@ -0,0 +1,19 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import * as AtomicAiConversationToggleStories from './atomic-ai-conversation-toggle.new.stories'; +import { AtomicDocTemplate } from '../../../../storybook-utils/documentation/atomic-doc-template'; + + + + + +// TODO: Add usage instructions here. + +// * See atomic-product-link.mdx for typical template component. +// * See atomic-commerce-pager.mdx for typical non-template component. + + diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.new.stories.tsx b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.new.stories.tsx new file mode 100644 index 00000000000..becad94ca3c --- /dev/null +++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.new.stories.tsx @@ -0,0 +1,35 @@ +// import {wrapInCommerceInterface} from '@/storybook-utils/commerce/commerce-interface-wrapper'; +// import {wrapInInsightInterface} from '@/storybook-utils/insight/insight-interface-wrapper'; +import type {Meta, StoryObj as Story} from '@storybook/web-components-vite'; +import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; +import {parameters} from '@/storybook-utils/common/common-meta-parameters'; +import {wrapInSearchInterface} from '@/storybook-utils/search/search-interface-wrapper'; +const {events, args, argTypes, template} = getStorybookHelpers('atomic-ai-conversation-toggle', { + excludeCategories: ['methods'], +}); + +// Wrap it in whatever interface/component you need +const {decorator, play} = wrapInSearchInterface(); +// const {decorator, play} = wrapInCommerceInterface(); +// const {decorator, play} = wrapInInsightInterface(); + +const meta: Meta = { + component: 'atomic-ai-conversation-toggle', + title: 'TODO/Ai Conversation Toggle', + id: 'atomic-ai-conversation-toggle', + render: (args) => template(args), + decorators: [decorator], + parameters: { + ...parameters, + actions: { + handles: events, + }, + }, + args, + argTypes, + play, +}; + +export default meta; + +export const Default: Story = {}; diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.spec.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.spec.ts new file mode 100644 index 00000000000..cc005a102a0 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.spec.ts @@ -0,0 +1,326 @@ +import {buildQuerySummary, type QuerySummaryState} from '@coveo/headless'; +import {html, type TemplateResult} from 'lit'; +import {ifDefined} from 'lit/directives/if-defined.js'; +import {within} from 'shadow-dom-testing-library'; +import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'; +import {page} from 'vitest/browser'; +import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/search/atomic-search-interface-fixture'; +import {buildFakeSearchEngine} from '@/vitest-utils/testing-helpers/fixtures/headless/search/engine'; +import {buildFakeSummary} from '@/vitest-utils/testing-helpers/fixtures/headless/search/summary-controller'; +import type {AtomicAiConversationToggle} from './atomic-ai-conversation-toggle'; +import './atomic-ai-conversation-toggle'; + +vi.mock('@coveo/headless', {spy: true}); + +describe('atomic-ai-conversation-toggle', () => { + const mockedEngine = buildFakeSearchEngine(); + let mockedQuerySummary: ReturnType; + + const renderAtomicAiConversationToggle = async ({ + props = {}, + slottedContent, + querySummaryState = {hasQuery: true, query: 'test query'}, + }: { + props?: Partial<{entityToGreet: string; isVulcan: boolean}>; + slottedContent?: TemplateResult; + querySummaryState?: Partial; + } = {}) => { + mockedQuerySummary = buildFakeSummary({state: querySummaryState}); + + vi.mocked(buildQuerySummary).mockImplementation(() => mockedQuerySummary); + + const {element} = + await renderInAtomicSearchInterface({ + template: html`${ifDefined(slottedContent)} + `, + selector: 'atomic-ai-conversation-toggle', + bindings: (bindings) => { + bindings.engine = mockedEngine; + return bindings; + }, + }); + + return { + element, + toggleRevealQueryButton: () => page.getByRole('button'), + parts: (element: AtomicAiConversationToggle) => { + const qs = (part: string) => + element.shadowRoot?.querySelector(`[part="${part}"]`); + return { + container: qs('container'), + message: qs('message'), + queryContainer: qs('query-container'), + toggleRevealQueryButton: qs('toggle-reveal-query-button'), + query: qs('query'), + }; + }, + }; + }; + + describe('#initialize', () => { + it('should not set the error when using the default props', async () => { + const {element} = await renderAtomicAiConversationToggle(); + + expect(element.error).toBeUndefined(); + }); + + it('should not set the error when receiving valid props', async () => { + const {element} = await renderAtomicAiConversationToggle({ + props: { + entityToGreet: 'Mr Spock', + isVulcan: true, + }, + }); + + expect(element.error).toBeUndefined(); + }); + + it('should set the error when received entityToGreet is an empty string', async () => { + const {element} = await renderAtomicAiConversationToggle({ + props: { + entityToGreet: '', + }, + }); + + expect(element.error).toBeInstanceOf(Error); + expect(element.error.message).toContain( + 'entityToGreet: value is an empty string.' + ); + }); + + it('should set the querySummary with buildQuerySummary', async () => { + const {element} = await renderAtomicAiConversationToggle(); + + expect(buildQuerySummary).toHaveBeenCalledWith(mockedEngine); + expect(element.querySummary).toBe(mockedQuerySummary); + }); + + it('should bind the querySummaryState to the state of query summary controller', async () => { + const {element} = await renderAtomicAiConversationToggle({ + querySummaryState: {hasQuery: true, query: 'test new query'}, + }); + + expect(element.querySummaryState.hasQuery).toBe(true); + expect(element.querySummaryState.query).toBe('test new query'); + }); + + it('should add an "atomic/redden" event listener to the element', async () => { + const {element} = await renderAtomicAiConversationToggle(); + const addEventListenerSpy = vi.spyOn(element, 'addEventListener'); + + element.initialize(); + + expect(addEventListenerSpy).toHaveBeenCalledExactlyOnceWith( + 'atomic/redden', + expect.any(Function) + ); + }); + }); + + describe('when the "atomic/redden" event is dispatched', () => { + let consoleLogSpy: MockInstance; + + beforeEach(() => { + // Mock the console to avoid polluting the test logs. + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should add the "text-red-600" class to the message paragraph', async () => { + const {element, parts} = await renderAtomicAiConversationToggle(); + expect(parts(element).message?.classList.contains('text-red-600')).toBe( + false + ); + + element?.dispatchEvent(new CustomEvent('atomic/redden')); + + expect(parts(element).message?.classList.contains('text-red-600')).toBe( + true + ); + }); + + it('should log to the console', async () => { + const {element} = await renderAtomicAiConversationToggle(); + + element?.dispatchEvent(new CustomEvent('atomic/redden')); + + expect(consoleLogSpy).toHaveBeenCalledWith( + 'The greeting has been reddened!' + ); + }); + }); + + describe('before updating (#willUpdate)', () => { + it('should not set the error when entityToGreet has not been updated', async () => { + const {element} = await renderAtomicAiConversationToggle(); + + element.isVulcan = true; + await element.updateComplete; + + expect(element.error).toBeUndefined(); + }); + + it('should not set the error when entityToGreet has been updated to a valid value', async () => { + const {element} = await renderAtomicAiConversationToggle(); + + element.entityToGreet = 'Mr LaForge'; + await element.updateComplete; + + expect(element.error).toBeUndefined(); + }); + + it('should set the error when entityToGreet has been updated to an invalid value', async () => { + const {element} = await renderAtomicAiConversationToggle(); + + element.entityToGreet = ''; + await element.updateComplete; + + expect(element.error).toBeInstanceOf(Error); + expect(element.error.message).toContain( + 'entityToGreet: value is an empty string.' + ); + }); + }); + + describe('when rendering (#render)', () => { + it('should render every part except the query initially', async () => { + const {element, parts} = await renderAtomicAiConversationToggle(); + const partsElements = parts(element); + + await expect.element(partsElements.container!).toBeInTheDocument(); + await expect.element(partsElements.message!).toBeInTheDocument(); + await expect.element(partsElements.query!).not.toBeInTheDocument(); + await expect.element(partsElements.queryContainer!).toBeInTheDocument(); + await expect + .element(partsElements.toggleRevealQueryButton!) + .toBeInTheDocument(); + }); + + it('should render content in the "before" slot before the message part', async () => { + const {element, parts} = await renderAtomicAiConversationToggle({ + slottedContent: html`
    Before
    `, + }); + const slot = parts(element).container?.querySelector( + 'slot[name="before"]' + ) as HTMLSlotElement; + + expect(within(slot).getByShadowText('Before')?.tagName).toBe('DIV'); + expect(within(slot).getByShadowText('Before').classList).toContain( + 'test-class' + ); + }); + + it('should render content in the default slot between the message part and the query container part', async () => { + const {element, parts} = await renderAtomicAiConversationToggle({ + slottedContent: html`
    Default
    `, + }); + const slot = parts(element).container?.querySelector( + 'slot:not([name])' + ) as HTMLSlotElement; + + expect(within(slot).getByShadowText('Default')?.tagName).toBe('DIV'); + expect(within(slot).getByShadowText('Default').classList).toContain( + 'test-class' + ); + }); + + it('should render content in the "after" slot after the query container part', async () => { + const {element, parts} = await renderAtomicAiConversationToggle({ + slottedContent: html`
    After
    `, + }); + const slot = parts(element).container?.querySelector( + 'slot[name="after"]' + ) as HTMLSlotElement; + + expect(within(slot).getByShadowText('After')?.tagName).toBe('DIV'); + expect(within(slot).getByShadowText('After').classList).toContain( + 'test-class' + ); + }); + + it('should render the Vulcan greeting when isVulcan is true', async () => { + const {element, parts} = await renderAtomicAiConversationToggle({ + props: {isVulcan: true}, + }); + + expect(parts(element).message).toHaveTextContent('🖖 Mr LaForge!'); + }); + + it('should render the non-Vulcan greeting when isVulcan is false', async () => { + const {element, parts} = await renderAtomicAiConversationToggle({ + props: {isVulcan: false}, + }); + const partsElements = parts(element); + + expect(partsElements.message).toHaveTextContent('👋 Mr LaForge!'); + }); + + it('should render the button with the correct style', async () => { + const {element, parts} = await renderAtomicAiConversationToggle(); + + expect(parts(element).toggleRevealQueryButton).toHaveClass('btn-primary'); + }); + + it('should render the button with the correct text when the query is not revealed', async () => { + const {element, parts} = await renderAtomicAiConversationToggle(); + + expect(parts(element).toggleRevealQueryButton).toHaveTextContent( + 'Show more' + ); + }); + + it('should render the button with the correct text when the query is revealed', async () => { + const {element, parts, toggleRevealQueryButton} = + await renderAtomicAiConversationToggle(); + + await toggleRevealQueryButton().click(); + + expect(parts(element).toggleRevealQueryButton).toHaveTextContent( + 'Show less' + ); + }); + + it('should enable the button when there is a query', async () => { + const {element, parts} = await renderAtomicAiConversationToggle({ + querySummaryState: {hasQuery: true, query: 'test query'}, + }); + + expect(parts(element).toggleRevealQueryButton).toBeEnabled(); + }); + + it('should disable the button when there is no query', async () => { + const {element, parts} = await renderAtomicAiConversationToggle({ + querySummaryState: {hasQuery: false, query: ''}, + }); + + expect(parts(element).toggleRevealQueryButton).toBeDisabled(); + }); + + it('should render the query part with the correct content when the button is clicked', async () => { + const {element, parts, toggleRevealQueryButton} = + await renderAtomicAiConversationToggle(); + expect(parts(element).query).not.toBeInTheDocument(); + + await toggleRevealQueryButton().click(); + + expect(parts(element).query).toBeInTheDocument(); + expect(parts(element).query).toHaveTextContent('test query'); + }); + }); + + describe('when removed from the DOM (#disconnectedCallback)', () => { + it('should remove the "atomic/redden" event listener from the element', async () => { + const {element} = await renderAtomicAiConversationToggle(); + const removeEventListenerSpy = vi.spyOn(element!, 'removeEventListener'); + + element.remove(); + + expect(removeEventListenerSpy).toHaveBeenCalledExactlyOnceWith( + 'atomic/redden', + expect.any(Function) + ); + }); + }); +}); diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.ts new file mode 100644 index 00000000000..abfadcdda00 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.ts @@ -0,0 +1,200 @@ +import { Schema, StringValue } from "@coveo/bueno"; + +import { type CSSResultGroup, css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { renderButton } from "@/src/components/common/button"; +import { ValidatePropsController } from "@/src/components/common/validate-props-controller/validate-props-controller"; +import type { Bindings } from "@/src/components/search/atomic-search-interface/interfaces"; +import { bindingGuard } from "@/src/decorators/binding-guard"; +import { bindings } from "@/src/decorators/bindings"; +import { errorGuard } from "@/src/decorators/error-guard"; +import type { InitializableComponent } from "@/src/decorators/types"; +import { withTailwindStyles } from "@/src/decorators/with-tailwind-styles.js"; +import "@/src/components/common/atomic-icon/atomic-icon"; +import "@/src/components/common/atomic-modal/atomic-modal"; +import { ATOMIC_MODAL_EXPORT_PARTS } from "../../common/atomic-modal/export-parts"; +import { booleanConverter } from "@/src/converters/boolean-converter"; +import { renderAIConversationModal } from "../../common/ai-conversation-modal/modal"; + +/** + * The `atomic-ai-conversation-toggle` component displays a simple greeting message and + * allows the user to show or hide the current query. + * + * @part container - The container for the entire component. + * @part message - The greeting message itself. + * @part query-container - The container for the button and query. + * @part toggle-reveal-query-button - The button to show or hide the query. + * @part query - The query. + * + * @slot - Content to display between the message and the the query container. + * @slot before - Content to display before the message. + * @slot after - Content to display after the query container. + * + * @remarks + * This is a template meant to be used as a starting point for creating or + * migrating components. It demonstrates common patterns and good practices. + */ +@customElement("atomic-ai-conversation-toggle") +@bindings() +@withTailwindStyles +export class AtomicAiConversationToggle + extends LitElement + implements InitializableComponent +{ + /** + * The element that opens the modal when clicked. + */ + @property({ attribute: "open-button", type: Object }) + openButton?: HTMLElement; + + /** + * The tooltip text for the AI Conversation Toggle button. + */ + @property({ type: String, attribute: "tooltip" }) + tooltip: string = "AI Mode"; + + /** + * The label text for the AI Conversation Toggle button. + */ + @property({ type: String, attribute: "label" }) label: string = "AI Mode"; + /** + * Whether the modal is open. + */ + @property({ + type: Boolean, + converter: booleanConverter, + reflect: true, + attribute: "is-open", + }) + isOpen = false; + + @state() public bindings!: Bindings; + @state() public error!: Error; + + static styles: CSSResultGroup = css` + :host { + overflow: hidden; + } + `; + + // ========================================================================== + // Standard custom element lifecycle methods + // ========================================================================== + /* + Override standard custom element lifecycle methods in this section. + + Use the following order: + 1. `constructor` + 2. `connectedCallback` + 3. `disconnectedCallback` + 4. `adoptedCallback` + 5. `attributeChangedCallback` + + Remove this multiline comment when no longer needed. + */ + + constructor() { + super(); + + new ValidatePropsController( + this, + () => ({ tooltip: this.tooltip, label: this.label }), + new Schema({ + tooltip: new StringValue({ + emptyAllowed: false, + }), + label: new StringValue({ + emptyAllowed: false, + }), + }) + ); + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + // ========================================================================== + // Public methods + // ========================================================================== + /* + Declare public non-lifecycle methods in this section. + + For initializable components, declare the `initialize` method first. + + Declare other public methods in alphabetical order. + + Remove this multiline comment when no longer needed. + */ + + public initialize() {} + + // ========================================================================== + // Lit reactive update lifecycle methods + // ========================================================================== + /* + Override Lit reactive update lifecycle method in this section. + + Use the following order: + 1. `shouldUpdate` + 2. `willUpdate` + 3. `update` + 4. `render` + 5. `firstUpdated` + 6. `updated` + + Remove this multiline comment when no longer needed. + */ + + @bindingGuard() + @errorGuard() + render() { + return html`${renderAIConversationModal({ + props: { + i18n: this.bindings.i18n, + host: this, + isOpen: this.isOpen, + onClose: () => { + this.isOpen = false; + }, + title: "✨ AI MODE!", + openButton: this.openButton, + }, + })(html``)} + ${renderButton({ + props: { + style: "primary", + class: "relative p-3", + part: `atomic-ai-conversation-toggle-button`, + title: this.tooltip, + onClick: () => { + this.isOpen = !this.isOpen; + }, + }, + })(html` + + ${this.label} + `)}`; + } + // ========================================================================== + // Private methods + // ========================================================================== + /* + Declare private methods in this section in any order. + + Remove this multiline comment when no longer needed. + */ + private handleCloseAIConversation() { + this.isOpen = false; + } +} + +declare global { + interface HTMLElementTagNameMap { + "atomic-ai-conversation-toggle": AtomicAiConversationToggle; + } +} diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/atomic-ai-conversation-toggle.e2e.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/atomic-ai-conversation-toggle.e2e.ts new file mode 100644 index 00000000000..5e4e7d08da8 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/atomic-ai-conversation-toggle.e2e.ts @@ -0,0 +1,12 @@ +import {expect, test} from './fixture'; + +test.describe('atomic-ai-conversation-toggle', () => { + test.beforeEach(async ({aiConversationToggle}) => { + await aiConversationToggle.load(); + }); + + // Add your tests here + test('should render properly', async ({aiConversationToggle}) => { + // TODO: Add test implementation + }); +}); diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/fixture.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/fixture.ts new file mode 100644 index 00000000000..adb73a5e321 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/fixture.ts @@ -0,0 +1,14 @@ +import {test as base} from '@playwright/test'; +import {AtomicAiConversationTogglePageObject} from './page-object'; + +type Fixtures = { + aiConversationToggle: AtomicAiConversationTogglePageObject; +}; + +export const test = base.extend({ + aiConversationToggle: async ({page}, use) => { + await use(new AtomicAiConversationTogglePageObject(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/page-object.ts b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/page-object.ts new file mode 100644 index 00000000000..7048815edfd --- /dev/null +++ b/packages/atomic/src/components/search/atomic-ai-conversation-toggle/e2e/page-object.ts @@ -0,0 +1,8 @@ +import type {Page} from '@playwright/test'; +import {BasePageObject} from '@/playwright-utils/lit-base-page-object'; + +export class AtomicAiConversationTogglePageObject extends BasePageObject { + constructor(page: Page) { + super(page, 'atomic-ai-conversation-toggle'); + } +} diff --git a/packages/atomic/src/components/search/index.ts b/packages/atomic/src/components/search/index.ts index fb9f17b729a..35c7850ca09 100644 --- a/packages/atomic/src/components/search/index.ts +++ b/packages/atomic/src/components/search/index.ts @@ -1,4 +1,5 @@ // Auto-generated file +export {AtomicAiConversationToggle} from './atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.js'; export {AtomicExternal} from './atomic-external/atomic-external.js'; export {AtomicFacet} from './atomic-facet/atomic-facet.js'; export {AtomicHtml} from './atomic-html/atomic-html.js'; diff --git a/packages/atomic/src/components/search/lazy-index.ts b/packages/atomic/src/components/search/lazy-index.ts index d17971ce8e8..f0d27f82f9a 100644 --- a/packages/atomic/src/components/search/lazy-index.ts +++ b/packages/atomic/src/components/search/lazy-index.ts @@ -1,5 +1,9 @@ // Auto-generated file export default { + 'atomic-ai-conversation-toggle': async () => + await import( + './atomic-ai-conversation-toggle/atomic-ai-conversation-toggle.js' + ), 'atomic-external': async () => await import('./atomic-external/atomic-external.js'), 'atomic-facet': async () => await import('./atomic-facet/atomic-facet.js'), diff --git a/packages/atomic/src/components/search/result-lists/atomic-result-children/atomic-result-children.tsx b/packages/atomic/src/components/search/result-lists/atomic-result-children/atomic-result-children.tsx index 3a87ffa2a8c..87ed0b8171c 100644 --- a/packages/atomic/src/components/search/result-lists/atomic-result-children/atomic-result-children.tsx +++ b/packages/atomic/src/components/search/result-lists/atomic-result-children/atomic-result-children.tsx @@ -77,7 +77,7 @@ export class AtomicResultChildren implements InitializableComponent { @State() private foldedResultListState!: FoldedResultListState; @State() - private showInitialChildren = false; + private showInitialChildren = true; /** * Whether to inherit templates defined in a parent atomic-result-children. Only works for the second level of child nesting. diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.spec.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.spec.ts new file mode 100644 index 00000000000..a87888cde7a --- /dev/null +++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.spec.ts @@ -0,0 +1,25 @@ +import {describe, expect, it} from 'vitest'; +import {ColorParser} from './color-parser'; + +describe('ColorParser', () => { + it('should return white for non-rgb input and inverted black', () => { + const parser = new ColorParser('foo'); + + expect(parser.rgb()).toBe('rgb(255, 255, 255)'); + expect(parser.rgbInverted()).toBe('rgb(0, 0, 0)'); + expect(parser.rgbSaturated()).toBe('rgb(255, 255, 255)'); + }); + + it('should parse rgb string and invert correctly', () => { + const parser = new ColorParser('rgb(10,20,30)'); + + expect(parser.rgb()).toBe('rgb(10, 20, 30)'); + expect(parser.rgbInverted()).toBe('rgb(245, 235, 225)'); + }); + + it('should saturate color by doubling saturation (clamped to 1)', () => { + const parser = new ColorParser('rgb(100, 150, 200)'); + + expect(parser.rgbSaturated()).toBe('rgb(0, 100, 200)'); + }); +}); diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.ts new file mode 100644 index 00000000000..f5297c07c69 --- /dev/null +++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/color-parser.ts @@ -0,0 +1,44 @@ +import {hsvToRgb, rgbToHsv} from '@/src/utils/color-utils'; + +/** + * @internal + */ +export class ColorParser { + private static readonly REGEX = + /\s*rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)/i; + + private red: number = 255; + private green: number = 255; + private blue: number = 255; + + constructor(private color: string) { + const rgb = this.color.match(ColorParser.REGEX); + if (rgb) { + this.red = parseInt(rgb[1], 10); + this.green = parseInt(rgb[2], 10); + this.blue = parseInt(rgb[3], 10); + } + } + + public rgb(): string { + return this.rgbString(this.red, this.green, this.blue); + } + + public rgbInverted(): string { + return this.rgbString(255 - this.red, 255 - this.green, 255 - this.blue); + } + + public rgbSaturated(): string { + const {h, s, v} = rgbToHsv(this.red, this.green, this.blue); + let newSaturation = s * 2; + if (newSaturation > 1) { + newSaturation = 1; + } + const {r, g, b} = hsvToRgb(h, newSaturation, v); + return this.rgbString(r, g, b); + } + + private rgbString(r: number, g: number, b: number): string { + return `rgb(${r}, ${g}, ${b})`; + } +} diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.spec.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.spec.ts new file mode 100644 index 00000000000..846dd9cc237 --- /dev/null +++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.spec.ts @@ -0,0 +1,101 @@ +import type {TermsToHighlight} from '@coveo/headless'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {unsafeHTMLElement} from '@/vitest-utils/testing-helpers/testing-utils/unsafe-html-element'; +import {getWordsHighlights, HIGHLIGHT_PREFIX} from './iframe-parser'; + +describe('getWordsHighlights', () => { + const baseColor = 'rgb(10, 20, 30)'; + const stemmingInfo: TermsToHighlight = { + keyword: ['keyword', 'keywords'], + test: ['test', 'tests'], + }; + + describe('with no iframe', () => { + it('should return an empty object', () => { + const result = getWordsHighlights(stemmingInfo); + + expect(result).toEqual({}); + }); + }); + + describe('with an iframe', () => { + let iframe: HTMLIFrameElement; + let iframeDoc: Document; + let container: HTMLDivElement; + + beforeEach(() => { + // Create a per-test container in the real document so iframe.contentDocument is available, + // but keep it isolated and remove it in afterEach to avoid polluting the global document. + container = document.createElement('div'); + document.body.appendChild(container); + + iframe = document.createElement('iframe') as HTMLIFrameElement; + container.appendChild(iframe); + + iframeDoc = (iframe.contentDocument || + iframe.contentWindow?.document) as Document; + }); + + afterEach(() => { + container?.parentNode?.removeChild(container); + container = null!; + iframe = null!; + iframeDoc = null!; + }); + + describe('with no tagged words', () => { + it('should return an empty object', () => { + const result = getWordsHighlights(stemmingInfo, iframe); + + expect(result).toEqual({}); + }); + }); + + describe('with tagged words', () => { + function createTaggedWordElement(id: string, text: string): HTMLElement { + return unsafeHTMLElement( + `${text}`, + iframeDoc + ); + } + + function appendTaggedWordElement(id: string, text: string) { + const el = createTaggedWordElement(id, text); + iframeDoc.body.appendChild(el); + } + + beforeEach(() => { + appendTaggedWordElement('1', 'keyword'); + appendTaggedWordElement('1', 'keyword'); + appendTaggedWordElement('2', 'test'); + }); + + it('should find and group highlights correctly', () => { + const highlights = getWordsHighlights(stemmingInfo, iframe); + + expect(Object.keys(highlights)).toContain('1'); + expect(Object.keys(highlights)).toContain('2'); + expect(highlights['1'].occurrences).toBe(2); + expect(highlights['2'].occurrences).toBe(1); + }); + }); + + describe('with invalid keyword identifier', () => { + let element: HTMLElement; + + beforeEach(() => { + element = unsafeHTMLElement( + ``, + iframeDoc + ); + iframeDoc.body.appendChild(element); + }); + + it('should throw an error', () => { + expect(() => getWordsHighlights(stemmingInfo, iframe)).toThrow( + 'Invalid keyword identifier for quickview' + ); + }); + }); + }); +}); diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.ts new file mode 100644 index 00000000000..e23bd3db504 --- /dev/null +++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/iframe-parser.ts @@ -0,0 +1,84 @@ +import type {TermsToHighlight} from '@coveo/headless'; +import {QuickviewWordHighlight} from './quickview-word-highlight'; +import {TextParser} from './text-parser'; + +export const HIGHLIGHT_PREFIX = 'CoveoHighlight'; + +function findHighlightedElements(iframe?: HTMLIFrameElement): HTMLElement[] { + const root = iframe?.contentDocument?.body; + if (!root) return []; + + // Collect tagged word elements as well as elements whose id starts with + // the highlight prefix. Tagged words (``) must be + // considered so that invalid identifiers (e.g. id="invalid") are + // discovered and cause the appropriate error in the parser. + const selector = `[id^="${HIGHLIGHT_PREFIX}"]`; + const query = root.querySelectorAll(selector); + + return Array.from(query); +} + +/** + * @internal + */ +export class IframeParser { + private textParser = new TextParser(this.stemmingInfoFromIndex); + private wordsHighlights: Record = {}; + + constructor( + private stemmingInfoFromIndex: TermsToHighlight, + private iframe?: HTMLIFrameElement + ) {} + + public parse(): Record { + const elements = findHighlightedElements(this.iframe); + for (const element of elements) { + const identifier = this.parseIdentifier(element); + if (!identifier) { + throw new Error('Invalid keyword identifier for quickview'); + } + + const text = this.textParser.parse(element); + if (!text) { + continue; + } + + const alreadyScannedKeyword = this.wordsHighlights[identifier]; + if (alreadyScannedKeyword) { + alreadyScannedKeyword.addElement(element); + continue; + } + + const color = element.style.backgroundColor; + const highlight = new QuickviewWordHighlight( + identifier, + text, + color, + element + ); + this.wordsHighlights[identifier] = highlight; + } + + return this.wordsHighlights; + } + + private parseIdentifier(element: HTMLElement): string | null { + const parts = element.id + .substring(HIGHLIGHT_PREFIX.length + 1) + .match(/^([0-9]+)\.([0-9]+)\.([0-9]+)$/); + + if (!parts || parts.length <= 3) { + return null; + } + + return parts[1]; + } +} + +export const getWordsHighlights = ( + stemmingInfoFromIndex: TermsToHighlight, + iframe?: HTMLIFrameElement +) => { + const parser = new IframeParser(stemmingInfoFromIndex, iframe); + return parser.parse(); +}; diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.spec.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.spec.ts new file mode 100644 index 00000000000..2966e977f33 --- /dev/null +++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.spec.ts @@ -0,0 +1,177 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import {QuickviewWordHighlight} from './quickview-word-highlight'; + +describe('QuickviewWordHighlight', () => { + const baseColor = 'rgb(10, 20, 30)'; + const focusedColor = 'rgb(245, 235, 225)'; + const previewBorderColor = 'rgb(0, 15, 30)'; + + let testDoc: Document; + + beforeEach(() => { + testDoc = document.implementation.createHTMLDocument(); + }); + + function createElement(): HTMLElement { + const element = testDoc.createElement('div'); + element.style.backgroundColor = baseColor; + return element; + } + + function createHighlight( + id: string, + text: string, + el?: HTMLElement + ): QuickviewWordHighlight { + const element = el ?? createElement(); + return new QuickviewWordHighlight(id, text, baseColor, element); + } + + function expectHighlighted(element: HTMLElement) { + expect(element.style.color).toBe(baseColor); + expect(element.style.backgroundColor).toBe(focusedColor); + } + + function expectNotHighlighted(element: HTMLElement) { + expect(element.style.color).toBe(''); + expect(element.style.backgroundColor).toBe(baseColor); + } + + describe('with one element', () => { + let element: HTMLElement; + let highlight: QuickviewWordHighlight; + + beforeEach(() => { + highlight = createHighlight('1', 'keyword'); + element = highlight.elements[0]; + }); + + it('should initialize properties correctly', () => { + expect(highlight.occurrences).toBe(1); + expect(highlight.indexIdentifier).toBe('1'); + expect(highlight.text).toBe('keyword'); + expect(highlight.color).toBe(baseColor); + }); + + it('should compute color properties correctly', () => { + expect(highlight.focusedColor).toBe(focusedColor); + expect(highlight.previewBorderColor).toBe(previewBorderColor); + }); + + describe('when no navigation has occurred', () => { + it('should not apply any highlighting styles', () => { + expectNotHighlighted(element); + }); + }); + + describe('when navigating forward', () => { + beforeEach(() => { + highlight.navigateForward(); + }); + + it('should apply highlighting styles to the focused element', () => { + expectHighlighted(element); + }); + + describe('when navigating forward again', () => { + beforeEach(() => { + highlight.navigateForward(); + }); + + it('should reapply highlighting styles to the focused element', () => { + expectHighlighted(element); + }); + }); + + describe('when navigating backward', () => { + beforeEach(() => { + highlight.navigateBackward(); + }); + + it('should reapply highlighting styles to the focused element', () => { + expectHighlighted(element); + }); + }); + }); + + describe('when navigating backward from initial state', () => { + beforeEach(() => { + highlight.navigateBackward(); + }); + + it('should apply highlighting styles to the focused element', () => { + expect(element.style.color).toBe(baseColor); + expect(element.style.backgroundColor).toBe(focusedColor); + }); + }); + }); + + describe('with two elements', () => { + let element1: HTMLElement; + let element2: HTMLElement; + let highlight: QuickviewWordHighlight; + + beforeEach(() => { + element1 = createElement(); + element2 = createElement(); + highlight = createHighlight('1', 'keyword', element1); + highlight.addElement(element2); + }); + + it('should track elements and occurrences', () => { + expect(highlight.occurrences).toBe(2); + expect(highlight.elements).toEqual([element1, element2]); + }); + + describe('when no navigation has occurred', () => { + it('should not apply any highlighting styles to either element', () => { + expectNotHighlighted(element1); + expectNotHighlighted(element2); + }); + }); + + describe('when navigating forward', () => { + beforeEach(() => { + highlight.navigateForward(); + }); + + it('should apply highlighting styles to the first element only', () => { + expectHighlighted(element1); + expectNotHighlighted(element2); + }); + + describe('when navigating forward again', () => { + beforeEach(() => { + highlight.navigateForward(); + }); + + it('should apply highlighting styles to the second element only', () => { + expectNotHighlighted(element1); + expectHighlighted(element2); + }); + + describe('when navigating forward a third time', () => { + beforeEach(() => { + highlight.navigateForward(); + }); + + it('should loop and apply highlighting to the first element again', () => { + expectHighlighted(element1); + expectNotHighlighted(element2); + }); + }); + }); + }); + + describe('when navigating backward from initial state', () => { + beforeEach(() => { + highlight.navigateBackward(); + }); + + it('should apply highlighting styles to the last element (loop backward)', () => { + expectNotHighlighted(element1); + expectHighlighted(element2); + }); + }); + }); +}); diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.ts new file mode 100644 index 00000000000..b660df08ab4 --- /dev/null +++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.ts @@ -0,0 +1,68 @@ +import {ColorParser} from './color-parser'; + +export {getWordsHighlights, HIGHLIGHT_PREFIX} from './iframe-parser'; + +export class QuickviewWordHighlight { + public readonly focusedColor: string; + public readonly previewBorderColor: string; + public readonly elements: HTMLElement[] = []; + + private currentNavigationPosition = -1; + + constructor( + public readonly indexIdentifier: string, + public readonly text: string, + public readonly color: string, + element: HTMLElement + ) { + this.addElement(element); + + const colorParser = new ColorParser(color); + this.focusedColor = colorParser.rgbInverted(); + this.previewBorderColor = colorParser.rgbSaturated(); + } + + public addElement(element: HTMLElement) { + this.elements.push(element); + } + + public get occurrences() { + return this.elements.length; + } + + public navigateForward() { + this.currentNavigationPosition++; + if (this.currentNavigationPosition >= this.elements.length) { + this.currentNavigationPosition = 0; + } + this.highlightNavigation(); + this.putElementIntoView(); + return this.elements[this.currentNavigationPosition]; + } + + public navigateBackward() { + this.currentNavigationPosition--; + if (this.currentNavigationPosition < 0) { + this.currentNavigationPosition = this.elements.length - 1; + } + this.highlightNavigation(); + this.putElementIntoView(); + return this.elements[this.currentNavigationPosition]; + } + + private highlightNavigation() { + const currentElement = this.elements[this.currentNavigationPosition]; + const otherElements = this.elements.filter((el) => el !== currentElement); + currentElement.style.color = this.color; + currentElement.style.backgroundColor = this.focusedColor; + otherElements.forEach((element) => { + element.style.color = ''; + element.style.backgroundColor = this.color; + }); + } + + private putElementIntoView() { + const element = this.elements[this.currentNavigationPosition]; + element.scrollIntoView(); + } +} diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.tsx b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.tsx deleted file mode 100644 index e2bfaa66761..00000000000 --- a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/quickview-word-highlight.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import {TermsToHighlight} from '@coveo/headless'; -import {hsvToRgb, rgbToHsv} from '../../../../utils/color-utils'; - -export const HIGHLIGHT_PREFIX = 'CoveoHighlight'; -export class QuickviewWordHighlight { - public text: string; - public indexIdentifier: string; - public occurrences = 0; - public color: string; - public focusedColor: string; - public previewBorderColor: string; - public elements: HTMLElement[] = []; - - private currentNavigationPosition = -1; - - constructor( - private stemmingInfoFromIndex: TermsToHighlight, - keywordElementInIframe: HTMLElement - ) { - const parsed = this.parseKeywordIdentifier(keywordElementInIframe); - if (!parsed) { - throw 'Invalid keyword identifier for quickview'; - } - - this.text = this.getText(keywordElementInIframe); - this.indexIdentifier = `${parsed.keywordIdentifier}`; - this.color = keywordElementInIframe.style.backgroundColor; - this.focusedColor = this.computeInvertedColor(); - this.previewBorderColor = this.computeSaturatedColor(); - - this.addElement(keywordElementInIframe); - } - - public addElement(keywordElementInIframe: HTMLElement) { - this.occurrences++; - this.elements.push(keywordElementInIframe); - } - - public navigateForward() { - this.currentNavigationPosition++; - if (this.currentNavigationPosition >= this.elements.length) { - this.currentNavigationPosition = 0; - } - this.highlightNavigation(); - this.putElementIntoView(); - return this.elements[this.currentNavigationPosition]; - } - - public navigateBackward() { - this.currentNavigationPosition--; - if (this.currentNavigationPosition < 0) { - this.currentNavigationPosition = this.elements.length - 1; - } - this.highlightNavigation(); - this.putElementIntoView(); - return this.elements[this.currentNavigationPosition]; - } - - private isTaggedWord(element: HTMLElement) { - return element.nodeName.toLowerCase() === 'coveotaggedword'; - } - - private highlightNavigation() { - const currentElement = this.elements[this.currentNavigationPosition]; - const otherElements = this.elements.filter((el) => el !== currentElement); - currentElement.style.color = this.color; - currentElement.style.backgroundColor = this.focusedColor; - otherElements.forEach((element) => { - element.style.color = ''; - element.style.backgroundColor = this.color; - }); - } - - private putElementIntoView() { - const element = this.elements[this.currentNavigationPosition]; - element.scrollIntoView(); - } - - private getText(element: HTMLElement) { - const innerTextOfHTMLElement = this.getHighlightedInnerText(element); - return this.resolveOriginalTerm(innerTextOfHTMLElement).trim(); - } - - private resolveOriginalTerm(highlight: string): string { - // First try to find either an exact match between the highlight and the original non-stemmed keyword. - // Otherwise try to find a match between the highlight and the stemming keyword expansions - // If nothing is found (which should not normally happen...), simply return the highlight keyword as is. - - const found = Object.keys(this.stemmingInfoFromIndex).find( - (originalTerm) => { - const originalTermMatch = - originalTerm.toLowerCase() === highlight.toLowerCase(); - if (originalTermMatch) { - return true; - } - const stemmingExpansions = this.stemmingInfoFromIndex[originalTerm]; - if (!stemmingExpansions) { - return false; - } - - const stemmingExpansionMatch = stemmingExpansions.find( - (stemmingExpansion) => - stemmingExpansion.toLowerCase() === highlight.toLowerCase() - ); - return stemmingExpansionMatch; - } - ); - return found || highlight; - } - - private getHighlightedInnerText(element: HTMLElement): string { - if (!this.isTaggedWord(element)) { - return this.getTextOfHTMLElement(element); - } - - const children = Array.from(element.children) as HTMLElement[]; - if (children.length >= 1) { - return this.getTextOfHTMLElement(children[0]); - } - - return ''; - } - - private parseKeywordIdentifier(element: HTMLElement) { - const parts = element.id - .substring(HIGHLIGHT_PREFIX.length + 1) - .match(/^([0-9]+)\.([0-9]+)\.([0-9]+)$/); - - if (!parts || parts.length <= 3) { - return null; - } - - return { - keywordIdentifier: parts[1], - keywordTermPart: parseInt(parts[3], 10), - }; - } - - private getTextOfHTMLElement(el: HTMLElement) { - return el.innerText || el.textContent || ''; - } - - private computeInvertedColor() { - const {r, g, b} = this.extractRgb(); - return `rgb(${255 - r}, ${255 - g}, ${255 - b})`; - } - - private computeSaturatedColor() { - const {r, g, b} = this.extractRgb(); - const {h, s, v} = rgbToHsv(r, g, b); - let newSaturation = s * 2; - if (newSaturation > 1) { - newSaturation = 1; - } - const { - r: rSaturated, - g: gSaturated, - b: bSaturated, - } = hsvToRgb(h, newSaturation, v); - return `rgb(${rSaturated}, ${gSaturated}, ${bSaturated})`; - } - - private extractRgb() { - const rgbExtracted = this.color.match(/\d+/g); - if (!rgbExtracted) { - return {r: 255, g: 255, b: 255}; - } - - return { - r: parseInt(rgbExtracted[0], 10), - g: parseInt(rgbExtracted[1], 10), - b: parseInt(rgbExtracted[2], 10), - }; - } -} - -export const getWordsHighlights = ( - stemmingInfoFromIndex: TermsToHighlight, - iframe?: HTMLIFrameElement -) => { - const wordsHighlights: Record = {}; - if (!iframe) { - return wordsHighlights; - } - - iframe.contentDocument?.body - .querySelectorAll(`[id^="${HIGHLIGHT_PREFIX}"]`) - .forEach((el) => { - const wordHTMLElementToHighlight = el as HTMLElement; - - const wordHighlight = new QuickviewWordHighlight( - stemmingInfoFromIndex, - wordHTMLElementToHighlight - ); - - if (!wordHighlight.text) { - return; - } - - const alreadyScannedKeyword = - wordsHighlights[wordHighlight.indexIdentifier]; - - if (alreadyScannedKeyword) { - alreadyScannedKeyword.addElement(wordHTMLElementToHighlight); - } else { - wordsHighlights[wordHighlight.indexIdentifier] = wordHighlight; - } - }); - - return wordsHighlights; -}; diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.spec.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.spec.ts new file mode 100644 index 00000000000..d3cc1eea63b --- /dev/null +++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.spec.ts @@ -0,0 +1,48 @@ +import type {TermsToHighlight} from '@coveo/headless'; +import {describe, expect, it} from 'vitest'; +import {TextParser} from './text-parser'; + +describe('TextParser', () => { + function createElement(tagName: string, innerText: string = ''): HTMLElement { + const element = document.createElement(tagName); + element.innerText = innerText; + return element; + } + + it('should return inner text for non-tagged elements (trimmed)', () => { + const parser = new TextParser({} as TermsToHighlight); + const el = createElement('div', ' Cat '); + + expect(parser.parse(el)).toBe('Cat'); + }); + + it('should resolve exact original term (case-insensitive)', () => { + const parser = new TextParser({Hello: []} as TermsToHighlight); + const el = createElement('div', 'hello'); + + expect(parser.parse(el)).toBe('Hello'); + }); + + it('should resolve stemming expansion to the original term', () => { + const parser = new TextParser({dogs: ['dog', 'doggy']} as TermsToHighlight); + const el = createElement('div', 'DOG'); + + expect(parser.parse(el)).toBe('dogs'); + }); + + it('should use the first child inner text for tagged elements', () => { + const parser = new TextParser({orig: ['x']} as TermsToHighlight); + const tag = createElement('coveotaggedword'); + const span = createElement('span', 'x'); + tag.appendChild(span); + + expect(parser.parse(tag)).toBe('orig'); + }); + + it('should return empty string when tagged element has no children', () => { + const parser = new TextParser({} as TermsToHighlight); + const tag = createElement('coveotaggedword'); + + expect(parser.parse(tag)).toBe(''); + }); +}); diff --git a/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.ts b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.ts new file mode 100644 index 00000000000..67f67a2ff7e --- /dev/null +++ b/packages/atomic/src/components/search/result-template-components/quickview-word-highlight/text-parser.ts @@ -0,0 +1,61 @@ +import type {TermsToHighlight} from '@coveo/headless'; + +/** + * @internal + */ +export class TextParser { + constructor(private stemmingInfoFromIndex: TermsToHighlight) {} + + public parse(element: HTMLElement) { + const innerTextOfHTMLElement = this.getHighlightedInnerText(element); + return this.resolveOriginalTerm(innerTextOfHTMLElement).trim(); + } + + private isTaggedWord(element: HTMLElement) { + return element.nodeName.toLowerCase() === 'coveotaggedword'; + } + + private getHighlightedInnerText(element: HTMLElement): string { + if (!this.isTaggedWord(element)) { + return this.getTextOfHTMLElement(element); + } + + const children = Array.from(element.children) as HTMLElement[]; + if (children.length >= 1) { + return this.getTextOfHTMLElement(children[0]); + } + + return ''; + } + + private getTextOfHTMLElement(el: HTMLElement) { + return el.innerText || el.textContent || ''; + } + + private resolveOriginalTerm(highlight: string): string { + // First try to find either an exact match between the highlight and the original non-stemmed keyword. + // Otherwise try to find a match between the highlight and the stemming keyword expansions + // If nothing is found (which should not normally happen...), simply return the highlight keyword as is. + + const found = Object.keys(this.stemmingInfoFromIndex).find( + (originalTerm) => { + const originalTermMatch = + originalTerm.toLowerCase() === highlight.toLowerCase(); + if (originalTermMatch) { + return true; + } + const stemmingExpansions = this.stemmingInfoFromIndex[originalTerm]; + if (!stemmingExpansions) { + return false; + } + + const stemmingExpansionMatch = stemmingExpansions.find( + (stemmingExpansion) => + stemmingExpansion.toLowerCase() === highlight.toLowerCase() + ); + return stemmingExpansionMatch; + } + ); + return found || highlight; + } +} diff --git a/packages/atomic/vitest-utils/testing-helpers/testing-utils/unsafe-html-element.ts b/packages/atomic/vitest-utils/testing-helpers/testing-utils/unsafe-html-element.ts new file mode 100644 index 00000000000..a97f627db6b --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/testing-utils/unsafe-html-element.ts @@ -0,0 +1,16 @@ +import {unsafeHTML} from 'lit/directives/unsafe-html.js'; +import {html, render} from 'lit-html'; + +export function unsafeHTMLElement( + raw: string, + doc: Document = document +): HTMLElement { + const container = doc.createElement('div'); + const template = html`${unsafeHTML(raw)}`; + render(template, container); + const element = container.firstElementChild as HTMLElement | null; + if (!element) { + throw new Error('Failed to create element from HTML string'); + } + return element; +} diff --git a/packages/headless/src/api/knowledge/stream-answer-api-state.ts b/packages/headless/src/api/knowledge/stream-answer-api-state.ts index cc703d5ff8f..fa475e9aab1 100644 --- a/packages/headless/src/api/knowledge/stream-answer-api-state.ts +++ b/packages/headless/src/api/knowledge/stream-answer-api-state.ts @@ -5,6 +5,7 @@ import type { GetAnswerQuerySection, InsightCaseContextSection, InsightConfigurationSection, + MultiTurnConversationSection, TabSection, } from '../../state/state-sections.js'; @@ -17,4 +18,5 @@ export type StreamAnswerAPIState = { Partial & GeneratedAnswerSection & Partial & - Partial; + Partial & + Partial; diff --git a/packages/headless/src/api/knowledge/stream-answer-api.ts b/packages/headless/src/api/knowledge/stream-answer-api.ts index 6787283e2c6..22dc3ed6f3d 100644 --- a/packages/headless/src/api/knowledge/stream-answer-api.ts +++ b/packages/headless/src/api/knowledge/stream-answer-api.ts @@ -12,6 +12,12 @@ import { logGeneratedAnswerStreamEnd, } from '../../features/generated-answer/generated-answer-analytics-actions.js'; import type {AnswerApiQueryParams} from '../../features/generated-answer/generated-answer-request.js'; +import { + addAnswer, + updateActiveAnswer, + updateActiveAnswerMessage, + updateCitationsToActiveAnswer, +} from '../../features/multi-turn-conversation/multi-turn-conversation-actions.js'; import {fetchEventSource} from '../../utils/fetch-event-source/fetch.js'; import type {EventSourceMessage} from '../../utils/fetch-event-source/parse.js'; import {getOrganizationEndpoint} from '../platform-client.js'; @@ -102,6 +108,14 @@ export const updateCacheWithEvent = ( const message: Required = JSON.parse(event.data); if (message.finishReason === 'ERROR' && message.errorMessage) { handleError(draft, message); + const errorMessage = message.errorMessage || 'Unknown error occurred'; + dispatch( + updateActiveAnswer({ + isStreaming: false, + isLoading: false, + error: errorMessage, + }) + ); } const parsedPayload: StreamPayload = message.payload.length @@ -112,6 +126,13 @@ export const updateCacheWithEvent = ( case 'genqa.headerMessageType': if (parsedPayload.contentFormat) { handleHeaderMessage(draft, parsedPayload); + dispatch( + updateActiveAnswer({ + isStreaming: true, + isLoading: false, + answerContentFormat: parsedPayload.contentFormat, + }) + ); dispatch(setAnswerContentFormat(parsedPayload.contentFormat)); } break; @@ -119,16 +140,23 @@ export const updateCacheWithEvent = ( if (parsedPayload.textDelta) { handleMessage(draft, parsedPayload); dispatch(updateMessage({textDelta: parsedPayload.textDelta})); + dispatch( + updateActiveAnswerMessage({textDelta: parsedPayload.textDelta}) + ); } break; case 'genqa.citationsType': if (parsedPayload.citations) { handleCitations(draft, parsedPayload); dispatch(updateCitations({citations: parsedPayload.citations})); + dispatch(updateCitationsToActiveAnswer(parsedPayload.citations)); } break; case 'genqa.endOfStreamType': handleEndOfStream(draft, parsedPayload); + dispatch( + updateActiveAnswer({isStreaming: false, cannotAnswer: !draft.generated}) + ); dispatch( logGeneratedAnswerStreamEnd(parsedPayload.answerGenerated ?? false) ); @@ -137,6 +165,69 @@ export const updateCacheWithEvent = ( } }; +const updateCacheWithEventBeta = ( + event: EventSourceMessage, + draft: GeneratedAnswerStream, + dispatch: ThunkDispatch +) => { + const message: Required = JSON.parse(event.data); + if (message.finishReason === 'ERROR' && message.errorMessage) { + handleError(draft, message); + const errorMessage = message.errorMessage || 'Unknown error occurred'; + dispatch( + updateActiveAnswer({ + isStreaming: false, + isLoading: false, + error: errorMessage, + }) + ); + } + + const parsedPayload: StreamPayload = message.payload.length + ? JSON.parse(message.payload) + : {}; + + switch (message.payloadType) { + case 'genqa.headerMessageType': + if (parsedPayload.contentFormat) { + handleHeaderMessage(draft, parsedPayload); + dispatch( + updateActiveAnswer({ + isStreaming: true, + isLoading: false, + answerContentFormat: parsedPayload.contentFormat, + }) + ); + // dispatch(setAnswerContentFormat(parsedPayload.contentFormat)); + } + break; + case 'genqa.messageType': + if (parsedPayload.textDelta) { + handleMessage(draft, parsedPayload); + dispatch( + updateActiveAnswerMessage({textDelta: parsedPayload.textDelta}) + ); + } + break; + case 'genqa.citationsType': + if (parsedPayload.citations) { + handleCitations(draft, parsedPayload); + dispatch(updateCitationsToActiveAnswer(parsedPayload.citations)); + } + break; + case 'genqa.endOfStreamType': + handleEndOfStream(draft, parsedPayload); + dispatch( + updateActiveAnswer({isStreaming: false, cannotAnswer: !draft.generated}) + ); + // dispatch( + // logGeneratedAnswerStreamEnd(parsedPayload.answerGenerated ?? false) + // ); + // dispatch(logGeneratedAnswerResponseLinked()); + break; + } +}; + export const buildAnswerEndpoint = ( platformEndpoint: string, organizationId: string, @@ -157,17 +248,20 @@ export const answerApi = answerSlice.injectEndpoints({ overrideExisting: true, endpoints: (builder) => ({ getAnswer: builder.query({ - queryFn: () => ({ - data: { - contentFormat: undefined, - answer: undefined, - citations: undefined, - error: undefined, - generated: false, - isStreaming: true, - isLoading: true, - }, - }), + queryFn: (args, {dispatch}) => { + dispatch(addAnswer({prompt: args.q!})); + return { + data: { + contentFormat: undefined, + answer: undefined, + citations: undefined, + error: undefined, + generated: false, + isStreaming: true, + 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. @@ -236,6 +330,89 @@ export const answerApi = answerSlice.injectEndpoints({ }); }, }), + getNewTurn: builder.query({ + queryFn: (args, {dispatch}) => { + dispatch(addAnswer({prompt: args.q!})); + return { + data: { + contentFormat: undefined, + answer: undefined, + citations: undefined, + error: undefined, + generated: false, + isStreaming: true, + 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 {configuration, generatedAnswer, insightConfiguration} = + getState() as unknown as StreamAnswerAPIState; + const {organizationId, environment, accessToken} = configuration; + const platformEndpoint = getOrganizationEndpoint( + organizationId, + environment + ); + const answerEndpoint = buildAnswerEndpoint( + platformEndpoint, + organizationId, + generatedAnswer.answerConfigurationId!, + insightConfiguration?.insightId + ); + + await fetchEventSource(answerEndpoint, { + method: 'POST', + body: JSON.stringify(args), + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Accept-Encoding': '*', + }, + fetch, + onopen: async (res) => { + const answerId = res.headers.get('x-answer-id'); + if (answerId) { + updateCachedData((draft) => { + draft.answerId = answerId; + dispatch(updateActiveAnswer({answerId})); + }); + } + }, + onmessage: (event) => { + updateCachedData((draft) => { + updateCacheWithEventBeta(event, draft, dispatch); + }); + }, + onerror: (error) => { + throw error; + }, + onclose: () => { + updateCachedData((draft) => { + dispatch(updateActiveAnswer({cannotAnswer: !draft.generated})); + }); + }, + }); + }, + }), }), }); @@ -243,6 +420,10 @@ export const fetchAnswer = (fetchAnswerParams: AnswerApiQueryParams) => { return answerApi.endpoints.getAnswer.initiate(fetchAnswerParams); }; +export const fetchNewTurn = (fetchAnswerParams: AnswerApiQueryParams) => { + return answerApi.endpoints.getNewTurn.initiate(fetchAnswerParams); +}; + // Select answer state from RTK endpoint state export const selectAnswer = (state: StreamAnswerAPIState) => { const params = selectAnswerApiQueryParams(state); diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts index b6b57d1186c..08fbba07905 100644 --- a/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts +++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts @@ -95,7 +95,7 @@ const parseEvaluationArguments = ({ question: query, }); -const subscribeToSearchRequest = ( +export const subscribeToSearchRequest = ( engine: SearchEngine ) => { let lastRequestId = ''; @@ -205,5 +205,8 @@ function loadAnswerApiReducers( QuerySection & {answer: ReturnType} > { engine.addReducers({[answerApi.reducerPath]: answerApi.reducer, query}); + engine.addReducers({ + [answerEvaluation.reducerPath]: answerEvaluation.reducer, + }); return true; } diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-multiturn-conversation.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-multiturn-conversation.ts new file mode 100644 index 00000000000..bc28d44078c --- /dev/null +++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-multiturn-conversation.ts @@ -0,0 +1,89 @@ +import {answerApi} from '../../../api/knowledge/stream-answer-api.js'; +import type {StreamAnswerAPIState} from '../../../api/knowledge/stream-answer-api-state.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 { + generateAnswerForPrompt, + updateAnswerConfigurationId, + updateResponseFormat, +} from '../../../features/generated-answer/generated-answer-actions.js'; +import {generatedAnswerReducer as generatedAnswer} from '../../../features/generated-answer/generated-answer-slice.js'; +import {multiTurnReducer as multiTurnConversation} from '../../../features/multi-turn-conversation/multi-turn-conversation-slice.js'; +import type {MultiTurnConversationState as MultiTurnConversationStateInterface} from '../../../features/multi-turn-conversation/multi-turn-conversation-state.js'; +import type { + GeneratedAnswerSection, + MultiTurnConversationSection, + QuerySection, +} from '../../../state/state-sections.js'; +import {loadReducerError} from '../../../utils/errors.js'; +import { + buildController, + type Controller, +} from '../../controller/headless-controller.js'; +import {subscribeToSearchRequest} from './headless-answerapi-generated-answer.js'; + +export interface MultiturnConversation extends Controller { + generateAnswerForPrompt(prompt: string): void; + subscribeToSearchRequest(): void; + state: MultiTurnConversationStateInterface; +} + +export interface MultiturnConversationProps { + answerConfigurationId?: string; +} + +export interface MultiturnConversationState + extends MultiTurnConversationStateInterface {} + +export function buildMultiturnConversation( + engine: SearchEngine | InsightEngine, + props: MultiturnConversationProps = {} +): MultiturnConversation { + if (!loadAnswerApiReducers(engine)) { + throw loadReducerError; + } + warnIfUsingNextAnalyticsModeForServiceFeature( + engine.state.configuration.analytics.analyticsMode + ); + + const controller = buildController(engine); + const getState = () => engine.state; + engine.dispatch(updateAnswerConfigurationId(props.answerConfigurationId!)); + engine.dispatch( + updateResponseFormat({ + contentFormat: ['text/markdown', 'text/plain'], + }) + ); + + return { + ...controller, + get state() { + return { + ...getState().multiTurnConversation, + }; + }, + subscribeToSearchRequest() { + subscribeToSearchRequest(engine as SearchEngine); + }, + generateAnswerForPrompt(prompt) { + engine.dispatch(generateAnswerForPrompt(prompt)); + }, + }; +} + +function loadAnswerApiReducers( + engine: SearchEngine | InsightEngine +): engine is SearchEngine< + GeneratedAnswerSection & + QuerySection & { + answer: ReturnType; + } & MultiTurnConversationSection +> { + engine.addReducers({ + [answerApi.reducerPath]: answerApi.reducer, + multiTurnConversation, + generatedAnswer, + }); + return true; +} 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..b938e94d5b1 100644 --- a/packages/headless/src/features/generated-answer/generated-answer-actions.ts +++ b/packages/headless/src/features/generated-answer/generated-answer-actions.ts @@ -16,7 +16,10 @@ import type { GeneratedAnswerStreamEventData, } from '../../api/generated-answer/generated-answer-event-payload.js'; import type {GeneratedAnswerStreamRequest} from '../../api/generated-answer/generated-answer-request.js'; -import {fetchAnswer} from '../../api/knowledge/stream-answer-api.js'; +import { + fetchAnswer, + fetchNewTurn, +} 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'; @@ -331,9 +334,8 @@ export const streamAnswer = createAsyncThunk< * instead of the regular search pipeline. * * Flow: - * 1. Reset the current generated answer state. - * 2. Construct the Answer API query parameters based on the current state. - * 3. Fetch a new answer from the Answer API using the provided configuration. + * 1. Construct the Answer API query parameters based on the current state. + * 2. Fetch a new answer from the Answer API using the provided configuration. */ export const generateAnswer = createAsyncThunk< void, @@ -361,3 +363,31 @@ export const generateAnswer = createAsyncThunk< } } ); + +export const generateAnswerForPrompt = createAsyncThunk< + void, + string, + AsyncThunkOptions +>( + 'generatedAnswer/generateAnswer', + async ( + prompt: string, + {getState, dispatch, extra: {navigatorContext, logger}} + ) => { + const state = getState() as StreamAnswerAPIState; + if (state.generatedAnswer.answerConfigurationId) { + const answerApiQueryParams = constructAnswerAPIQueryParams( + {...state, query: {q: prompt, enableQuerySyntax: false}}, + navigatorContext + ); + // Important to remove previous line when un-commenting the next line + // dispatch(setAnswerApiQueryParams(answerApiQueryParams)); + await dispatch(fetchNewTurn(answerApiQueryParams)); + } else { + logger.warn( + '[WARNING] Missing answerConfigurationId in engine configuration. ' + + 'The generateAnswer action requires an answer configuration ID to use CRGA with the Answer API.' + ); + } + } +); diff --git a/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-actions.ts b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-actions.ts new file mode 100644 index 00000000000..7ce39934ee5 --- /dev/null +++ b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-actions.ts @@ -0,0 +1,46 @@ +import {StringValue} from '@coveo/bueno'; +import {createAction} from '@reduxjs/toolkit'; +import type { + GeneratedAnswerCitation, + GeneratedAnswerMessagePayload, +} from '../../api/generated-answer/generated-answer-event-payload.js'; +import {validatePayload} from '../../utils/validate-payload.js'; +import type {GeneratedContentFormat} from '../generated-answer/generated-response-format.js'; + +const stringValue = new StringValue({required: true}); + +export const resetConversation = createAction<{conversationId: string}>( + 'multiTurn/resetConversation' +); + +export const addAnswer = createAction<{prompt: string}>('multiTurn/addAnswer'); + +export const updateActiveAnswerMessage = createAction( + 'multiTurn/updateActiveAnswerMessage', + (payload: GeneratedAnswerMessagePayload) => + validatePayload(payload, { + textDelta: stringValue, + }) +); + +export const updateActiveAnswer = createAction< + Partial<{ + answer: string; + prompt: string; + citations: GeneratedAnswerCitation[]; + isStreaming: boolean; + isLoading: boolean; + error: string | null; + cannotAnswer: boolean; + answerId: string; + answerContentFormat: GeneratedContentFormat; + }> +>('multiTurn/updateActiveAnswer'); + +export const updateCitationsToActiveAnswer = createAction< + GeneratedAnswerCitation[] +>('multiTurn/updateCitationsToActiveAnswer'); + +export const markActiveAnswerComplete = createAction( + 'multiTurn/markActiveAnswerComplete' +); diff --git a/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-slice.ts b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-slice.ts new file mode 100644 index 00000000000..0c7b910645e --- /dev/null +++ b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-slice.ts @@ -0,0 +1,72 @@ +import {createReducer} from '@reduxjs/toolkit'; +import { + addAnswer, + markActiveAnswerComplete, + resetConversation, + updateActiveAnswer, + updateActiveAnswerMessage, + updateCitationsToActiveAnswer, +} from './multi-turn-conversation-actions.js'; +import { + type ConversationTurn, + getMultiTurnConversationInitialState, + type MultiTurnConversationState, +} from './multi-turn-conversation-state.js'; + +export const multiTurnReducer = createReducer( + getMultiTurnConversationInitialState(), + (builder) => { + builder + .addCase(resetConversation, (state, action) => { + state.conversationId = action.payload.conversationId; + state.answers = []; + }) + .addCase(addAnswer, (state, action) => { + const newAnswer: ConversationTurn = { + answer: '', + prompt: action.payload.prompt, + citations: [], + isStreaming: false, + isLoading: true, + error: null, + cannotAnswer: false, + answerContentFormat: 'text/plain', + }; + state.answers.push(newAnswer); + }) + .addCase(updateActiveAnswerMessage, (state, {payload}) => { + const index = state.answers.length - 1; + if (index >= 0) { + state.answers[index] = { + ...state.answers[index], + isLoading: false, + isStreaming: true, + error: null, + answer: (state.answers[index].answer || '') + payload.textDelta, + }; + } + }) + .addCase(updateActiveAnswer, (state, action) => { + const index = state.answers.length - 1; + if (index >= 0) { + state.answers[index] = { + ...state.answers[index], + ...action.payload, + }; + } + }) + .addCase(updateCitationsToActiveAnswer, (state, action) => { + const index = state.answers.length - 1; + if (index >= 0) { + state.answers[index].citations = action.payload; + } + }) + .addCase(markActiveAnswerComplete, (state) => { + const index = state.answers.length - 1; + if (index >= 0) { + state.answers[index].isLoading = false; + state.answers[index].isStreaming = false; + } + }); + } +); diff --git a/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-state.ts b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-state.ts new file mode 100644 index 00000000000..1ebab3c5b42 --- /dev/null +++ b/packages/headless/src/features/multi-turn-conversation/multi-turn-conversation-state.ts @@ -0,0 +1,26 @@ +import type {GeneratedAnswerCitation} from '../../api/generated-answer/generated-answer-event-payload.js'; +import type {GeneratedContentFormat} from '../generated-answer/generated-response-format.js'; + +export interface ConversationTurn { + answer: string; + prompt: string; + citations: GeneratedAnswerCitation[]; + isStreaming: boolean; + isLoading: boolean; + error: string | null; + cannotAnswer: boolean; + answerId?: string; + answerContentFormat: GeneratedContentFormat; +} + +export interface MultiTurnConversationState { + conversationId: string; + answers: ConversationTurn[]; +} + +export function getMultiTurnConversationInitialState(): MultiTurnConversationState { + return { + conversationId: '', + answers: [], + }; +} diff --git a/packages/headless/src/index.ts b/packages/headless/src/index.ts index b8f15d65256..adf26b0520c 100644 --- a/packages/headless/src/index.ts +++ b/packages/headless/src/index.ts @@ -251,6 +251,12 @@ export type { InstantResultsState, } from './controllers/instant-results/instant-results.js'; export {buildInstantResults} from './controllers/instant-results/instant-results.js'; +export type { + MultiturnConversation, + MultiturnConversationProps, + MultiturnConversationState, +} from './controllers/knowledge/generated-answer/headless-multiturn-conversation.js'; +export {buildMultiturnConversation} from './controllers/knowledge/generated-answer/headless-multiturn-conversation.js'; export type { Pager, PagerInitialState, diff --git a/packages/headless/src/insight.index.ts b/packages/headless/src/insight.index.ts index a10b14fd9a2..7e60f25be69 100644 --- a/packages/headless/src/insight.index.ts +++ b/packages/headless/src/insight.index.ts @@ -320,6 +320,12 @@ export type { export {buildUserActions} from './controllers/insight/user-actions/headless-user-actions.js'; export type {InsightInterface} from './controllers/insight-interface/insight-interface.js'; export {buildInsightInterface} from './controllers/insight-interface/insight-interface.js'; +export type { + MultiturnConversation, + MultiturnConversationProps, + MultiturnConversationState, +} from './controllers/knowledge/generated-answer/headless-multiturn-conversation.js'; +export {buildMultiturnConversation} from './controllers/knowledge/generated-answer/headless-multiturn-conversation.js'; export * from './features/analytics/generic-analytics-actions-loader.js'; export * from './features/analytics/insight-analytics-actions-loader.js'; export * from './features/attached-results/attached-results-actions-loader.js'; diff --git a/packages/headless/src/state/insight-app-state.ts b/packages/headless/src/state/insight-app-state.ts index 88afdc9764a..28e60bf0b66 100644 --- a/packages/headless/src/state/insight-app-state.ts +++ b/packages/headless/src/state/insight-app-state.ts @@ -15,6 +15,7 @@ import type { InsightConfigurationSection, InsightInterfaceSection, InsightUserActionsSection, + MultiTurnConversationSection, NumericFacetSection, PaginationSection, QuerySection, @@ -66,4 +67,5 @@ export type InsightAppState = InsightSearchParametersState & ContextSection & InsightUserActionsSection & TriggerSection & - RecentQueriesSection; + RecentQueriesSection & + MultiTurnConversationSection; diff --git a/packages/headless/src/state/search-app-state.ts b/packages/headless/src/state/search-app-state.ts index 561160f9166..0b01386e704 100644 --- a/packages/headless/src/state/search-app-state.ts +++ b/packages/headless/src/state/search-app-state.ts @@ -19,6 +19,7 @@ import type { GeneratedAnswerSection, HistorySection, InstantResultSection, + MultiTurnConversationSection, NumericFacetSection, PaginationSection, PipelineSection, @@ -78,4 +79,5 @@ export type SearchAppState = SearchParametersState & RecentQueriesSection & ExcerptLengthSection & GeneratedAnswerSection & - InstantResultSection; + InstantResultSection & + MultiTurnConversationSection; diff --git a/packages/headless/src/state/state-sections.ts b/packages/headless/src/state/state-sections.ts index e992bac55ca..77e1ff04dff 100644 --- a/packages/headless/src/state/state-sections.ts +++ b/packages/headless/src/state/state-sections.ts @@ -54,6 +54,7 @@ import type {InsightConfigurationState} from '../features/insight-configuration/ import type {InsightInterfaceState} from '../features/insight-interface/insight-interface-state.js'; import type {UserActionsState} from '../features/insight-user-actions/insight-user-actions-state.js'; import type {InstantResultsState} from '../features/instant-results/instant-results-state.js'; +import type {MultiTurnConversationState} from '../features/multi-turn-conversation/multi-turn-conversation-state.js'; import type {PaginationState} from '../features/pagination/pagination-state.js'; import type {QueryState} from '../features/query/query-state.js'; import type {QuerySetState} from '../features/query-set/query-set-state.js'; @@ -70,6 +71,13 @@ import type {StaticFilterSetState} from '../features/static-filter-set/static-fi import type {TabSetState} from '../features/tab-set/tab-set-state.js'; import type {TriggerState} from '../features/triggers/triggers-state.js'; +export interface MultiTurnConversationSection { + /** + * The state of the multi-turn conversation. + */ + multiTurnConversation: MultiTurnConversationState; +} + export interface QuerySection { /** * The expressions that constitute the current query. diff --git a/packages/headless/src/test/mock-insight-state.ts b/packages/headless/src/test/mock-insight-state.ts index 5b0d629d328..747db947b5a 100644 --- a/packages/headless/src/test/mock-insight-state.ts +++ b/packages/headless/src/test/mock-insight-state.ts @@ -15,6 +15,7 @@ import {getGeneratedAnswerInitialState} from '../features/generated-answer/gener import {getInsightConfigurationInitialState} from '../features/insight-configuration/insight-configuration-state.js'; import {getInsightInterfaceInitialState} from '../features/insight-interface/insight-interface-state.js'; import {getInsightUserActionsInitialState} from '../features/insight-user-actions/insight-user-actions-state.js'; +import {getMultiTurnConversationInitialState} from '../features/multi-turn-conversation/multi-turn-conversation-state.js'; import {getPaginationInitialState} from '../features/pagination/pagination-state.js'; import {getQueryInitialState} from '../features/query/query-state.js'; import {getQuerySetInitialState} from '../features/query-set/query-set-state.js'; @@ -65,6 +66,7 @@ export function buildMockInsightState( insightUserActions: getInsightUserActionsInitialState(), triggers: getTriggerInitialState(), recentQueries: getRecentQueriesInitialState(), + multiTurnConversation: getMultiTurnConversationInitialState(), ...config, }; } diff --git a/packages/headless/src/test/mock-state.ts b/packages/headless/src/test/mock-state.ts index 14dee456da5..746f7bc7bb6 100644 --- a/packages/headless/src/test/mock-state.ts +++ b/packages/headless/src/test/mock-state.ts @@ -20,6 +20,7 @@ import {getFoldingInitialState} from '../features/folding/folding-state.js'; import {getGeneratedAnswerInitialState} from '../features/generated-answer/generated-answer-state.js'; import {getHistoryInitialState} from '../features/history/history-state.js'; import {getInstantResultsInitialState} from '../features/instant-results/instant-results-state.js'; +import {getMultiTurnConversationInitialState} from '../features/multi-turn-conversation/multi-turn-conversation-state.js'; import {getPaginationInitialState} from '../features/pagination/pagination-state.js'; import {getPipelineInitialState} from '../features/pipeline/pipeline-state.js'; import {getQueryInitialState} from '../features/query/query-state.js'; @@ -86,6 +87,7 @@ export function createMockState( excerptLength: getExcerptLengthInitialState(), automaticFacetSet: getAutomaticFacetSetInitialState(), generatedAnswer: getGeneratedAnswerInitialState(), + multiTurnConversation: getMultiTurnConversationInitialState(), ...config, }; }