diff --git a/packages/chat-component/public/svg/cancel-icon.svg b/packages/chat-component/public/svg/cancel-icon.svg new file mode 100644 index 00000000..a57f7391 --- /dev/null +++ b/packages/chat-component/public/svg/cancel-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/chat-component/src/config/global-config.js b/packages/chat-component/src/config/global-config.js index 8b610110..c7e099fa 100644 --- a/packages/chat-component/src/config/global-config.js +++ b/packages/chat-component/src/config/global-config.js @@ -16,6 +16,7 @@ const globalConfig = { CHAT_MESSAGES: [], // This are the labels for the chat button and input CHAT_BUTTON_LABEL_TEXT: 'Ask Support', + CHAT_CANCEL_BUTTON_LABEL_TEXT: 'Cancel Generation', CHAT_VOICE_BUTTON_LABEL_TEXT: 'Voice input', CHAT_VOICE_REC_BUTTON_LABEL_TEXT: 'Listening to voice input', CHAT_INPUT_PLACEHOLDER: 'Type your question, eg. "How to search and book rentals?"', diff --git a/packages/chat-component/src/core/parser/index.ts b/packages/chat-component/src/core/parser/index.ts index ed825f14..291bd3e4 100644 --- a/packages/chat-component/src/core/parser/index.ts +++ b/packages/chat-component/src/core/parser/index.ts @@ -3,11 +3,15 @@ import { createReader, readStream } from '../stream/index.js'; export async function parseStreamedMessages({ chatThread, apiResponseBody, - visit, + signal, + onChunkRead: onVisit, + onCancel, }: { chatThread: ChatThreadEntry[]; apiResponseBody: ReadableStream | null; - visit: () => void; + signal: AbortSignal; + onChunkRead: () => void; + onCancel: () => void; }) { const reader = createReader(apiResponseBody); const chunks = readStream(reader); @@ -27,6 +31,11 @@ export async function parseStreamedMessages({ }; for await (const chunk of chunks) { + if (signal.aborted) { + onCancel(); + return result; + } + const { content, context } = chunk.choices[0].delta; if (context?.data_points) { result.data_points = context.data_points ?? []; @@ -137,12 +146,9 @@ export async function parseStreamedMessages({ const citations = parseCitations(streamedMessageRaw.join('')); updateCitationsEntry({ citations, chatThread }); - visit(); + onVisit(); } - return { - result, - reader, - }; + return result; } // update the citations entry and wrap the citations in a sup tag diff --git a/packages/chat-component/src/core/stream/index.ts b/packages/chat-component/src/core/stream/index.ts index ade4f461..9798fba4 100644 --- a/packages/chat-component/src/core/stream/index.ts +++ b/packages/chat-component/src/core/stream/index.ts @@ -1,5 +1,5 @@ import { NdJsonParserStream } from './data-format/ndjson.js'; -// import { globalConfig } from '../../config/global-config.js'; +import { globalConfig } from '../../config/global-config.js'; export function createReader(responseBody: ReadableStream | null) { return responseBody?.pipeThrough(new TextDecoderStream()).pipeThrough(new NdJsonParserStream()).getReader(); @@ -14,10 +14,9 @@ export async function* readStream(reader: any): AsyncGenerator { let done: boolean; while ((({ value, done } = await reader.read()), !done)) { yield new Promise((resolve) => { - resolve(value as T); - /* setTimeout(() => { - - }, globalConfig.BOT_TYPING_EFFECT_INTERVAL); */ + setTimeout(() => { + resolve(value as T); + }, globalConfig.BOT_TYPING_EFFECT_INTERVAL); }); } } diff --git a/packages/chat-component/src/main.ts b/packages/chat-component/src/main.ts index 8ae85d52..0f97ee99 100644 --- a/packages/chat-component/src/main.ts +++ b/packages/chat-component/src/main.ts @@ -19,6 +19,7 @@ import iconSuccess from '../public/svg/success-icon.svg?raw'; import iconCopyToClipboard from '../public/svg/copy-icon.svg?raw'; import iconSend from '../public/svg/send-icon.svg?raw'; import iconClose from '../public/svg/close-icon.svg?raw'; +import iconCancel from '../public/svg/cancel-icon.svg?raw'; import iconQuestion from '../public/svg/bubblequestion-icon.svg?raw'; import iconSpinner from '../public/svg/spinner-icon.svg?raw'; import iconMicOff from '../public/svg/mic-icon.svg?raw'; @@ -120,13 +121,16 @@ export class ChatComponent extends LitElement { abortController: AbortController = new AbortController(); // eslint-disable-next-line unicorn/no-abusive-eslint-disable - streamReader: ReadableStreamDefaultReader | null | undefined = null; // eslint-disable-line chatRequestOptions: ChatRequestOptions = requestOptions; chatHttpOptions: ChatHttpOptions = chatHttpOptions; selectedAsideTab: 'tab-thought-process' | 'tab-support-context' | 'tab-citations' = 'tab-thought-process'; + // Is currently processing the response from the API + // This is used to show the cancel button + isProcessingResponse = false; + static override styles = [mainStyle]; // debounce dispatching must-scroll event @@ -161,20 +165,33 @@ export class ChatComponent extends LitElement { }, ]; - const { result, reader } = await parseStreamedMessages({ + this.isProcessingResponse = true; + + const result = await parseStreamedMessages({ chatThread: this.chatThread, + signal: this.abortController.signal, apiResponseBody: (this.apiResponse as Response).body, - visit: () => { + onChunkRead: () => { // NOTE: this function is called whenever we mutate sub-properties of the array + // so we need to trigger a re-render this.requestUpdate('chatThread'); }, - // this will be processing thought process only with streaming enabled + onCancel: () => { + this.isProcessingResponse = false; + // TODO: show a message to the user that the response has been cancelled + }, }); - this.streamReader = reader; + + // this will be processing thought process only with streaming enabled this.chatThoughts = result.thoughts; this.chatDataPoints = result.data_points; this.canShowThoughtProcess = true; + this.isProcessingResponse = false; + // NOTE: for whatever reason, Lit doesn't re-render when we update isProcessingResponse property + // so we need to trigger a re-render manually + this.requestUpdate('isProcessingResponse'); + return true; } @@ -375,6 +392,7 @@ export class ChatComponent extends LitElement { this.hasDefaultPromptsEnabled = true; this.isResponseCopied = false; this.hideThoughtProcess(event); + this.handleUserChatCancel(event); } // Show the default prompts when enabled @@ -403,12 +421,13 @@ export class ChatComponent extends LitElement { } // Stop generation - stopResponseGeneration(): any { - console.log('Stopping response generation'); - this.isStreamCancelled = true; - this.streamReader?.cancel(); + handleUserChatCancel(event: Event): any { + event?.preventDefault(); + this.isProcessingResponse = false; this.abortController.abort(); - return false; + + // we have to reset the abort controller so that we can use it again + this.abortController = new AbortController(); } handleShowThoughtProcess(event: Event): void { @@ -481,7 +500,9 @@ export class ChatComponent extends LitElement { ); } // scroll to the bottom of the chat - this.debounceScrollIntoView(); + if (this.isProcessingResponse) { + this.debounceScrollIntoView(); + } return entries; } @@ -539,6 +560,29 @@ export class ChatComponent extends LitElement { return ''; } + renderChatOrCancelButton() { + const submitChatButton = html``; + const cancelChatButton = html``; + + return this.isProcessingResponse ? cancelChatButton : submitChatButton; + } + // Render the chat component as a web component override render() { return html` @@ -663,9 +707,6 @@ export class ChatComponent extends LitElement { id="chat-form" class="form__container ${this.inputPosition === 'sticky' ? 'form__container-sticky' : ''}" > -
` : ''}
- + ${this.renderChatOrCancelButton()}