diff --git a/package-lock.json b/package-lock.json index 4ebdae123f6..4b9d4d0d4e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59991,7 +59991,9 @@ "license": "Apache-2.0", "dependencies": { "@coveo/bueno": "0.45.9", - "@coveo/headless": "2.68.0" + "@coveo/headless": "2.68.0", + "dompurify": "3.1.5", + "marked": "12.0.2" }, "devDependencies": { "@ckeditor/jsdoc-plugins": "39.9.1", @@ -60607,6 +60609,11 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "packages/quantic/node_modules/dompurify": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.5.tgz", + "integrity": "sha512-lwG+n5h8QNpxtyrJW/gJWckL+1/DQiYMX8f7t8Z2AZTPw1esVrqjI63i7Zc2Gz0aKzLVMYC1V1PL/ky+aY/NgA==" + }, "packages/quantic/node_modules/eslint-plugin-jest": { "version": "28.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.2.0.tgz", @@ -60709,6 +60716,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/quantic/node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "packages/quantic/node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", diff --git a/packages/quantic/.gitignore b/packages/quantic/.gitignore index 1d1913f33f3..0c4a988d58b 100755 --- a/packages/quantic/.gitignore +++ b/packages/quantic/.gitignore @@ -38,6 +38,8 @@ scratch-def.ci.json # Static resources copied from node_modules force-app/main/default/staticresources/coveoheadless force-app/main/default/staticresources/coveobueno +force-app/main/default/staticresources/marked +force-app/main/default/staticresources/dompurify # Test Coverage & Report coverage diff --git a/packages/quantic/copy-static-resources.js b/packages/quantic/copy-static-resources.js index b5a46fdb8ab..80cd35fbde0 100644 --- a/packages/quantic/copy-static-resources.js +++ b/packages/quantic/copy-static-resources.js @@ -12,7 +12,45 @@ const copy = async (source, dest) => { }; const main = async () => { - console.info('Begin copy.'); + console.info('Begin copy static resources'); + await copyHeadless(); + await copyBueno(); + await copyMarked(); + await copyDompurify(); +}; + +const copyDompurify = async () => { + console.info('Begin copy DOMPurify.'); + + await mkdir('./force-app/main/default/staticresources/dompurify', { + recursive: true, + }); + + await copy( + '../../node_modules/dompurify/dist/purify.min.js', + './force-app/main/default/staticresources/dompurify/purify.min.js' + ); + + console.info('DOMPurify copied.'); +}; + +const copyMarked = async () => { + console.info('Begin copy Marked.'); + + await mkdir('./force-app/main/default/staticresources/marked', { + recursive: true, + }); + + await copy( + '../../node_modules/marked/marked.min.js', + './force-app/main/default/staticresources/marked/marked.min.js' + ); + + console.info('Marked copied.'); +}; + +const copyHeadless = async () => { + console.info('Begin copy Headless.'); await mkdir( './force-app/main/default/staticresources/coveoheadless/case-assist', @@ -30,15 +68,6 @@ const main = async () => { './force-app/main/default/staticresources/coveoheadless/definitions/', {recursive: true} ); - await mkdir('./force-app/main/default/staticresources/coveobueno/browser', { - recursive: true, - }); - await mkdir( - './force-app/main/default/staticresources/coveobueno/definitions', - { - recursive: true, - } - ); await copy( '../../node_modules/@coveo/headless/dist/quantic/headless.js', './force-app/main/default/staticresources/coveoheadless/headless.js' @@ -59,6 +88,22 @@ const main = async () => { '../../node_modules/@coveo/headless/dist/definitions', './force-app/main/default/staticresources/coveoheadless/definitions' ); + + console.info('Headless copied.'); +}; + +const copyBueno = async () => { + console.info('Begin copy Bueno.'); + + await mkdir('./force-app/main/default/staticresources/coveobueno/browser', { + recursive: true, + }); + await mkdir( + './force-app/main/default/staticresources/coveobueno/definitions', + { + recursive: true, + } + ); await copy( '../../node_modules/@coveo/bueno/dist/browser/bueno.js', './force-app/main/default/staticresources/coveobueno/browser/bueno.js' @@ -68,7 +113,7 @@ const main = async () => { './force-app/main/default/staticresources/coveobueno/definitions' ); - console.info('Headless copied.'); + console.info('Bueno copied.'); }; main().then(() => { diff --git a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-expectations.ts b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-expectations.ts index 52853729437..433e78f01bb 100644 --- a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-expectations.ts +++ b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-expectations.ts @@ -155,12 +155,30 @@ function generatedAnswerExpectations(selector: GeneratedAnswerSelector) { .log(`the generated answer should contain "${answer}"`); }, + generatedAnswerContentContainsHTML: (findSelector: string) => { + selector + .generatedAnswerContentContainer() + .find(findSelector) + .log( + `the generated answer content should contain an element matching "${findSelector}"` + ); + }, + + generatedAnswerContentContainsText: (text: string) => { + selector + .generatedAnswerContentContainer() + .contains(text) + .log( + `the generated answer content should contain text matching "${text}"` + ); + }, + generatedAnswerIsStreaming: (isStreaming: boolean) => { selector - .generatedAnswer() + .generatedAnswerContentContainer() .should( isStreaming ? 'have.class' : 'not.have.class', - 'generated-answer__answer--streaming' + 'generated-answer-content__answer--streaming' ) .log(`the generated answer ${should(isStreaming)} be streaming`); }, diff --git a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts index 2d3228b06b6..4e0ce4703b9 100644 --- a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts +++ b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer-selectors.ts @@ -14,6 +14,7 @@ export interface GeneratedAnswerSelector extends ComponentSelector { retryButton: () => CypressSelector; toggleGeneratedAnswerButton: () => CypressSelector; generatedAnswerContent: () => CypressSelector; + generatedAnswerContentContainer: () => CypressSelector; feedbackModal: () => CypressSelector; feedbackOption: (index: number) => CypressSelector; feedbackSubmitButton: () => CypressSelector; @@ -77,6 +78,10 @@ export const GeneratedAnswerSelectors: GeneratedAnswerSelector = { GeneratedAnswerSelectors.get().find( '[data-cy="generated-answer__content"]' ), + generatedAnswerContentContainer: () => + GeneratedAnswerSelectors.get().find( + '[data-cy="generated-answer__content-container"]' + ), feedbackModal: () => cy.get('lightning-modal'), feedbackOption: (index: number) => cy.get('lightning-modal').find('lightning-radio-group input').eq(index), diff --git a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer.cypress.ts b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer.cypress.ts index b127c8fafc9..62e6408ddea 100644 --- a/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer.cypress.ts +++ b/packages/quantic/cypress/e2e/default-2/generatedAnswer/generated-answer.cypress.ts @@ -215,6 +215,177 @@ describe('quantic-generated-answer', () => { }); }); + describe('handling text/markdown', () => { + const streamId = crypto.randomUUID(); + const genQaMarkdownTypePayload = { + payloadType: 'genqa.headerMessageType', + payload: JSON.stringify({ + answerStyle: 'default', + contentFormat: 'text/markdown', + }), + }; + + beforeEach(() => { + mockSearchWithGeneratedAnswer(streamId, param.useCase); + }); + + it('should display the generated answer content in markdown format', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '# Some markdown text', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + }); + + it('should properly create divs instead of headings', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '# level1\n ## level2\n ### level3', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML( + 'div[data-level="answer-heading-1"]' + ); + Expect.generatedAnswerContentContainsText('level1'); + Expect.generatedAnswerContentContainsHTML( + 'div[data-level="answer-heading-2"]' + ); + Expect.generatedAnswerContentContainsText('level2'); + Expect.generatedAnswerContentContainsHTML( + 'div[data-level="answer-heading-3"]' + ); + Expect.generatedAnswerContentContainsText('level3'); + }); + + it('should properly close bold tags even before receiving the closing tag', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '**bold text', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML('strong'); + Expect.generatedAnswerContentContainsText('bold text'); + }); + + it('should properly close code tags even before receiving the closing tag', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '`code text', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML('code'); + Expect.generatedAnswerContentContainsText('code text'); + }); + + it('should properly render lists as ul and li', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '- list item 1\n- list item 2', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML('ul'); + Expect.generatedAnswerContentContainsHTML('li'); + Expect.generatedAnswerContentContainsText('list item 1'); + Expect.generatedAnswerContentContainsText('list item 2'); + }); + + it('should properly render tables as scrollable-tables', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: + '| Tables | Are | Cool |\n|----------|-------------|------|\n| col1 | col2 | col3 |', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML( + 'div.scrollable-table > table > thead' + ); + Expect.generatedAnswerContentContainsHTML('th'); + Expect.generatedAnswerContentContainsText('Tables Are Cool'); + Expect.generatedAnswerContentContainsText('col1 col2 col3'); + }); + + it('should properly render code blocks', () => { + const genQaMarkdownTextPayload = { + payloadType: 'genqa.messageType', + payload: JSON.stringify({ + textDelta: '```\nconst foo = "bar";\nconsole.log(foo);\n```', + }), + finishReason: 'COMPLETED', + }; + mockStreamResponse(streamId, [ + genQaMarkdownTypePayload, + genQaMarkdownTextPayload, + ]); + visitGeneratedAnswer({ + useCase: param.useCase, + }); + Expect.displayGeneratedAnswerCard(true); + Expect.generatedAnswerContentContainsHTML('pre > code'); + Expect.generatedAnswerContentContainsText('const foo = "bar";'); + Expect.generatedAnswerContentContainsText('console.log(foo);'); + }); + }); + describe('when a custom value is provided to the fields to include in citations attribute', () => { const streamId = crypto.randomUUID(); const customFields = 'foo,bar'; diff --git a/packages/quantic/cypress/page-objects/search.ts b/packages/quantic/cypress/page-objects/search.ts index 7ebb75217d5..ff8610fc40d 100644 --- a/packages/quantic/cypress/page-objects/search.ts +++ b/packages/quantic/cypress/page-objects/search.ts @@ -524,8 +524,20 @@ export function mockStreamResponse(streamId: string, body: unknown) { url: `**/machinelearning/streaming/${streamId}`, }, (request) => { - request.reply(200, `data: ${JSON.stringify(body)} \n\n`, { - 'content-type': 'text/event-stream', + let bodyText = ''; + if (!Array.isArray(body)) { + bodyText = `data: ${JSON.stringify(body)} \n\n`; + } else { + body.forEach((data) => { + bodyText += `data: ${JSON.stringify(data)} \n\n`; + }); + } + request.reply({ + statusCode: 200, + body: bodyText, + headers: { + 'content-type': 'text/event-stream', + }, }); } ).as(getStreamInterceptAlias(streamId).substring(1)); diff --git a/packages/quantic/force-app/main/default/.eslintrc.json b/packages/quantic/force-app/main/default/.eslintrc.json index a6a6c116306..c78044ffbe6 100644 --- a/packages/quantic/force-app/main/default/.eslintrc.json +++ b/packages/quantic/force-app/main/default/.eslintrc.json @@ -7,7 +7,9 @@ "CoveoHeadlessCaseAssist": "readonly", "CoveoHeadlessInsight": "readonly", "CoveoHeadlessRecommendation": "readonly", - "Bueno": "readonly" + "Bueno": "readonly", + "DOMPurify": "readonly", + "marked": "readonly" }, "rules": { "no-unused-expressions": ["error", {"allowTernary": true}] diff --git a/packages/quantic/force-app/main/default/lwc/jsconfig.json b/packages/quantic/force-app/main/default/lwc/jsconfig.json index 7e57491b147..7f5b4ae8ac7 100644 --- a/packages/quantic/force-app/main/default/lwc/jsconfig.json +++ b/packages/quantic/force-app/main/default/lwc/jsconfig.json @@ -79,6 +79,9 @@ ], "c/quanticRadioButtonsGroup": [ "quanticRadioButtonsGroup/quanticRadioButtonsGroup.js" + ], + "c/quanticGeneratedAnswerContent": [ + "quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js" ] } }, diff --git a/packages/quantic/force-app/main/default/lwc/quanticCitation/quanticCitation.js b/packages/quantic/force-app/main/default/lwc/quanticCitation/quanticCitation.js index 45647e596b7..229118c4b6e 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticCitation/quanticCitation.js +++ b/packages/quantic/force-app/main/default/lwc/quanticCitation/quanticCitation.js @@ -16,9 +16,9 @@ const debounceDurationBeforeHoverMs = 200; */ export default class QuanticCitation extends NavigationMixin(LightningElement) { /** + * The citation item information. * @api * @type {{title: string, index: number, text: string, clickUri: string, fields: object}} - * The citation item information. */ @api citation; /** diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js index c115f8c3b3a..74cf19ea81f 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/quanticGeneratedAnswer.js @@ -201,7 +201,10 @@ export default class QuanticGeneratedAnswer extends LightningElement { return this.headless.buildGeneratedAnswer(engine, { initialState: { isVisible: initialVisibility, - responseFormat: {answerStyle: this.answerStyle}, + responseFormat: { + answerStyle: this.answerStyle, + contentFormat: ['text/markdown', 'text/plain'], + }, }, fieldsToIncludeInCitations: this.citationFields, }); @@ -404,6 +407,10 @@ export default class QuanticGeneratedAnswer extends LightningElement { return this?.state?.citations; } + get answerContentFormat() { + return this?.state?.answerContentFormat; + } + get shouldDisplayCitations() { const hasCitations = !!this.citations?.length; return hasCitations && !this.isAnswerCollapsed; @@ -443,9 +450,7 @@ export default class QuanticGeneratedAnswer extends LightningElement { ? 'generated-answer__answer--expanded' : 'generated-answer__answer--collapsed'; } - return `generated-answer__answer ${ - this.isStreaming ? 'generated-answer__answer--streaming' : '' - } ${collapsedStateClass}`; + return `generated-answer__answer ${collapsedStateClass}`; } get hasRetryableError() { diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css index 8a9e0cf61a9..0f9a53fb16e 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.css @@ -7,8 +7,6 @@ .generated-answer__answer { white-space: pre-wrap; - line-height: 1.5rem; - font-size: 15px; overflow: hidden; position: relative; } @@ -23,17 +21,6 @@ max-width: 425px; } -.generated-answer__answer--streaming > span::after { - content: ''; - width: 0.5rem; - height: 0.9rem; - margin-left: 0.1rem; - background: var(--lwc-brandAccessible, #0176d3); - display: inline-block; - animation: cursor-blink 1.5s steps(2, start) infinite; - vertical-align: baseline; -} - .generated-answer__answer--collapsed { max-height: var(--maxHeight, 250px); } diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html index 2455b08f0d0..69e16573d1f 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswer/templates/generatedAnswer.html @@ -45,7 +45,12 @@ class="generated-answer__content slds-var-m-top_medium" >
- {answer} + +
text); +global.marked = { + use: mockMarkedUse, + parse: mockMarkedParse, +}; + +const SELECTORS = { + textAnswerContainer: 'span.generated-answer-content__answer', + markdownAnswerContainer: 'div.generated-answer-content__answer', +}; + +const defaultOptions = { + isStreaming: false, + answerContentFormat: 'text/plain', + answer: '', +}; + +function createTestComponent(options = defaultOptions) { + const element = createElement('c-quantic-generated-answer-content', { + is: QuanticGeneratedAnswerContent, + }); + for (const [key, value] of Object.entries(options)) { + element[key] = value; + } + + document.body.appendChild(element); + return element; +} + +// Helper function to wait until the microtask queue is empty. +function flushPromises() { + // eslint-disable-next-line @lwc/lwc/no-async-operation + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe('c-quantic-generated-answer-content', () => { + function cleanup() { + // The jsdom instance is shared across test cases in a single file so reset the DOM + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + jest.clearAllMocks(); + } + + afterEach(() => { + cleanup(); + }); + + describe('text/plain answer content', () => { + it('should render a simple text/plain answer when not streaming', async () => { + const element = createTestComponent({ + ...defaultOptions, + answer: 'Hello, world!', + }); + await flushPromises(); + + const answerContent = element.shadowRoot.querySelector( + SELECTORS.textAnswerContainer + ); + + expect(answerContent.textContent).toBe('Hello, world!'); + expect(answerContent.className).toBe('generated-answer-content__answer'); + }); + + it('should render a text/plain answer when streaming', async () => { + const element = createTestComponent({ + ...defaultOptions, + answer: 'Hello, world!', + isStreaming: true, + }); + await flushPromises(); + + const answerContent = element.shadowRoot.querySelector( + SELECTORS.textAnswerContainer + ); + + expect(answerContent.textContent).toBe('Hello, world!'); + expect(answerContent.className).toContain( + 'generated-answer-content__answer--streaming' + ); + }); + }); + + describe('text/markdown answer content', () => { + it('should render a simple markdown answer when not streaming', async () => { + const element = createTestComponent({ + ...defaultOptions, + answerContentFormat: 'text/markdown', + answer: 'Hello, world!', + }); + await flushPromises(); + + const answerContent = element.shadowRoot.querySelector( + SELECTORS.markdownAnswerContainer + ); + + expect(answerContent.textContent).toBe('Hello, world!'); + expect(answerContent.className).toBe('generated-answer-content__answer'); + }); + + it('should render a simple markdown answer when streaming', async () => { + const element = createTestComponent({ + ...defaultOptions, + answerContentFormat: 'text/markdown', + answer: 'Hello, world!', + isStreaming: true, + }); + await flushPromises(); + + const answerContent = element.shadowRoot.querySelector( + SELECTORS.markdownAnswerContainer + ); + + expect(answerContent.textContent).toBe('Hello, world!'); + expect(answerContent.className).toContain( + 'generated-answer-content__answer--streaming' + ); + }); + }); +}); diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js new file mode 100644 index 00000000000..0e79e5d597d --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js @@ -0,0 +1,116 @@ +import { + transformMarkdownToHtml, + loadMarkdownDependencies, +} from 'c/quanticUtils'; +import {LightningElement, api} from 'lwc'; +// @ts-ignore +import generatedMarkdownContentTemplate from './templates/generatedMarkdownContent.html'; +// @ts-ignore +import generatedTextContentTemplate from './templates/generatedTextContent.html'; + +/** + * The `QuanticGeneratedAnswerContent` component displays the generated answer content. + * @category Internal + * @example + * + */ +export default class QuanticGeneratedAnswerContent extends LightningElement { + /** + * If the answer is streaming, it will render a blinking cursor at the end of the answer. + * @api + * @type {boolean} + */ + @api isStreaming; + /** + * The format of the answer content. Can be either `text/plain` or `text/markdown`. + * When `text/markdown` is selected, the answer content will be converted from markdown to HTML. + * @api + * @type {'text/plain' | 'text/markdown'} + * @default {'text/plain'} + */ + @api answerContentFormat = 'text/plain'; + /** + * The answer content to display. + * @api + * @type {string} + */ + @api + get answer() { + return this._answer; + } + set answer(value) { + this._answer = value; + if ( + this.answerContentFormat === 'text/markdown' && + this._markedLoaded && + this._domPurifyLoaded + ) { + this.updateHtmlContent(); + } + } + + _answer; + _markedLoaded = false; + _domPurifyLoaded = false; + + connectedCallback() { + if (this.answerContentFormat === 'text/markdown') { + loadMarkdownDependencies(this) + .then(() => { + // @ts-ignore + this._markedLoaded = true; + // @ts-ignore + this._domPurifyLoaded = true; + this.updateHtmlContent(); + }) + .catch((error) => { + console.error('Error loading the Marked library.', error); + this._markedLoadingError = true; + }); + } + } + + updateHtmlContent() { + const answerContainer = this.template.querySelector( + '.generated-answer-content__answer' + ); + if (this._markedLoaded && this._domPurifyLoaded) { + // Transform the markdown answer to HTML and update the innerHTML of the container + const newHTMLContent = + // @ts-ignore + (window.marked && + // @ts-ignore + transformMarkdownToHtml(this.answer, window.marked)) || + ''; + if (answerContainer) { + try { + // @ts-ignore + // eslint-disable-next-line @lwc/lwc/no-inner-html + answerContainer.innerHTML = DOMPurify.sanitize(newHTMLContent); + } catch (error) { + // DOMPurify is not compatible with Locker Service, but Locker already sanitizes HTML. + // eslint-disable-next-line @lwc/lwc/no-inner-html + answerContainer.innerHTML = newHTMLContent; + } + } + } + // Fallback to display answer as text if the Marked library failed to load + else { + answerContainer.textContent = this.answer; + } + } + + get generatedAnswerContentClass() { + return `generated-answer-content__answer ${this.isStreaming ? 'generated-answer-content__answer--streaming' : ''}`; + } + + get contentAsText() { + return this._answer; + } + + render() { + return this.answerContentFormat === 'text/markdown' + ? generatedMarkdownContentTemplate + : generatedTextContentTemplate; + } +} diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js-meta.xml b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js-meta.xml new file mode 100644 index 00000000000..6127d855ae7 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/quanticGeneratedAnswerContent.js-meta.xml @@ -0,0 +1,5 @@ + + + 59.0 + false + \ No newline at end of file diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.css b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.css new file mode 100644 index 00000000000..35c5d41bc17 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.css @@ -0,0 +1,150 @@ +:host { + --quantic-genqa-inline-code: var(--quantic-inline-code, var(--lwc-colorTextRequired, #cd2113)); + --quantic-genqa-text-small: var(--quantic-text-small, 0.75rem); + --quantic-genqa-bg-neutral-light: var(--quantic-bg-neutral-light, #f3f3f3); + --quantic-genqa-border-neutral-dim: var(--quantic-border-neutral-dim, var(--lwc-colorBorder, #bfc4c8)); + --quantic-genqa-border-neutral: var(--quantic-border-neutral, var(--lwc-colorBorderButtonDefault, #d6dcdd)); + --quantic-genqa-bg-neutral: var(--quantic-bg-neutral, var(--lwc-colorBackgroundToast, #747474)); + --quantic-genqa-bg-table-header: var(--quantic-bg-table-header, #e5e8e8); + --quantic-genqa-border-radius: var(--quantic-border-radius, var(--lwc-borderRadiusMedium, .25rem)); + --quantic-genqa-text-color-on-background: var(--quantic-text-color-on-background, #181818)); + --quantic-genqa-line-height: var(--quantic-line-height, var(--lwc-lineHeightText, 1.5)); +} + +.generated-answer-content__answer--streaming::after { + content: ''; + width: 0.5rem; + height: 0.9rem; + margin-left: 0.1rem; + background: var(--lwc-brandAccessible, #0176d3); + display: inline-block; + animation: cursor-blink 1.5s steps(2, start) infinite; + vertical-align: baseline; +} + +@keyframes cursor-blink { + 100% { + visibility: hidden; + } +} + +div[data-level='answer-heading-1'] { + font-size: 1.75rem; +} +div[data-level='answer-heading-2'] { + font-size: 1.25rem; +} +div[data-level='answer-heading-3'] { + font-size: 1rem; +} + +div[data-level] { + margin-top: var(--lwc-spacingMedium, 1rem); + margin-bottom: var(--lwc-spacingXSmall, 0.5rem); + font-weight: bold; +} + +li, +p, +blockquote, +th, +td { + line-height: var(--quantic-genqa-line-height); +} + +ol { + list-style-type: decimal; + padding-inline-start: var(--lwc-spacingXLarge, 2rem); + margin-bottom: var(--lwc-spacingXSmall, 0.5rem); +} + +ul { + list-style-type: disc; + padding-inline-start: var(--lwc-spacingXLarge, 2rem); + margin-bottom: var(--lwc-spacingXSmall, 0.5rem); +} + +code { + padding: var(--lwc-spacingXxxSmall, 0.125rem) var(--lwc-spacingXxSmall, 0.25rem); + border: 1px solid; + color: var(--quantic-genqa-inline-code); + font-size: var(--quantic-genqa-text-small); + background-color: var(--quantic-genqa-bg-neutral-light); + border-color: var(--quantic-genqa-border-neutral); + border-radius: var(--quantic-genqa-border-radius); + text-wrap: nowrap; + line-height: 1.5rem; +} + +pre { + padding: var(--lwc-spacingXSmall, 0.5rem); + overflow: auto; + max-height: 24rem; + border: 1px solid; + background-color: var(--quantic-genqa-bg-neutral-light); + border-color: var(--quantic-genqa-border-neutral); + border-radius: var(--quantic-genqa-border-radius); + scrollbar-color: var(--quantic-genqa-bg-neutral); +} + +pre > code { + padding: 0; + border: 0; + background-color: transparent; + color: var(--quantic-genqa-text-color-on-background); +} + +blockquote { + margin-left: var(--lwc-spacingXxLarge, 4rem); + margin-right: var(--lwc-spacingXxLarge, 4rem);; + font-size: italic; +} + +.scrollable-table { + margin-bottom: var(--lwc-spacingLarge, 1.5rem); + display: inline-block; + border: 1px solid; + overflow: auto; + max-height: 24rem; + border-color: var(--quantic-genqa-border-neutral-dim); + border-radius: var(--quantic-genqa-border-radius); +} + +.scrollable-table th { + top: 0px; + position: sticky; +} + +table { + font-size: var(--atomic-text-base); +} + +table thead th { + padding: var(--lwc-spacingMedium, 1rem); + text-align: left; + background-color: var(--quantic-genqa-bg-table-header); + border-bottom: solid 2px var(--quantic-genqa-border-neutral-dim); + border-left: solid 1px var(--quantic-genqa-border-neutral-dim); +} + +table thead th:first-of-type { + border-left: none; +} + +tbody tr:nth-child(even) { + background-color: var(--quantic-genqa-bg-neutral-light); +} + +tbody tr td { + padding: var(--lwc-spacingMedium, 1rem); + border-left: solid 1px var(--quantic-genqa-border-neutral-dim); + border-bottom: solid 1px var(--quantic-genqa-border-neutral-dim); +} + +tbody tr td:first-of-type { + border-left: none; +} + +tbody tr td:last-of-type td { + border-bottom: unset; +} diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.html b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.html new file mode 100644 index 00000000000..ca44d130e06 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedMarkdownContent.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedTextContent.css b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedTextContent.css new file mode 100644 index 00000000000..32b85be8bef --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedTextContent.css @@ -0,0 +1,16 @@ +.generated-answer-content__answer--streaming::after { + content: ''; + width: 0.5rem; + height: 0.9rem; + margin-left: 0.1rem; + background: var(--lwc-brandAccessible, #0176d3); + display: inline-block; + animation: cursor-blink 1.5s steps(2, start) infinite; + vertical-align: baseline; +} + +@keyframes cursor-blink { + 100% { + visibility: hidden; + } +} \ No newline at end of file diff --git a/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedTextContent.html b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedTextContent.html new file mode 100644 index 00000000000..4bcd1abec84 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticGeneratedAnswerContent/templates/generatedTextContent.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/packages/quantic/force-app/main/default/lwc/quanticUtils/__tests__/markdownUtils.test.js b/packages/quantic/force-app/main/default/lwc/quanticUtils/__tests__/markdownUtils.test.js new file mode 100644 index 00000000000..3a161743252 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticUtils/__tests__/markdownUtils.test.js @@ -0,0 +1,63 @@ +// This is the same js file as what we load inside the LWC via a static resource. +import {transformMarkdownToHtml} from 'c/quanticUtils'; +import {marked} from '../../../../../../node_modules/marked/lib/marked.cjs'; + +const removeLineBreaks = (text) => text.replace(/\n/g, ''); + +describe('c/markdownUtils', () => { + describe('transformMarkdownToHtml', () => { + it('should transform markdown text to HTML', () => { + const text = 'Hello, world!'; + const result = transformMarkdownToHtml(text, marked); + expect(removeLineBreaks(result)).toEqual('

Hello, world!

'); + }); + + it('should transform markdown code to HTML
', () => {
+      const text = '```javascript\nconst foo = "bar";\n```';
+      const result = transformMarkdownToHtml(text, marked);
+      expect(removeLineBreaks(result)).toEqual(
+        '
const foo = "bar";
' + ); + }); + + it('should transform markdown heading to HTML
with data-level="answer-heading-${level}"', () => { + const text = '# Hello, world!'; + const result = transformMarkdownToHtml(text, marked); + expect(removeLineBreaks(result)).toEqual( + '
Hello, world!
' + ); + }); + + it('should transform markdown list item to HTML
  • ', () => { + const text = '- Hello, world!'; + const result = transformMarkdownToHtml(text, marked); + expect(removeLineBreaks(result)).toEqual( + '
    • Hello, world!
    ' + ); + }); + + it('should transform markdown table to HTML
    ', () => { + const text = '| Header |\n| ------ |\n| Cell |'; + const result = transformMarkdownToHtml(text, marked); + expect(removeLineBreaks(result)).toEqual( + '
    Header
    Cell
    ' + ); + }); + + it('should complete unclosed inline elements such as bold, italic, and code', () => { + const textBold = '**bold'; + const resultBold = transformMarkdownToHtml(textBold, marked); + expect(removeLineBreaks(resultBold)).toEqual( + '

    bold

    ' + ); + + const textItalic = '*italic'; + const resultItalic = transformMarkdownToHtml(textItalic, marked); + expect(removeLineBreaks(resultItalic)).toEqual('

    italic

    '); + + const textCode = '`code'; + const resultCode = transformMarkdownToHtml(textCode, marked); + expect(removeLineBreaks(resultCode)).toEqual('

    code

    '); + }); + }); +}); diff --git a/packages/quantic/force-app/main/default/lwc/quanticUtils/markdownUtils.js b/packages/quantic/force-app/main/default/lwc/quanticUtils/markdownUtils.js new file mode 100644 index 00000000000..88e87d83f03 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticUtils/markdownUtils.js @@ -0,0 +1,118 @@ +import DOMPURIFY from '@salesforce/resourceUrl/dompurify'; +import MARKED_JS from '@salesforce/resourceUrl/marked'; +// @ts-ignore +import {loadScript} from 'lightning/platformResourceLoader'; + +// Any number of `*` between 1 and 3, or a single backtick, followed by a word character or whitespace character +const unclosedElement = /(\*{1,3}|`)($|\w[\w\s]*$)/; + +/** + * Complete unclosed elements such as bold, italic, and code. + * @param {string} text + * @returns {string} + */ +const completeUnclosedElement = (text) => { + const match = unclosedElement.exec(text); + if (match) { + const symbol = match[1]; + + const replacements = { + '***': '$2', + '**': '$2', + '*': '$2', + '`': '$2', + }; + + return text.replace(unclosedElement, replacements[symbol]); + } + + return text; +}; + +const escapeHtml = (text) => { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +const customRenderer = { + code(code) { + return `
    ${escapeHtml(code)}
    `; + }, + + /** + * Custom Marked renderer to replace heading elements with div elements. + * @param {string} text + * @param {string} level + */ + heading(text, level) { + return `
    ${text}
    `; + }, + + /** + * Returns escaped HTML. + * @param {string} text + * @returns + */ + html(text) { + return escapeHtml(text); + }, + + /** + * Custom Marked renderer to remove wrapping `

    ` element around list item content. + * @param {string} text The element text content. + * @returns {string} The list item element to render. + */ + listitem(text) { + const unwrappedText = text.replace(/^]*>/, '').replace(/<\/p>\n?/, ''); + const withClosedElement = completeUnclosedElement(unwrappedText); + return `

  • ${withClosedElement}
  • `; + }, + + /** + * Custom Marked renderer to wrap `` element in a scrolling container. + * @param {string} header The table header content. + * @param {string} body The table body content. + * @returns {string} The element to render. + */ + table(header, body) { + return `
    ${header}${body}
    `; + }, + + /** + * Custom Marked renderer to complete unclosed inline elements such as bold, italic, and code. + * @param {string} text The text content. + * @returns {string} The corrected text content. + */ + text(text) { + return completeUnclosedElement(text); + }, +}; + +/** + * Transform markdown text to HTML + * @param {string} text + * @param {object} marked The marked library object + * @returns {string} HTML text corresponding to markdown + */ +const transformMarkdownToHtml = (text, marked) => { + marked.use({renderer: customRenderer}); + return marked.parse(text); +}; + +/** + * Load the libraries Marked and DOMPurify. + * @param element + * @returns {Promise} + */ +const loadMarkdownDependencies = (element) => { + return Promise.all([ + loadScript(element, MARKED_JS + '/marked.min.js'), + loadScript(element, DOMPURIFY + '/purify.min.js'), + ]); +}; + +export {transformMarkdownToHtml, loadMarkdownDependencies}; diff --git a/packages/quantic/force-app/main/default/lwc/quanticUtils/quanticUtils.js b/packages/quantic/force-app/main/default/lwc/quanticUtils/quanticUtils.js index f719958f43a..50564f301f0 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticUtils/quanticUtils.js +++ b/packages/quantic/force-app/main/default/lwc/quanticUtils/quanticUtils.js @@ -31,6 +31,8 @@ import pastYear_plural from '@salesforce/label/c.quantic_PastYear_plural'; /** @typedef {import("coveo").Result} Result */ +export * from './markdownUtils'; + export class Debouncer { _timeout; diff --git a/packages/quantic/force-app/main/default/staticresources/dompurify.resource-meta.xml b/packages/quantic/force-app/main/default/staticresources/dompurify.resource-meta.xml new file mode 100644 index 00000000000..8b42614c3f4 --- /dev/null +++ b/packages/quantic/force-app/main/default/staticresources/dompurify.resource-meta.xml @@ -0,0 +1,6 @@ + + + application/zip + DOMPurify is a DOM-only, super-fast, uber-tolerant XSS sanitizer for HTML. + Public + \ No newline at end of file diff --git a/packages/quantic/force-app/main/default/staticresources/marked.resource-meta.xml b/packages/quantic/force-app/main/default/staticresources/marked.resource-meta.xml new file mode 100644 index 00000000000..051e590cee6 --- /dev/null +++ b/packages/quantic/force-app/main/default/staticresources/marked.resource-meta.xml @@ -0,0 +1,6 @@ + + + application/zip + A markdown parser built for speed. + Public + \ No newline at end of file diff --git a/packages/quantic/package.json b/packages/quantic/package.json index 4daef9c8319..7cfa90574a8 100644 --- a/packages/quantic/package.json +++ b/packages/quantic/package.json @@ -46,7 +46,9 @@ }, "dependencies": { "@coveo/bueno": "0.45.9", - "@coveo/headless": "2.68.0" + "@coveo/headless": "2.68.0", + "dompurify": "3.1.5", + "marked": "12.0.2" }, "engines": { "node": ">=14.0.0" diff --git a/packages/quantic/typings/lwc/dompurify.resource.d.ts b/packages/quantic/typings/lwc/dompurify.resource.d.ts new file mode 100644 index 00000000000..f9d158254ed --- /dev/null +++ b/packages/quantic/typings/lwc/dompurify.resource.d.ts @@ -0,0 +1,4 @@ +declare module '@salesforce/resourceUrl/dompurify' { + var DOMPurify: string; + export default DOMPurify; +} \ No newline at end of file diff --git a/packages/quantic/typings/lwc/marked.resource.d.ts b/packages/quantic/typings/lwc/marked.resource.d.ts new file mode 100644 index 00000000000..e816fee1319 --- /dev/null +++ b/packages/quantic/typings/lwc/marked.resource.d.ts @@ -0,0 +1,4 @@ +declare module '@salesforce/resourceUrl/marked' { + var marked: string; + export default marked; +} \ No newline at end of file