diff --git a/packages/chat-component/public/svg/chevron-up-icon.svg b/packages/chat-component/public/svg/chevron-up-icon.svg new file mode 100644 index 00000000..549d247a --- /dev/null +++ b/packages/chat-component/public/svg/chevron-up-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/chat-component/public/svg/history-dismiss-icon.svg b/packages/chat-component/public/svg/history-dismiss-icon.svg new file mode 100644 index 00000000..b0557768 --- /dev/null +++ b/packages/chat-component/public/svg/history-dismiss-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/chat-component/public/svg/history-icon.svg b/packages/chat-component/public/svg/history-icon.svg new file mode 100644 index 00000000..4defb55e --- /dev/null +++ b/packages/chat-component/public/svg/history-icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/chat-component/src/components/chat-component.ts b/packages/chat-component/src/components/chat-component.ts index 7d877696..86d2123f 100644 --- a/packages/chat-component/src/components/chat-component.ts +++ b/packages/chat-component/src/components/chat-component.ts @@ -3,10 +3,17 @@ import { LitElement, html } from 'lit'; import DOMPurify from 'dompurify'; import { customElement, property, query, state } from 'lit/decorators.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -import { chatHttpOptions, globalConfig, teaserListTexts, requestOptions } from '../config/global-config.js'; +import { + chatHttpOptions, + globalConfig, + teaserListTexts, + requestOptions, + MAX_CHAT_HISTORY, +} from '../config/global-config.js'; import { chatStyle } from '../styles/chat-component.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { produce } from 'immer'; +import { chatEntryToString } from '../utils/index.js'; // TODO: allow host applications to customize these icons @@ -15,6 +22,7 @@ import iconDelete from '../../public/svg/delete-icon.svg?raw'; import iconCancel from '../../public/svg/cancel-icon.svg?raw'; import iconSend from '../../public/svg/send-icon.svg?raw'; import iconClose from '../../public/svg/close-icon.svg?raw'; +import iconUp from '../../public/svg/chevron-up-icon.svg?raw'; import './loading-indicator.js'; import './voice-input-button.js'; @@ -26,6 +34,7 @@ import './chat-thread-component.js'; import './chat-action-button.js'; import { type TabContent } from './tab-component.js'; import { ChatController } from './chat-controller.js'; +import { ChatHistoryController } from './chat-history-controller.js'; /** * A chat component that allows the user to ask questions and get answers from an API. @@ -78,6 +87,7 @@ export class ChatComponent extends LitElement { isResetInput = false; private chatController = new ChatController(this); + private chatHistoryController = new ChatHistoryController(this); // Is showing thought process panel @state() @@ -127,6 +137,27 @@ export class ChatComponent extends LitElement { } } + getMessageContext(): Message[] { + if (this.interactionModel === 'ask') { + return []; + } + + const history = [ + ...this.chatThread, + // include the history from the previous session if the user has enabled the chat history + ...(this.chatHistoryController.showChatHistory ? this.chatHistoryController.chatHistory : []), + ]; + + const messages: Message[] = history.map((entry) => { + return { + content: chatEntryToString(entry), + role: entry.isUserMessage ? 'user' : 'assistant', + }; + }); + + return messages; + } + // Handle the click on the chat button and send the question to the API async handleUserChatSubmit(event: Event): Promise { event.preventDefault(); @@ -134,7 +165,6 @@ export class ChatComponent extends LitElement { const question = DOMPurify.sanitize(this.questionInput.value); this.isChatStarted = true; this.isDefaultPromptsEnabled = false; - this.questionInput.value = ''; await this.chatController.generateAnswer( { @@ -145,6 +175,7 @@ export class ChatComponent extends LitElement { }, question, type: this.interactionModel, + messages: this.getMessageContext(), }, { // use defaults @@ -155,6 +186,13 @@ export class ChatComponent extends LitElement { stream: this.useStream, }, ); + + if (this.interactionModel === 'chat') { + this.chatHistoryController.saveChatHistory(this.chatThread); + } + + this.questionInput.value = ''; + this.isResetInput = false; } // Reset the input field and the current question @@ -173,6 +211,8 @@ export class ChatComponent extends LitElement { this.isDefaultPromptsEnabled = true; this.selectedCitation = undefined; this.chatController.reset(); + // clean up the current session content from the history too + this.chatHistoryController.saveChatHistory(this.chatThread); this.collapseAside(event); this.handleUserChatCancel(event); } @@ -309,6 +349,27 @@ export class ChatComponent extends LitElement { } } + renderChatThread(chatThread: ChatThreadEntry[]) { + return html` + `; + } + // Render the chat component as a web component override render() { return html` @@ -318,6 +379,9 @@ export class ChatComponent extends LitElement { ${this.isChatStarted ? html`
+ ${this.interactionModel === 'chat' + ? this.chatHistoryController.renderHistoryButton({ disabled: this.isDisabled }) + : ''}
- - + ${this.chatHistoryController.showChatHistory + ? html`
+ ${this.renderChatThread(this.chatHistoryController.chatHistory)} + +
` + : ''} + ${this.renderChatThread(this.chatThread)} ` : ''} ${this.chatController.isAwaitingResponse diff --git a/packages/chat-component/src/components/chat-history-controller.ts b/packages/chat-component/src/components/chat-history-controller.ts new file mode 100644 index 00000000..6d0ce32a --- /dev/null +++ b/packages/chat-component/src/components/chat-history-controller.ts @@ -0,0 +1,81 @@ +import { type ReactiveController, type ReactiveControllerHost } from 'lit'; +import { html } from 'lit'; +import { globalConfig, MAX_CHAT_HISTORY } from '../config/global-config.js'; + +import iconHistory from '../../public/svg/history-icon.svg?raw'; +import iconHistoryDismiss from '../../public/svg/history-dismiss-icon.svg?raw'; + +import './chat-action-button.js'; + +export class ChatHistoryController implements ReactiveController { + host: ReactiveControllerHost; + static CHATHISTORY_ID = 'component:chat-history'; + + chatHistory: ChatThreadEntry[] = []; + + private _showChatHistory: boolean = false; + + get showChatHistory() { + return this._showChatHistory; + } + + set showChatHistory(value: boolean) { + this._showChatHistory = value; + this.host.requestUpdate(); + } + + constructor(host: ReactiveControllerHost) { + (this.host = host).addController(this); + } + + hostConnected() { + const chatHistory = localStorage.getItem(ChatHistoryController.CHATHISTORY_ID); + if (chatHistory) { + // decode base64 string and then parse it + const history = JSON.parse(atob(chatHistory)); + + // find last 5 user messages indexes + const lastUserMessagesIndexes = history + .map((entry, index) => { + if (entry.isUserMessage) { + return index; + } + }) + .filter((index) => index !== undefined) + .slice(-MAX_CHAT_HISTORY); + + // trim everything before the first user message + const trimmedHistory = lastUserMessagesIndexes.length === 0 ? history : history.slice(lastUserMessagesIndexes[0]); + + this.chatHistory = trimmedHistory; + } + } + + hostDisconnected() { + // no-op + } + + saveChatHistory(currentChat: ChatThreadEntry[]): void { + const newChatHistory = [...this.chatHistory, ...currentChat]; + // encode to base64 string and then save it + localStorage.setItem(ChatHistoryController.CHATHISTORY_ID, btoa(JSON.stringify(newChatHistory))); + } + + handleChatHistoryButtonClick(event: Event) { + event.preventDefault(); + this.showChatHistory = !this.showChatHistory; + } + + renderHistoryButton(options: { disabled: boolean } | undefined) { + return html` + + + `; + } +} diff --git a/packages/chat-component/src/components/chat-thread-component.ts b/packages/chat-component/src/components/chat-thread-component.ts index 3d912d4e..d5a297b5 100644 --- a/packages/chat-component/src/components/chat-thread-component.ts +++ b/packages/chat-component/src/components/chat-thread-component.ts @@ -6,6 +6,7 @@ import { styles } from '../styles/chat-thread-component.js'; import { globalConfig } from '../config/global-config.js'; import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { chatEntryToString } from '../utils/index.js'; import iconSuccess from '../../public/svg/success-icon.svg?raw'; import iconCopyToClipboard from '../../public/svg/copy-icon.svg?raw'; @@ -42,9 +43,7 @@ export class ChatThreadComponent extends LitElement { // Copy response to clipboard copyResponseToClipboard(entry: ChatThreadEntry): void { - const response = entry.text - .map((textEntry) => textEntry.value + '\n\n' + textEntry.followingSteps?.map((s) => ' - ' + s).join('\n')) - .join('\n\n'); + const response = chatEntryToString(entry); navigator.clipboard.writeText(response); this.isResponseCopied = true; @@ -177,19 +176,21 @@ export class ChatThreadComponent extends LitElement {
${unsafeSVG(iconQuestion)}
`; diff --git a/packages/chat-component/src/config/global-config.js b/packages/chat-component/src/config/global-config.js index 32178e4c..017aa5ce 100644 --- a/packages/chat-component/src/config/global-config.js +++ b/packages/chat-component/src/config/global-config.js @@ -34,6 +34,10 @@ const globalConfig = { SUPPORT_CONTEXT_LABEL: 'Support Context', CITATIONS_LABEL: 'Learn More:', CITATIONS_TAB_LABEL: 'Citations', + SHOW_CHAT_HISTORY_LABEL: 'Show Chat History', + HIDE_CHAT_HISTORY_LABEL: 'Hide Chat History', + CHAT_MAX_COUNT_TAG: '{MAX_CHAT_HISTORY}', + CHAT_HISTORY_FOOTER_TEXT: 'Showing past {MAX_CHAT_HISTORY} conversations', }; const teaserListTexts = { @@ -72,6 +76,16 @@ const chatHttpOptions = { stream: true, }; +const MAX_CHAT_HISTORY = 5; + const APPROACH_MODEL = ['rrr', 'rtr']; -export { globalConfig, requestOptions, chatHttpOptions, NEXT_QUESTION_INDICATOR, APPROACH_MODEL, teaserListTexts }; +export { + globalConfig, + requestOptions, + chatHttpOptions, + NEXT_QUESTION_INDICATOR, + APPROACH_MODEL, + teaserListTexts, + MAX_CHAT_HISTORY, +}; diff --git a/packages/chat-component/src/core/http/index.ts b/packages/chat-component/src/core/http/index.ts index cb4b1db4..fe8d8831 100644 --- a/packages/chat-component/src/core/http/index.ts +++ b/packages/chat-component/src/core/http/index.ts @@ -1,7 +1,7 @@ import { ChatResponseError } from '../../utils/index.js'; export async function callHttpApi( - { question, type, approach, overrides }: ChatRequestOptions, + { question, type, approach, overrides, messages }: ChatRequestOptions, { method, url, stream, signal }: ChatHttpOptions, ) { return await fetch(`${url}/${type}`, { @@ -12,6 +12,7 @@ export async function callHttpApi( signal, body: JSON.stringify({ messages: [ + ...(messages ?? []), { content: question, role: 'user', diff --git a/packages/chat-component/src/styles/chat-action-button.ts b/packages/chat-component/src/styles/chat-action-button.ts index 4c3c6e37..12c883fe 100644 --- a/packages/chat-component/src/styles/chat-action-button.ts +++ b/packages/chat-component/src/styles/chat-action-button.ts @@ -50,6 +50,7 @@ export const styles = css` svg { fill: currentColor; width: 20px; + height: 20px; padding: 3px; } button:hover > span, diff --git a/packages/chat-component/src/styles/chat-component.ts b/packages/chat-component/src/styles/chat-component.ts index 28b0f577..c360b22e 100644 --- a/packages/chat-component/src/styles/chat-component.ts +++ b/packages/chat-component/src/styles/chat-component.ts @@ -260,4 +260,18 @@ export const chatStyle = css` border-radius: 25px; padding: 20px; } + .chat-history__footer { + display: flex; + flex-direction: row; + gap: 10px; + justify-content: space-between; + align-self: center; + padding: 20px; + } + .chat-history__container { + display: flex; + flex-direction: column; + border-bottom: 3px solid var(--light-gray); + margin-bottom: 30px; + } `; diff --git a/packages/chat-component/src/types.d.ts b/packages/chat-component/src/types.d.ts index bb9147da..3aa5ceb2 100644 --- a/packages/chat-component/src/types.d.ts +++ b/packages/chat-component/src/types.d.ts @@ -40,6 +40,7 @@ declare interface ChatRequestOptions { overrides: RequestOverrides; type: string; question: string; + messages?: Message[]; } declare interface RequestOverrides { diff --git a/packages/chat-component/src/utils/index.ts b/packages/chat-component/src/utils/index.ts index 1f53bb3a..015add23 100644 --- a/packages/chat-component/src/utils/index.ts +++ b/packages/chat-component/src/utils/index.ts @@ -77,6 +77,15 @@ export function getTimestamp() { }); } +export function chatEntryToString(entry: ChatThreadEntry) { + const message = entry.text + .map((textEntry) => textEntry.value + '\n\n' + textEntry.followingSteps?.map((s, i) => `${i + 1}.` + s).join('\n')) + .join('\n\n') + .replaceAll(/]*>(.*?)<\/sup>/g, ''); // remove the tags from the message + + return message; +} + // Creates a new chat message error export class ChatResponseError extends Error { code?: number; diff --git a/tests/e2e/webapp.spec.ts b/tests/e2e/webapp.spec.ts index c5919814..a5da2b1a 100644 --- a/tests/e2e/webapp.spec.ts +++ b/tests/e2e/webapp.spec.ts @@ -45,6 +45,9 @@ test.describe('default', () => { await expect(userMessage.nth(0)).toHaveText(firstQuestionText); await expect(defaultQuestions).toHaveCount(0); + + // make sure chat history is available for chat interaction mode + await expect(page.getByTestId('chat-history-button')).toBeVisible(); }); // make sure the response is formatted as list items @@ -108,6 +111,9 @@ test.describe('default', () => { // expect some response await expect(page.locator('.chat__txt--entry')).not.toHaveText(''); await expect(defaultQuestions).toHaveCount(0); + + // make sure chat history is not available for ask interaction mode + await expect(page.getByTestId('chat-history-button')).not.toBeVisible(); }); await test.step('Reset chat', async () => { @@ -132,6 +138,67 @@ test.describe('default', () => { await expect(page.getByTestId('loading-indicator')).toBeVisible(); await expect(page.getByTestId('question-input')).not.toBeEnabled(); }); + + test('chat history', async ({ page }) => { + await page.goto('/'); + await page.getByTestId('default-question').nth(0).click(); + + await page.routeFromHAR('./tests/e2e/hars/default-chat-response-stream.har', { + url: '/chat', + update: false, + updateContent: 'embed', + }); + + await page.getByTestId('submit-question-button').click(); + // wait for the thought process button to be enabled. + await expect(page.getByTestId('chat-show-thought-process')).toBeEnabled({ timeout: 30_000 }); + + await test.step('new chat history', async () => { + await expect(page.locator('.chat-history__container')).not.toBeVisible(); + await expect(page.getByTestId('chat-history-button')).toHaveText('Show Chat History'); + + await page.getByTestId('chat-history-button').click(); + + await expect(page.getByTestId('chat-history-button')).toHaveText('Hide Chat History'); + await expect(page.locator('.chat-history__container')).toBeVisible(); + + // no history in the past yet + const chatHistory = page.locator('.chat-history__container .chat__listItem'); + await expect(chatHistory).toHaveCount(0); + }); + + const currentChat = page.locator('.chat__txt--entry').nth(-1); + const lastChatText = await currentChat.textContent(); + + const currentUserMessage = page.locator('.chat__txt.user-message').nth(-1); + const lastChatUserMessageText = await currentUserMessage.textContent(); + + await test.step('chat history after chat', async () => { + // ask another question to get a new thread + await page.goto('/'); + await page.getByTestId('question-input').fill(`testing chat history`); + + await page.getByTestId('submit-question-button').click(); + // wait for the thought process button to be enabled. + await expect(page.getByTestId('chat-show-thought-process')).toBeEnabled({ timeout: 30_000 }); + + await page.getByTestId('chat-history-button').click(); + + // should show the last two last conversation + const chatHistory = page.locator('.chat-history__container .chat__listItem'); + await expect(chatHistory).toHaveCount(2); + + // check that the last session's chat matches in the one in chat history + // which is different from current session's chat + const previousChatUserMessage = chatHistory.nth(0).locator('.chat__txt.user-message').nth(-1); + await expect(currentUserMessage).not.toHaveText(lastChatUserMessageText); + await expect(previousChatUserMessage).toHaveText(lastChatUserMessageText); + + const previousChatLastItem = chatHistory.nth(-1).locator('.chat__txt--entry').nth(-1); + await expect(currentChat).not.toHaveText(lastChatText); + await expect(previousChatLastItem).toHaveText(lastChatText); + }); + }); }); test.describe('errors', () => {