/* eslint-disable @typescript-eslint/no-inferrable-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ import { Message } from '@aws-sdk/client-bedrock-runtime/dist-types/models'; import { LitElement, html, css } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { repeat } from 'lit/directives/repeat.js'; import { when } from 'lit/directives/when.js'; import { ModelClient } from './model-client'; import { AgentClient } from './agent-client'; import { userIcon, assistantIcon } from './assetPaths.js'; import DOMPurify from 'dompurify'; import { marked } from "marked"; import { awsCredentialsForAnonymousUser, awsCredentialsForAuthCognitoUser } from "./authentication"; export const defaultOptions = { bedrock: { modelId: "anthropic.claude-3-sonnet-20240229-v1:0", // inferenceConfig: { // maxTokens: 1024, // temperature: 0.5, // topP: 0.9, // } } } function deepMerge(target, source) { for (const key of Object.keys(source)) { if (source[key] instanceof Object && key in target) { Object.assign(source[key], deepMerge(target[key], source[key])); } } return { ...target, ...source }; } /** * An example element. * * @fires count-changed - Indicates when the count changes * @slot - This element has a slot * @csspart button - The button */ @customElement('br-chat') export class MyElement extends LitElement { @property({ type: Object, // converter: { // fromAttribute: (value: string) => { // try { // const parsedValue = JSON.parse(value); // // Use deep merge to combine default options with parsed value // return deepMerge(defaultOptions, parsedValue); // } catch (e) { // console.warn('Invalid JSON string:', value, e); // return defaultOptions; // Return default options if parsing fails // } // } // }, }) // config: any = defaultOptions; config: any = {}; // TODO: Check if prompt as a property has an impact on performances @property() prompt: string = ''; @property({ type: Array }) attachedFiles: any[] = []; @property({ type: Array }) messages: Message[] = []; // @state() protected isLoading: boolean = false; @query('.prompt form textarea') protected promptDOMElement!: HTMLTextAreaElement; private _bedrockClient: AgentClient | ModelClient | undefined; protected reunderWebExperience() { console.log("renderWebExperience"); return html`
${this.config.ui.webExperience.title}
${this.config.ui.webExperience.subtitle}
${this.renderMessage({ role: "assistant", content: [{ text: this.config.ui.webExperience.welcomeMessage }] })}
`; } protected renderMessageIcon(role: string) { console.log("renderMessageIcon"); if (role === 'assistant') { if (this.config.ui?.icons?.assistant) { return html`` } return html`${assistantIcon}`; } else if (role === 'user') { if (this.config.ui?.icons?.user) return html`` return html`${userIcon}`; } } protected renderMessage(message: Message) { console.log("renderMessage"); const htmlContent = marked.parse(message.content[0].text); const sanitizedHtml = DOMPurify.sanitize(htmlContent); return html`
${this.renderMessageIcon(message.role)}
${unsafeHTML(sanitizedHtml)}
${when(message.content.length > 1, () => html`
${repeat(message.content, (file, index) => this.renderFile(file, index))}
`)}
`; } protected removeFile(event: any) { const clickedElementIndex: number = parseInt((event.target as Element).getAttribute('data-index')!, 10); this.attachedFiles.splice(clickedElementIndex, 1); this.attachedFiles = [...this.attachedFiles]; } protected getImage(file: any) { console.log("getImage"); const blob = new Blob([file.image.source.bytes], { type: file.image.mimeType }); const thumbnail = URL.createObjectURL(blob); return thumbnail; } protected renderFile(file: any, index: number, withDeleteButton: boolean = false) { console.log("renderFile"); if (file.image) { return html`
${when(withDeleteButton, () => html` `)}
`; } else if (file.document) { return html`
${file.document.format}
${file.document.fileName}
${when(withDeleteButton, () => html` `)}
`; } } protected renderPromptInput() { console.log("renderPromptInput"); return html`
${when(this.config.attachFilesToPrompt, () => html` `)}
${when(this.attachedFiles.length > 0, () => html`
${repeat(this.attachedFiles, (file, index) => this.renderFile(file, index, true))}
`)}
`; } override render() { console.log("render"); return html`
${when(this.messages.length > 0, () => html`
${repeat(this.messages, (message) => this.renderMessage(message))}
`)} ${when(this.messages.length <= 0 && this.config.ui?.webExperience, () => html`${this.reunderWebExperience()}`)} ${this.renderPromptInput()}
`; } async handlePromptInput(event: Event) { const target = event.target as HTMLTextAreaElement; this.prompt = target.value; this.adjustTextareaHeight(target); } async onSendPromptClicked() { if (this.isLoading) { return; } if (this.prompt) { await this.sendMessage(); this.prompt = ''; setTimeout(() => { this.adjustTextareaHeight(this.promptDOMElement); }, 0); this.attachedFiles = []; } } private getAttachedFileType(file) { const imageExtensions = ['image/gif', 'image/jpeg', 'image/png', 'image/webp']; if (imageExtensions.includes(file.type.toLowerCase())) { return 'image'; } return 'document' } private notifyMessagesUpdated() { console.log("notifyMessagesUpdated"); const event = new CustomEvent('brc-messages-updated', { detail: { messages: this.messages }, bubbles: true, composed: true }); this.dispatchEvent(event); } async attachfile(event: Event) { const files = (event.target as HTMLInputElement).files; // this.attachedFiles.push(...Object.values(files)); const newFiles = []; for (const file of files) { newFiles.push({ [this.getAttachedFileType(file)]: { // format: file.type.split('/')[1], format: file.name.split('.').pop().toLowerCase(), mimeType: file.type, name: file.name.split('.')[0], fileName: file.name, source: { bytes: new Uint8Array(await file.arrayBuffer()) } } }) } // this.attachedFiles = [...this.attachedFiles, ...Object.values(files)]; this.attachedFiles = [...this.attachedFiles, ...newFiles]; console.log(this.attachedFiles); } async onPromptKeyDown(event: KeyboardEvent) { if (event.key === 'Enter') { if (event.shiftKey) { return; } else { event.preventDefault(); this.onSendPromptClicked(); } } } override updated(changedProperties: Map) { if (changedProperties.has('config')) { this.init(); // this.onMessagesChanged(); } } private async init() { const errors = []; if (!this.config.auth) { errors.push("Authentication missing"); } if (!this.config.bedrock || !this.config.bedrock?.modelId) { errors.push("Bedrock information missing"); } if (errors.length > 0) { for (const error of errors) { console.error(error); } return; } let credentials; try { if (this.config.auth.anonymous) { credentials = await awsCredentialsForAnonymousUser(this.config.auth); } else if (this.config.auth.cognito) { credentials = await awsCredentialsForAuthCognitoUser(this.config.auth); } else { throw new Error("There is an error with your credentials. Check if you put a valid role"); } if (this.config.bedrock?.agent) { this._bedrockClient = new AgentClient(this.config, credentials); } else if (this.config.bedrock.modelId) { this._bedrockClient = new ModelClient(this.config, credentials); } } catch (error) { console.error(error); } } addMessage(message: Message) { this.messages = [...this.messages, message]; this.notifyMessagesUpdated(); } private adjustTextareaHeight(textarea: HTMLTextAreaElement) { textarea.style.height = '0px'; textarea.style.height = `${textarea.scrollHeight}px`; } private async sendMessage() { this.isLoading = true; const message: Message = { role: "user", content: [{ text: this.prompt }] }; if (this.attachedFiles && this.attachedFiles.length > 0) { for (const file of this.attachedFiles) { message.content.push(file); } } this.addMessage(message); try { const completionStreamIterator = this._bedrockClient?.sendMessage(this.messages); const { messages } = this; const responseMessage: Message = { role: "assistant", content: [{ text: '' }] }; for await (const chunk of completionStreamIterator) { responseMessage.content[0].text += chunk; this.messages = [...messages, responseMessage]; } this.notifyMessagesUpdated(); } catch (err) { console.error(err); } finally { this.isLoading = false; } } static override styles = css` :host { --primary: var(--brc-primary, #141f2e); --bg: var(--brc-bg, #fcfcfd); --text-color: var(--brc-text-color, #000); --text-invert-color: var(--brc--text-invert-color, #fff); /* --submit-button-color: var(--brc-submit-button-color, var(--primary)); */ --submit-button-border: var(--brc-submit-button-border, none); --submit-button-bg: var(--brc-submit-button-bg, none); --submit-button-bg-hover: var(--brc-submit-button-bg-hover, #f0f0f0); --prompt-input-bg-color: var(--brc-prompt-input-bg-color, #fff); --prompt-input-text-color: var(--brc-prompt-input-text-color, var(--text-color)); --prompt-input-border-color: var(--brc-prompt-input-border-color, #53b1fd); /* --input-font-size: var(--brc-input-font-size, 16px); */ /* --user-chat-bg-color: var(--brc-user-chat-bg-color, #f0f0f0); --user-chat-border-color: var(--brc-user-chat-border-color, #f0f0f0); */ --user-chat-text-color: var(--brc-user-chat-text-color, var(--text-color)); --assistant-chat-bg-color: var(--brc-assistant-chat-bg-color, #fff); --assistant-chat-border-color: var(--brc-assistant-chat-border-color, #d0d5dd); --assistant-chat-text-color: var(--brc-assistant-chat-text-color, var(--text-color)); } .file-icon { position: relative; background-color: white; border-radius: 5px; overflow: hidden; } .file-header { text-align: center; background-color: #e74c3c; } .file-type { color: white; font-weight: bold; text-transform: uppercase; } .file-body { text-align: center; word-wrap: break-word; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; font-size: .8rem; padding: 5px; } .chat-container { width: 100%; height: 100%; display: flex; flex-direction: column; background-color: var(--bg); position: relative; text-align: left; justify-content: end; & .attached-files { display: flex; flex-direction: row; /* flex-wrap: wrap; */ gap: 8px; overflow: auto; .file { padding: 15px 10px; position: relative; /* &:first-child { padding-left: 0px; } */ .thumbnail { height: 85px; width: auto; border-radius: 8px; display: flex; flex-direction: column; box-shadow: 0px 0px 2px rgba(0, 0, 0, .5); &:not(img) { /* border: solid 1px var(--primary); */ aspect-ratio: 1; overflow: hidden; position: relative; } & .filename { display: flex; flex-direction: row; justify-content: center; align-items: center; text-align: center; text-overflow: ellipsis; box-sizing: border-box; font-size: .8rem; padding: 5px; flex-grow: 1; overflow: hidden; background-color: #fff; div { text-overflow: ellipsis; overflow: hidden; height: 100%; } } & .filetype { /* padding: 5px; */ color: var(--text-color); background-color: var(--bg); text-transform: uppercase; width: 100%; font-size: .7rem; text-align: center; box-sizing: border-box; border-top: solid 1px var(--primary); div { text-align: center; } } } button { position: absolute; top: 0; right: 0; padding: 0; transition: transform .1s; width: 24px; height: 24px; border: none; border: solid 2px #fff; background-color: #d83b3b; color: #fff; border-radius: 50%; cursor: pointer; display: flex; flex-direction: row; align-items: center; justify-content: center; &:hover { transform: scale(1.1); } } } } .web-experience { /* position: absolute; */ height: 90%; width: 100%; display: flex; flex-direction: column; justify-content: center; padding: 4em 0; & .header { text-align: center; & .title { font-size: 36px; font-weight: 700; line-height: 64px; color: #383c43; } & .subtitle { color: #667085; font-size: 18px; font-weight: 600; padding-bottom: 32px; } } } pre { font-family: inherit; font-size: inherit; margin: 0; white-space: inherit; } & .messages { display: flex; flex-direction: column; flex-grow: 1; gap: 24px; overflow-x: hidden; overflow-y: auto; width: 100%; padding-bottom: 4px; padding-top: 12px; & .message { display: flex; flex-direction: row; gap: 17px; margin: 0; padding: 0 10px; height: auto; max-width: 100%; & .avatar { margin-bottom: auto; overflow: clip; width: 48px; height: auto; } & .body-response { display: flex; flex: 1; flex-direction: column; gap: 8px; margin-left: 6px; & .text { display: flex; flex: 1; flex-direction: column; word-break: break-word; font-style: normal; p { margin: 0 0 8px; } } } } } & .user { align-items: flex-start; font-size: 16px; font-weight: 500; gap: 17px; line-height: 160%; margin-bottom: 32px; color: var(--user-chat-text-color); & .body-response { & .text { margin: 10px 0 0; } } } & .assistant { margin: 0; margin-bottom: 24px; & .body-response { background-color: var(--assistant-chat-bg-color); border: 0.5px solid var(--assistant-chat-border-color); color: var(--assistant-chat-text-color); border-radius: 12px; /* box-shadow: 0 1px 2px 0 rgba(16, 24, 40, .06), 0 1px 3px 0 rgba(16, 24, 40, .1); */ & .text { padding: 24px; } } } & .prompt-container { align-self: stretch; display: flex; flex-direction: row-reverse; font-family: inherit; font-weight: 400; gap: 17px; justify-content: center; line-height: 160%; margin: 0 auto; padding-top: 12px; width: 100%; & .prompt { display: flex; flex-direction: column; width: 100%; & form { align-items: center; background-color: var(--prompt-input-bg-color); border: solid 1px var(--prompt-input-border-color); /* border-radius: 8px; */ box-sizing: border-box; display: flex; flex-direction: row; gap: 8px; height: auto; margin: 0; max-height: 350px; min-height: 48px; padding: 6px 6px 6px 12px; color: var(--prompt-input-text-color); & textarea { align-items: flex-start; border: none; display: flex; flex-direction: column; flex-grow: 1; font-family: inherit; font-size: var(--input-font-size); font-weight: 400; order: 1; outline: none; overflow-y: auto; padding: 0; resize: none; width: 100%; line-height: 1.5em; min-height: 1.5em; max-height: 4.5em; height: 0px; white-space: pre-wrap; background-color: inherit; color: var(--prompt-input-text-color); } & textarea:placeholder-shown { font-style: italic; } input[type=file] { display: none; } a { cursor: pointer; } & .button { align-items: baseline; background-color: transparent; border: transparent; display: flex; gap: 16px; justify-content: flex-end; margin: auto 0 0; order: 2; padding: 0; & button { background-color: var(--submit-button-bg); border: solid 1px var(--submit-button-border); color: var(--submit-button-color); border-radius: 4px; cursor: pointer; transition: background-color 100ms linear; align-items: center; border-radius: 4px; box-sizing: border-box; display: flex; flex-direction: row; font-family: inherit; font-size: 16px; font-style: normal; font-weight: 500; gap: 8px; line-height: 157%; padding: 0 5px 0 0; text-decoration: none; &:hover { background-color: var(--submit-button-bg-hover); } } } } form:has(textarea:focus) { border: 1px solid var(--gray-450, #6b727e); } } } } `; } declare global { interface HTMLElementTagNameMap { 'br-chat': MyElement; } } function internalProperty(arg0: {}): (target: MyElement, propertyKey: "prompt") => void { throw new Error('Function not implemented.'); }