/*!
* Copyright (c) 2024, Oracle and/or its affiliates.
*/
/**
* @file
*
* Main view class containing all the visuals of the chat. It can be rendered either
* as a dialog or as an inline view.
*/
import { Chat, events as chatEvents } from './Chat.mjs';
import { EventDispatcher } from './EventDispatcher.mjs';
import { MessageView, events as messageViewEvents } from './MessageView.mjs';
import { messageTypes } from './Message.mjs';
import { getUniqueId } from './util.mjs';
// dependencies
const { jQuery: $, lang, util } = apex;
const
CLS_MAIN = 'a-ChatClient',
CLS_TRANSCRIPT = 'a-ChatTranscript',
CLS_TRANSCRIPT_DISCLAIMER = 'a-ChatTranscript-disclaimer',
CLS_TRANSCRIPT_LOG = 'a-ChatTranscript-log',
CLS_MESSAGES = 'a-ChatItems',
CLS_QUICK_PICKS = 'a-ChatActions-quickPicks',
CLS_INPUT_CONTAINER = 'a-ChatInputContainer',
CLS_TEXTAREA_CONTAINER = 'a-ChatInput',
CLS_TEXTAREA_WRAPPER = 'a-ChatInput-textWrap',
CLS_TEXTAREA_RESIZER = 'a-ChatInput-textPreview',
CLS_TEXTAREA = 'a-ChatInput-text',
CLS_DIALOG = 'ui-dialog--chat-client',
CLS_SEND_BTN = 'a-ChatInput-button',
CLS_SEND_BTN_ICON = 'a-ChatInput-icon';
const renderModes = {
INLINE: 'inline',
DIALOG: 'dialog',
NONE: 'none' // default
};
const
MSG_TEXTAREA_LABEL = lang.getMessage('APEX.AI.TEXTAREA_LABEL'),
MSG_DIALOG_TITLE = lang.getMessage('APEX.AI.DIALOG_TITLE'),
MSG_CONVERSATION_HISTORY = lang.getMessage('APEX.AI.CONVERSATION_HISTORY'),
MSG_TEXTAREA_PLACEHOLDER = lang.getMessage('APEX.AI.TEXTAREA_PLACEHOLDER'),
MSG_WARN_UNSENT_MESSAGE = lang.getMessage('APEX.AI.WARN_UNSENT_MESSAGE'),
MSG_SEND_MESSAGE = lang.getMessage('APEX.AI.SEND_MESSAGE'),
MSG_QUICK_ACTIONS_ARIA_LABEL = lang.getMessage('APEX.AI.MAIN_QUICK_ACTIONS_ARIA_LABEL');
const
DIALOG_DEFAULT_WIDTH = 500,
DIALOG_DEFAULT_HEIGHT = 600;
const baseHtml = `
`;
let id = 0;
const events = {
MESSAGE_VIEW_RENDER: 'messageviewrender',
MESSAGE_QUICK_ACTION_TRIGGER: 'messageviewquickactiontrigger',
MESSAGE_CODE_BLOCK_QUICK_ACTION_TRIGGER: 'messagecodeblockquickactiontrigger',
QUICK_ACTION_TRIGGER: 'quickactiontrigger',
HIDE: 'hide',
DIALOG_CLOSE: 'dialogclose',
DESTROY: 'destroy'
};
class ChatView extends EventDispatcher {
#model;
#options;
#id;
#chatContainer$;
#el;
#transcriptEl;
#messageListEl;
#textAreaContainerEl;
#textareaEl;
#textareaResizerEl;
#quickActionsEl;
#quickActions = new Map();
#messageViews = [];
#sendBtnEl;
#chatLocked = false;
#apiInitiated = false;
#hidden = true;
#destroyed = false;
#scrolledToBottom = true;
// Native events - handlers
#onTextareaInput = () => {
this.#setSendButtonDisabledAttribute();
this.#updateTextareaResizerText();
};
#onTextareaKeyDown = e => {
const { which, shiftKey } = e;
// on enter click
if (which === 13 && !shiftKey) {
e.preventDefault();
this.#maybeSubmitTextareaMessage();
}
};
#onSendBtnClick = () => {
this.#maybeSubmitTextareaMessage();
};
#onQuickActionClick = e => {
if (this.#model.isLocked()) {
return;
}
const pick = this.#quickActions.get(e.currentTarget);
const { message } = pick;
if (message) {
this.#model.addUserMessage(message, messageTypes.TEXT);
}
this.removeQuickActions();
this.#textareaEl.focus();
this.triggerEvent(events.QUICK_ACTION_TRIGGER, pick);
};
#onTranscriptScroll = e => {
const { target: el } = e;
this.#scrolledToBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 1;
};
constructor(model, options) {
super();
// eslint-disable-next-line
this.#id = `main-view-${id++}`;
this.#options = $.extend({
user: null,
el: null,
focus: true,
disclaimer: null,
highlightCodeFn: null,
mode: renderModes.DIALOG,
title: MSG_DIALOG_TITLE,
placeholder: MSG_TEXTAREA_PLACEHOLDER,
width: DIALOG_DEFAULT_WIDTH, // applies only to dialog
height: DIALOG_DEFAULT_HEIGHT // applies only to dialog
}, options);
if (model instanceof Chat) {
this.#model = model;
}
else {
throw new Error('A valid Chat instance must be provided');
}
this.#build();
}
findMessageViewByModel(model) {
return this.#messageViews.find(view => view.model === model);
}
show(focus = true) {
if (!this.#hidden) {
return;
}
const { mode } = this.#options;
if (mode === renderModes.DIALOG) {
this.#chatContainer$.dialog('open');
}
else {
$(this.#el).show();
}
if (focus) {
this.#textareaEl.focus();
}
this.#hidden = false;
}
hide() {
if (this.#hidden) {
return;
}
this.#hidden = true;
const { mode } = this.#options;
if (mode === renderModes.DIALOG) {
this.#apiInitiated = true;
this.#chatContainer$.dialog('close');
this.#apiInitiated = false;
this.triggerEvent(events.DIALOG_CLOSE);
}
else {
$(this.#el).hide();
}
this.triggerEvent(events.HIDE);
}
// syntactic sugar for hide(), to be used by dialog views
close() {
this.hide();
}
destroy(clearOwnListeners = true) {
if (this.#destroyed) {
return;
}
// needs to be before close() in case somebody calls destroy()
// on the DIALOG_CLOSE event
this.#destroyed = true;
const { mode } = this.#options;
this.close();
const chat = this.#model;
this.#messageViews.forEach(msgView => {
msgView.destroy();
// can be off-ed completely as there is no other instance using them
msgView.off();
});
// remove only the listeners for 'this', scope
chat.offAllScoped(this);
if (clearOwnListeners) {
this.off();
}
if (this.#quickActions.size) {
this.#quickActions.forEach((value, key) => {
key.removeEventListener('click', this.#onQuickActionClick);
});
}
this.#textareaEl.removeEventListener('input', this.#onTextareaInput);
this.#textareaEl.removeEventListener('keydown', this.#onTextareaKeyDown);
this.#sendBtnEl.removeEventListener('click', this.#onSendBtnClick);
this.#transcriptEl.removeEventListener('scroll', this.#onTranscriptScroll);
if (mode === renderModes.DIALOG) {
this.#chatContainer$
.dialog('destroy')
.remove();
}
else {
this.#el.remove();
}
this.triggerEvent(events.DESTROY);
}
isDestroyed() {
return this.#destroyed;
}
get destroyed() {
return this.#destroyed;
}
hasUncommittedInput() {
return !this.#destroyed &&
!this.#hidden &&
this.#textareaEl.value !== '';
}
get id() {
return this.#id;
}
#build() {
let chatContainer$;
let el;
let sendBtnEl;
const model = this.#model;
const {
quickActions,
mode,
el: renderToEl,
title,
focus,
width,
height,
disclaimer
} = this.#options;
if (mode === renderModes.INLINE) {
chatContainer$ = $(renderToEl);
}
else if (mode === renderModes.DIALOG) {
chatContainer$ = apex.message.showDialog('', {
// disable various functionalities
okButton: false,
confirm: false,
alert: false,
//
height,
width,
minHeight: 400,
minWidth: 300,
//
autoOpen: false,
dialogClass: CLS_DIALOG,
// providing an ID, so the dialog won't get removed on close, only hidden
// this is a quirk of message.showDialog
id: getUniqueId(),
isPopup: false,
noOverlay: false,
draggable: true,
resizable: true,
title,
unsafe: false,
takeFocus: true,
beforeClose: () => {
// something triggered the close event. it's important to know what
if (this.#apiInitiated) {
//noting to do. close gracefully
return;
}
else {
// if the user initiated the close and the textfield has text, show a prompt
if (this.hasUncommittedInput()) {
apex.message.confirm(MSG_WARN_UNSENT_MESSAGE, okPressed => {
if (okPressed) {
// we'll end up again in beforeClose but this time as apiInitiated
this.close();
}
});
return false;
} else {
// go through the api so it sets isHidden, triggers events, etc
// we'll end up again in beforeClose but this time as apiInitiated
this.close();
return false;
}
}
}
});
}
else {
throw new Error('Invalid option for mode');
}
el = document.createRange().createContextualFragment(baseHtml).firstElementChild;
chatContainer$[0].append(el);
sendBtnEl = el.querySelector(`.${CLS_SEND_BTN}`);
sendBtnEl.disabled = true;
// expose for other methods
this.#chatContainer$ = chatContainer$;
this.#el = el;
this.#sendBtnEl = sendBtnEl;
this.#transcriptEl = el.querySelector(`.${CLS_TRANSCRIPT}`);
this.#messageListEl = el.querySelector(`.${CLS_MESSAGES}`);
this.#textAreaContainerEl = el.querySelector(`.${CLS_INPUT_CONTAINER}`);
this.#textareaEl = el.querySelector(`.${CLS_TEXTAREA}`);
this.#textareaResizerEl = el.querySelector(`.${CLS_TEXTAREA_RESIZER}`);
if (disclaimer) {
const disclaimerEl = document.createElement('div');
disclaimerEl.classList.add(CLS_TRANSCRIPT_DISCLAIMER);
disclaimerEl.appendChild(document.createTextNode(disclaimer));
this.#transcriptEl.prepend(disclaimerEl);
}
this.#setupTextarea();
this.#setupListeners();
if (Array.isArray(quickActions) && quickActions.length) {
this.createQuickActions(quickActions);
}
model.getHistory().messages.forEach(message => this.#maybeRenderMessage(message));
if (model.isLocked()) {
this.#onChatLock();
}
this.show(focus);
}
createQuickActions(arr = []) {
this.removeQuickActions();
if (arr.length) {
const container = document.createElement('div');
container.classList.add(CLS_QUICK_PICKS);
container.setAttribute('role', 'region');
container.setAttribute('aria-label', MSG_QUICK_ACTIONS_ARIA_LABEL);
this.#quickActionsEl = container;
arr.forEach(pick => {
const btn = document.createElement('button');
btn.type = 'button';
btn.classList.add('a-Button');
const { title, description, disabled } = pick;
if (title) {
const titleEl = document.createElement('span');
const id = getUniqueId();
titleEl.textContent = title;
titleEl.classList.add('a-Button-label', 'a-Button-label--title');
titleEl.id = id;
btn.setAttribute('aria-labelledby', id);
btn.appendChild(titleEl);
}
if (description) {
const descriptionEl = document.createElement('span');
const id = getUniqueId();
descriptionEl.textContent = description;
descriptionEl.classList.add('a-Button-label', 'a-Button-label--description');
descriptionEl.id = id;
btn.setAttribute('aria-describedby', id);
btn.appendChild(descriptionEl);
}
if (disabled) {
btn.disabled = true;
}
this.#quickActions.set(btn, pick);
btn.addEventListener('click', this.#onQuickActionClick);
container.appendChild(btn);
});
this.#el.insertBefore(container, this.#textAreaContainerEl);
if (this.#scrolledToBottom) {
this.scrollTranscriptToBottom(true);
}
}
return this.getQuickActionButtons();
}
removeQuickActions() {
if (this.#quickActions.size) {
this.#quickActions.forEach((value, key) => {
key.removeEventListener('click', this.#onQuickActionClick);
});
this.#quickActions.clear();
this.#quickActionsEl.remove();
this.#quickActionsEl = null;
}
}
getQuickActionButtons() {
return Array.from(this.#quickActions.entries()).map(([button, actionObj]) => ({ button, actionObj }));
}
getMessageViews() {
return [...this.#messageViews];
}
get el() {
return this.#el;
}
#setupListeners() {
this.#model.on(chatEvents.MESSAGE_ADDED, this.#onMessageModelAdded, { scope: this });
this.#model.on(chatEvents.CHAT_LOCK, this.#onChatLock, { scope: this });
this.#model.on(chatEvents.CHAT_UNLOCK, this.#onChatUnlock, { scope: this });
this.#model.on(chatEvents.CLEAR, this.#onMessagesClear, { scope: this });
this.#textareaEl.addEventListener('input', this.#onTextareaInput);
this.#textareaEl.addEventListener('keydown', this.#onTextareaKeyDown);
this.#sendBtnEl.addEventListener('click', this.#onSendBtnClick);
this.#transcriptEl.addEventListener('scroll', this.#onTranscriptScroll);
}
#setSendButtonDisabledAttribute() {
this.#sendBtnEl.disabled = this.#chatLocked || !this.#textareaEl.value;
}
#onMessageModelAdded(e, message) {
this.#maybeRenderMessage(message);
}
#onChatLock() {
this.#chatLocked = true;
this.#sendBtnEl.disabled = true;
this.#quickActionsEl?.querySelectorAll('button').forEach(btn => btn.disabled = true);
}
#onChatUnlock() {
this.#chatLocked = false;
this.#setSendButtonDisabledAttribute();
this.#quickActionsEl?.querySelectorAll('button').forEach(btn => btn.disabled = false);
}
#onMessagesClear() {
this.#messageViews.forEach(view => {
view.destroy();
// can be off-ed completely as there is no other instance using them
view.off();
view.el.remove();
});
this.#messageViews = [];
}
#maybeRenderMessage(message) {
// hidden messages should not appear in the view, they are for context only
if (message.hidden) {
return;
}
const view = new MessageView(message, { highlightCodeFn: this.#options.highlightCodeFn });
this.#messageViews.push(view);
this.#messageListEl.append(view.el);
this.#scrollMessageIntoView(view);
view.on(messageViewEvents.QUICK_ACTION_TRIGGER, this.#onMessageViewQuickActionTrigger, { scope: this });
view.on(messageViewEvents.CODE_BLOCK_QUICK_ACTION_TRIGGER, this.#onMessageViewCodeBlockQuickActionTrigger, { scope: this });
view.on(messageViewEvents.AFTER_LOAD_RENDER, this.#onMessageViewAfterLoadRender, { scope: this });
view.on(messageViewEvents.AFTER_QUICK_ACTIONS_RENDER, this.#onMessageViewAfterQuickActionsRender, { scope: this });
this.triggerEvent(events.MESSAGE_VIEW_RENDER, view);
}
#onMessageViewQuickActionTrigger(e, actionObj) {
this.triggerEvent(events.MESSAGE_QUICK_ACTION_TRIGGER, e.target, actionObj);
}
#onMessageViewCodeBlockQuickActionTrigger(e, actionObj, buttonEl, codeBlockEl) {
this.triggerEvent(events.MESSAGE_CODE_BLOCK_QUICK_ACTION_TRIGGER, e.target, actionObj, buttonEl, codeBlockEl);
}
#onMessageViewAfterLoadRender(e) {
this.#scrollMessageIntoView(e.target);
}
#onMessageViewAfterQuickActionsRender(e) {
this.#scrollMessageIntoView(e.target);
}
#setupTextarea() {
const self = this,
o = self.#options,
textareaEl = this.#textareaEl;
if (o.placeholder) {
textareaEl.setAttribute('placeholder', o.placeholder);
}
}
#maybeSubmitTextareaMessage() {
if (this.#chatLocked) {
return false;
}
const ta = this.#textareaEl;
const message = ta.value;
if (message.length) {
// remove quick picks
if (this.#quickActionsEl) {
this.removeQuickActions();
}
this.#model.addUserMessage(message, messageTypes.TEXT);
ta.value = '';
this.#updateTextareaResizerText();
this.#setSendButtonDisabledAttribute();
// set the focus back to the textfield - this is necessary when the button was clicked/keyed down
this.#textareaEl.focus();
}
return true;
}
isTranscriptScrolledToBottom() {
const el = this.#transcriptEl;
return el.scrollHeight - el.scrollTop - el.clientHeight < 1;
}
scrollTranscriptToBottom(instant = false) {
const el = this.#transcriptEl;
el.scroll({ top: el.scrollHeight, behavior: instant ? 'instant' : 'smooth' });
}
#scrollMessageIntoView(messageView) {
const messageEl = messageView.el;
this.#transcriptEl.scroll({ top: messageEl.offsetTop, behavior: 'smooth' });
}
#updateTextareaResizerText() {
this.#textareaResizerEl.innerText = this.#textareaEl.value;
}
}
ChatView.events = events;
export { ChatView, renderModes, events };