diff --git a/.github/workflows/azure-dev-validation.yaml b/.github/workflows/azure-dev-validation.yaml index 535a7b5e..dadc9d77 100644 --- a/.github/workflows/azure-dev-validation.yaml +++ b/.github/workflows/azure-dev-validation.yaml @@ -1,11 +1,11 @@ name: Validate AZD template on: push: - branches: [main] + branches: [main, develop] paths: - 'infra/**' pull_request: - branches: [main] + branches: [main, develop] paths: - 'infra/**' diff --git a/.github/workflows/azure-dev.yaml b/.github/workflows/azure-dev.yaml index 9fb3dea8..5cfdf0a5 100644 --- a/.github/workflows/azure-dev.yaml +++ b/.github/workflows/azure-dev.yaml @@ -2,11 +2,10 @@ name: Deploy on Azure on: workflow_dispatch: push: - # Run when commits are pushed to mainline branch (main or master) + # Run when commits are pushed to mainline branch (main) # Set this to the mainline branch you are using branches: - main - - master # GitHub Actions workflow to deploy to Azure using azd # To configure required secrets for connecting to Azure, simply run `azd pipeline config` diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 038173c9..8b757040 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -3,9 +3,11 @@ on: push: branches: - main + - develop pull_request: branches: - main + - develop jobs: test: diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index a1d52131..66914d2b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -1,9 +1,9 @@ name: Playwright Tests on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] jobs: test: timeout-minutes: 60 diff --git a/README.md b/README.md index 55e60597..98403dec 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ To then limit access to a specific set of users or groups, you can follow the st ### Additional security considerations -We recommend deploying additional security mechanisms. When applicable, consider setting up a [VNet](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) or setting up a [Proxy Policy](https://learn.microsoft.com/azure/api-management/proxy-policy). +We recommend deploying additional security mechanisms. When applicable, consider setting up a [VNet](https://learn.microsoft.com/azure/virtual-network/virtual-networks-overview) or setting up a [Proxy Policy](https://learn.microsoft.com/en-us/azure/api-management/proxy-policy). ### Enabling CORS for an alternate frontend diff --git a/package-lock.json b/package-lock.json index 436b1d18..1cf28bcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3585,6 +3585,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/dom-speech-recognition": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/dom-speech-recognition/-/dom-speech-recognition-0.0.4.tgz", + "integrity": "sha512-zf2GwV/G6TdaLwpLDcGTIkHnXf8JEf/viMux+khqKQKDa8/8BAUtXXZS563GnvJ4Fg0PBLGAaFf2GekEVSZ6GQ==", + "dev": true + }, "node_modules/@types/dompurify": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.3.tgz", @@ -13553,6 +13559,7 @@ "marked": "^9.1.5" }, "devDependencies": { + "@types/dom-speech-recognition": "^0.0.4", "@types/dompurify": "^3.0.3", "rimraf": "^5.0.1", "typescript": "^5.2.2", diff --git a/packages/chat-component/index.html b/packages/chat-component/index.html index f0c6ab9f..45cf297e 100644 --- a/packages/chat-component/index.html +++ b/packages/chat-component/index.html @@ -43,6 +43,6 @@

Welcome to this Azure OpenAI JavaScript Chat Sample

} } - + diff --git a/packages/chat-component/package.json b/packages/chat-component/package.json index c91e3c53..0242c444 100644 --- a/packages/chat-component/package.json +++ b/packages/chat-component/package.json @@ -27,6 +27,7 @@ "marked": "^9.1.5" }, "devDependencies": { + "@types/dom-speech-recognition": "^0.0.4", "@types/dompurify": "^3.0.3", "rimraf": "^5.0.1", "typescript": "^5.2.2", diff --git a/packages/chat-component/src/components/chat-action-button.ts b/packages/chat-component/src/components/chat-action-button.ts new file mode 100644 index 00000000..9f557517 --- /dev/null +++ b/packages/chat-component/src/components/chat-action-button.ts @@ -0,0 +1,41 @@ +import { LitElement, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { styles } from '../styles/chat-action-button.js'; +import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; + +export interface ChatActionButton { + label: string; + svgIcon: string; + isDisabled: boolean; + id: string; +} + +@customElement('chat-action-button') +export class ChatActionButtonComponent extends LitElement { + static override styles = [styles]; + + @property({ type: String }) + label = ''; + + @property({ type: String }) + svgIcon = ''; + + @property({ type: Boolean }) + isDisabled = false; + + @property({ type: String }) + actionId = ''; + + @property({ type: String }) + tooltip: string | undefined = undefined; + + override render() { + return html` + + `; + } +} diff --git a/packages/chat-component/src/components/chat-component.ts b/packages/chat-component/src/components/chat-component.ts new file mode 100644 index 00000000..ebfc03c4 --- /dev/null +++ b/packages/chat-component/src/components/chat-component.ts @@ -0,0 +1,584 @@ +/* eslint-disable unicorn/template-indent */ +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 { getAPIResponse } from '../core/http/index.js'; +import { parseStreamedMessages } from '../core/parser/index.js'; +import { chatStyle } from '../styles/chat-component.js'; +import { type ChatResponseError, getTimestamp, processText } from '../utils/index.js'; +import { unsafeSVG } from 'lit/directives/unsafe-svg.js'; +// TODO: allow host applications to customize these icons + +import iconLightBulb from '../../public/svg/lightbulb-icon.svg?raw'; +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 './loading-indicator.js'; +import './voice-input-button.js'; +import './teaser-list-component.js'; +import './document-previewer.js'; +import './tab-component.js'; +import './citation-list.js'; +import './chat-thread-component.js'; +import './chat-action-button.js'; +import { type TabContent } from './tab-component.js'; + +/** + * A chat component that allows the user to ask questions and get answers from an API. + * The component also displays default prompts that the user can click on to ask a question. + * The component is built as a custom element that extends LitElement. + * + * Labels and other aspects are configurable via properties that get their values from the global config file. + * @element chat-component + * @fires chat-component#questionSubmitted - Fired when the user submits a question + * @fires chat-component#defaultQuestionClicked - Fired when the user clicks on a default question + * */ + +@customElement('chat-component') +export class ChatComponent extends LitElement { + //-- + // Public attributes + // - + + @property({ type: String, attribute: 'data-input-position' }) + inputPosition = 'sticky'; + + @property({ type: String, attribute: 'data-interaction-model' }) + interactionModel: 'ask' | 'chat' = 'chat'; + + @property({ type: String, attribute: 'data-api-url' }) + apiUrl = chatHttpOptions.url; + + @property({ type: String, attribute: 'data-use-stream', converter: (value) => value?.toLowerCase() === 'true' }) + useStream: boolean = chatHttpOptions.stream; + + @property({ type: String, attribute: 'data-overrides', converter: (value) => JSON.parse(value || '{}') }) + overrides: RequestOverrides = {}; + + //-- + + @property({ type: String }) + currentQuestion = ''; + + @query('#question-input') + questionInput!: HTMLInputElement; + + // Default prompts to display in the chat + @state() + isDisabled = false; + + @state() + isChatStarted = false; + + @state() + isResetInput = false; + + // The program is awaiting response from API + @state() + isAwaitingResponse = false; + + // Show error message to the end-user, if API call fails + @property({ type: Boolean }) + hasAPIError = false; + + // Has the response been copied to the clipboard + @state() + isResponseCopied = false; + + // Is showing thought process panel + @state() + isShowingThoughtProcess = false; + + @state() + canShowThoughtProcess = false; + + @state() + isDefaultPromptsEnabled: boolean = globalConfig.IS_DEFAULT_PROMPTS_ENABLED && !this.isChatStarted; + + @state() + isProcessingResponse = false; + + @state() + selectedCitation: Citation | undefined = undefined; + + selectedAsideTab: 'tab-thought-process' | 'tab-support-context' | 'tab-citations' = 'tab-thought-process'; + + // api response + apiResponse = {} as BotResponse | Response; + // These are the chat bubbles that will be displayed in the chat + chatThread: ChatThreadEntry[] = []; + defaultPrompts: string[] = globalConfig.DEFAULT_PROMPTS; + defaultPromptsHeading: string = globalConfig.DEFAULT_PROMPTS_HEADING; + chatButtonLabelText: string = globalConfig.CHAT_BUTTON_LABEL_TEXT; + chatThoughts: string | null = ''; + chatDataPoints: string[] = []; + + abortController: AbortController = new AbortController(); + + chatRequestOptions: ChatRequestOptions = requestOptions; + chatHttpOptions: ChatHttpOptions = chatHttpOptions; + + static override styles = [chatStyle]; + + // Send the question to the Open AI API and render the answer in the chat + + // Add a message to the chat, when the user or the API sends a message + async processApiResponse({ message, isUserMessage }: { message: string; isUserMessage: boolean }) { + const citations: Citation[] = []; + const followingSteps: string[] = []; + const followupQuestions: string[] = []; + // Get the timestamp for the message + const timestamp = getTimestamp(); + const updateChatWithMessageOrChunk = async (part: string, isChunk: boolean) => { + if (isChunk) { + // we need to prepare an empty instance of the chat message so that we can start populating it + this.chatThread = [ + ...this.chatThread, + { + text: [{ value: '', followingSteps: [] }], + followupQuestions: [], + citations: [], + timestamp: timestamp, + isUserMessage, + }, + ]; + + this.isProcessingResponse = true; + + const result = await parseStreamedMessages({ + chatThread: this.chatThread, + signal: this.abortController.signal, + apiResponseBody: (this.apiResponse as Response).body, + 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'); + }, + onCancel: () => { + this.isProcessingResponse = false; + // TODO: show a message to the user that the response has been cancelled + }, + }); + // 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; + + return true; + } + + this.chatThread = [ + ...this.chatThread, + { + text: [ + { + value: part, + followingSteps, + }, + ], + followupQuestions, + citations: [...new Set(citations)], + timestamp: timestamp, + isUserMessage, + }, + ]; + return true; + }; + + // Check if message is a bot message to process citations and follow-up questions + if (isUserMessage) { + updateChatWithMessageOrChunk(message, false); + } else { + if (this.useStream) { + await updateChatWithMessageOrChunk(message, true); + } else { + // non-streamed response + const processedText = processText(message, [citations, followingSteps, followupQuestions]); + message = processedText.replacedText; + // Push all lists coming from processText to the corresponding arrays + citations.push(...(processedText.arrays[0] as unknown as Citation[])); + followingSteps.push(...(processedText.arrays[1] as string[])); + followupQuestions.push(...(processedText.arrays[2] as string[])); + updateChatWithMessageOrChunk(message, false); + } + } + } + + setQuestionInputValue(value: string): void { + this.questionInput.value = DOMPurify.sanitize(value || ''); + this.currentQuestion = this.questionInput.value; + } + + handleVoiceInput(event: CustomEvent): void { + event?.preventDefault(); + this.setQuestionInputValue(event?.detail?.input); + } + + handleQuestionInputClick(event: CustomEvent): void { + event?.preventDefault(); + this.setQuestionInputValue(event?.detail?.question); + } + + handleCitationClick(event: CustomEvent): void { + event?.preventDefault(); + this.selectedCitation = event?.detail?.citation; + + if (!this.isShowingThoughtProcess) { + this.handleExpandAside(); + this.selectedAsideTab = 'tab-citations'; + } + } + + // Handle the click on the chat button and send the question to the API + async handleUserChatSubmit(event: Event): Promise { + event.preventDefault(); + this.collapseAside(event); + const question = DOMPurify.sanitize(this.questionInput.value); + if (question) { + this.currentQuestion = question; + try { + const type = this.interactionModel; + // Remove default prompts + this.isChatStarted = true; + this.isDefaultPromptsEnabled = false; + // Disable the input field and submit button while waiting for the API response + this.isDisabled = true; + // clear out errors + this.hasAPIError = false; + // Show loading indicator while waiting for the API response + this.isAwaitingResponse = true; + if (type === 'chat') { + this.processApiResponse({ message: question, isUserMessage: true }); + } + + this.apiResponse = await getAPIResponse( + { + ...this.chatRequestOptions, + overrides: { + ...this.chatRequestOptions.overrides, + ...this.overrides, + }, + question, + type, + }, + { + // use defaults + ...this.chatHttpOptions, + + // override if the user has provided different values + url: this.apiUrl, + stream: this.useStream, + }, + ); + + this.questionInput.value = ''; + this.isAwaitingResponse = false; + this.isDisabled = false; + this.isResetInput = false; + const response = this.apiResponse as BotResponse; + // adds thought process support when streaming is disabled + if (!this.useStream) { + this.chatThoughts = response.choices[0].message.context?.thoughts ?? ''; + this.chatDataPoints = response.choices[0].message.context?.data_points ?? []; + this.canShowThoughtProcess = true; + } + await this.processApiResponse({ + message: this.useStream ? '' : response.choices[0].message.content, + isUserMessage: false, + }); + } catch (error_: any) { + console.error(error_); + + const error = error_ as ChatResponseError; + const chatError = { + message: error?.code === 400 ? globalConfig.INVALID_REQUEST_ERROR : globalConfig.API_ERROR_MESSAGE, + }; + + if (this.isProcessingResponse && this.chatThread.at(-1)) { + const processingThread = this.chatThread.at(-1); + if (processingThread) { + processingThread.error = chatError; + } + } else { + this.chatThread = [ + ...this.chatThread, + { + error: chatError, + text: [], + timestamp: getTimestamp(), + isUserMessage: false, + }, + ]; + } + + this.handleAPIError(); + } + } + } + + // Reset the input field and the current question + resetInputField(event: Event): void { + event.preventDefault(); + this.questionInput.value = ''; + this.currentQuestion = ''; + this.isResetInput = false; + } + + // Reset the chat and show the default prompts + resetCurrentChat(event: Event): void { + this.isChatStarted = false; + this.chatThread = []; + this.isDisabled = false; + this.isDefaultPromptsEnabled = true; + this.isResponseCopied = false; + this.selectedCitation = undefined; + this.collapseAside(event); + this.handleUserChatCancel(event); + } + + // Show the default prompts when enabled + showDefaultPrompts(event: Event): void { + if (!this.isDefaultPromptsEnabled) { + this.resetCurrentChat(event); + } + } + + // Handle the change event on the input field + handleOnInputChange(): void { + this.isResetInput = !!this.questionInput.value; + } + + // Handle API error + handleAPIError(): void { + this.hasAPIError = true; + this.isDisabled = false; + this.isAwaitingResponse = false; + this.isProcessingResponse = false; + } + + // Stop generation + handleUserChatCancel(event: Event): any { + event?.preventDefault(); + this.isProcessingResponse = false; + this.abortController.abort(); + + // we have to reset the abort controller so that we can use it again + this.abortController = new AbortController(); + } + + // show thought process aside + handleExpandAside(event: Event | undefined = undefined): void { + event?.preventDefault(); + this.isShowingThoughtProcess = true; + this.selectedAsideTab = 'tab-thought-process'; + this.shadowRoot?.querySelector('#overlay')?.classList.add('active'); + this.shadowRoot?.querySelector('#chat__containerWrapper')?.classList.add('aside-open'); + } + + // hide thought process aside + collapseAside(event: Event): void { + event.preventDefault(); + this.isShowingThoughtProcess = false; + this.shadowRoot?.querySelector('#chat__containerWrapper')?.classList.remove('aside-open'); + this.shadowRoot?.querySelector('#overlay')?.classList.remove('active'); + } + + renderChatOrCancelButton() { + const submitChatButton = html``; + const cancelChatButton = html``; + + return this.isProcessingResponse ? cancelChatButton : submitChatButton; + } + + handleChatEntryActionButtonClick(event: CustomEvent) { + if (event.detail?.id === 'chat-show-thought-process') { + this.handleExpandAside(event); + } + } + + // Render the chat component as a web component + override render() { + return html` +
+
+
+ ${this.isChatStarted + ? html` +
+ + +
+ + + ` + : ''} + ${this.isAwaitingResponse && !this.hasAPIError + ? html`` + : ''} + +
+ + ${this.isDefaultPromptsEnabled + ? html` + + ` + : ''} +
+
+
+
+ + ${this.isResetInput ? '' : html``} +
+ ${this.renderChatOrCancelButton()} + +
+ + ${this.isDefaultPromptsEnabled + ? '' + : html`
+ +
`} +
+
+ ${this.isShowingThoughtProcess + ? html` + + ` + : ''} +
+ `; + } +} diff --git a/packages/chat-component/src/components/chat-thread-component.ts b/packages/chat-component/src/components/chat-thread-component.ts new file mode 100644 index 00000000..37f9ec59 --- /dev/null +++ b/packages/chat-component/src/components/chat-thread-component.ts @@ -0,0 +1,208 @@ +import { LitElement, html } from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; + +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 iconSuccess from '../../public/svg/success-icon.svg?raw'; +import iconCopyToClipboard from '../../public/svg/copy-icon.svg?raw'; +import iconQuestion from '../../public/svg/bubblequestion-icon.svg?raw'; + +import './citation-list.js'; +import './chat-action-button.js'; +import { type ChatActionButton } from './chat-action-button.js'; + +@customElement('chat-thread-component') +export class ChatThreadComponent extends LitElement { + static override styles = [styles]; + + @property({ type: Array }) + chatThread: ChatThreadEntry[] = []; + + @property({ type: Array }) + actionButtons: ChatActionButton[] = []; + + @property({ type: Boolean }) + isDisabled = false; + + @property({ type: Boolean }) + isProcessingResponse = false; + + @state() + isResponseCopied = false; + + @query('#chat-list-footer') + chatFooter!: HTMLElement; + + @property({ type: Object }) + selectedCitation: Citation | undefined = undefined; + + // Copy response to clipboard + copyResponseToClipboard(): void { + const response = this.chatThread.at(-1)?.text.at(-1)?.value as string; + navigator.clipboard.writeText(response); + this.isResponseCopied = true; + } + + actionButtonClicked(actionButton: ChatActionButton, entry: ChatThreadEntry, event: Event) { + event.preventDefault(); + + const actionButtonClickedEvent = new CustomEvent('on-action-button-click', { + detail: { + id: actionButton.id, + chatThreadEntry: entry, + }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(actionButtonClickedEvent); + } + + // debounce dispatching must-scroll event + debounceScrollIntoView(): void { + let timeout: any = 0; + clearTimeout(timeout); + timeout = setTimeout(() => { + if (this.chatFooter) { + this.chatFooter.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 500); + } + + handleFollowupQuestionClick(question: string, event: Event) { + event.preventDefault(); + const citationClickedEvent = new CustomEvent('on-followup-click', { + detail: { + question, + }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(citationClickedEvent); + } + + renderResponseActions(entry: ChatThreadEntry) { + return html` +
+ ${this.actionButtons.map( + (actionButton) => html` + + `, + )} + +
+ `; + } + + renderTextEntry(textEntry: ChatMessageText) { + const entries = [html`

${unsafeHTML(textEntry.value)}

`]; + + // render steps + if (textEntry.followingSteps && textEntry.followingSteps.length > 0) { + entries.push(html` +
    + ${textEntry.followingSteps.map( + (followingStep) => html`
  1. ${unsafeHTML(followingStep)}
  2. `, + )} +
+ `); + } + if (this.isProcessingResponse) { + this.debounceScrollIntoView(); + } + return html`
${entries}
`; + } + + renderCitation(citations: Citation[] | undefined) { + if (citations && citations.length > 0) { + return html` +
+ +
+ `; + } + + return ''; + } + + renderFollowupQuestions(followupQuestions: string[] | undefined) { + // render followup questions + // need to fix first after decoupling of teaserlist + if (followupQuestions && followupQuestions.length > 0) { + return html` +
+ ${unsafeSVG(iconQuestion)} + +
+ `; + } + + return ''; + } + + renderError(error: { message: string }) { + return html`

${error.message}

`; + } + + override render() { + return html` + + + `; + } +} diff --git a/packages/chat-component/src/components/citation-list.ts b/packages/chat-component/src/components/citation-list.ts new file mode 100644 index 00000000..6bf1637e --- /dev/null +++ b/packages/chat-component/src/components/citation-list.ts @@ -0,0 +1,66 @@ +import { LitElement, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; + +import { styles } from '../styles/citation-list.js'; + +@customElement('citation-list') +export class CitationListComponent extends LitElement { + static override styles = [styles]; + + @property({ type: String }) + label: string | undefined = undefined; + + @property({ type: Array }) + citations: Citation[] | undefined = undefined; + + @property({ type: Object }) + selectedCitation: Citation | undefined = undefined; + + handleCitationClick(citation: Citation, event: Event) { + event.preventDefault(); + this.selectedCitation = citation; + const citationClickEvent = new CustomEvent('on-citation-click', { + detail: { + citation, + }, + bubbles: true, + composed: true, + }); + this.dispatchEvent(citationClickEvent); + } + + compareCitation(citationA, citationB) { + if (citationA && citationB && citationA.text === citationB.text) { + return true; + } + return false; + } + renderCitation(citations: Citation[] | undefined) { + // render citations + if (citations && citations.length > 0) { + return html` +
    + ${this.label ? html`

    ${this.label}

    ` : ''} + ${citations.map( + (citation) => html` +
  1. + ${citation.ref}. ${citation.text} +
  2. + `, + )} +
+ `; + } + return ''; + } + + override render() { + return this.renderCitation(this.citations); + } +} diff --git a/packages/chat-component/src/components/document-previewer.ts b/packages/chat-component/src/components/document-previewer.ts new file mode 100644 index 00000000..5eeeae1d --- /dev/null +++ b/packages/chat-component/src/components/document-previewer.ts @@ -0,0 +1,63 @@ +import { LitElement, type PropertyValueMap, html } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { globalConfig } from '../config/global-config.js'; +import { marked } from 'marked'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import './loading-indicator.js'; + +@customElement('document-previewer') +export class DocumentPreviewerComponent extends LitElement { + @property({ type: String }) + url: string | undefined = undefined; + + @state() + previewContent: string | undefined = undefined; + + @state() + loading: boolean = false; + + retrieveMarkdown() { + if (this.url) { + fetch(this.url) + .then((response) => response.text()) + .then((text) => { + this.previewContent = marked.parse(text); + }) + .finally(() => { + this.loading = false; + }); + } + } + + override willUpdate(_changedProperties: PropertyValueMap | Map): void { + if ( + this.url && + _changedProperties.has('url') && + _changedProperties.get('url') !== this.url && + this.url.endsWith('.md') + ) { + this.loading = true; + this.retrieveMarkdown(); + } + } + + renderContent() { + if (this.url) { + return html` + ${this.previewContent + ? html` ${unsafeHTML(this.previewContent)}` + : html`