@instructure/canvas-rce
Version:
A component wrapping Canvas's usage of Tinymce
489 lines (484 loc) • 17 kB
JavaScript
import _pt from "prop-types";
/*
* Copyright (C) 2024 - present Instructure, Inc.
*
* This file is part of Canvas.
*
* Canvas is free software: you can redistribute it and/or modify it under
* the terms of the GNU Affero General Public License as published by the Free
* Software Foundation, version 3 of the License.
*
* Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
* A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License along
* with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import formatMessage from '../../../../format-message';
import { Button, CloseButton, CondensedButton, IconButton } from '@instructure/ui-buttons';
import { Flex } from '@instructure/ui-flex';
import { Heading } from '@instructure/ui-heading';
import { Tray } from '@instructure/ui-tray';
import { SVGIcon } from '@instructure/ui-svg-images';
import { SimpleSelect } from '@instructure/ui-simple-select';
import { ScreenReaderContent } from '@instructure/ui-a11y-content';
import { Spinner } from '@instructure/ui-spinner';
import { TextArea } from '@instructure/ui-text-area';
import { TruncateText } from '@instructure/ui-truncate-text';
import { View } from '@instructure/ui-view';
import { uid } from '@instructure/uid';
import { showFlashAlert } from '../../../../common/FlashAlert';
import doFetchApi from '../do-fetch-api-effect';
import { AIWandSVG, AIAvatarSVG, InsertSVG, CopySVG, RefreshSVG, DislikeSVG } from './aiicons';
import { AIResponseModal } from './AIResponseModal';
const msgid = () => uid('msg', 3);
const modifyAllTaskMessage = formatMessage('Hello. Please describe the modifications you would like to make to your composition.');
const modifySelectionTaskMessage = formatMessage('Hello. Please describe the modifications you would like to make to your selection.');
const generateTaskMessage = formatMessage('Please decribe what you would like to compose.');
export const AIToolsTray = ({
open,
container,
mountNode,
contextId,
contextType,
currentContent,
onClose,
onInsertContent,
onReplaceContent
}) => {
const [trayRef, setTrayRef] = useState(null);
const [containerStyle] = useState(() => {
if (container) {
return {
width: container.style.width,
boxSizing: container.style.boxSizing,
transition: container.style.transition
};
}
return {};
});
const [isOpen, setIsOpen] = useState(open);
const [task, setTask] = useState(() => {
return currentContent.content.trim().length > 0 ? 'modify' : 'generate';
});
const [userPrompt, setUserPrompt] = useState('');
const [waitingForResponse, setWaitingForResponse] = useState(false);
const [responseHtml, setResponseHtml] = useState('');
const chatContainerRef = useRef(null);
const getModifyTaskMessage = useCallback(() => {
return currentContent.type === 'selection' ? modifySelectionTaskMessage : modifyAllTaskMessage;
}, [currentContent.type]);
const initChatMessages = useCallback(() => {
return [task === 'modify' ? {
id: msgid(),
type: 'message',
message: getModifyTaskMessage()
} : {
id: msgid(),
type: 'message',
message: generateTaskMessage
}];
}, [getModifyTaskMessage, task]);
const chatMessagesRef = useRef(initChatMessages());
const reset = useCallback(() => {
chatMessagesRef.current = initChatMessages();
setWaitingForResponse(false);
setUserPrompt('');
}, [initChatMessages]);
useLayoutEffect(() => {
const lastbox = chatContainerRef.current?.querySelector('.ai-chat-box:last-child');
lastbox?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}, [trayRef, chatMessagesRef.current.length]);
useEffect(() => {
setTask(currentContent.content.trim().length > 0 ? 'modify' : 'generate');
}, [currentContent.content]);
useEffect(() => {
if (open !== isOpen) {
setIsOpen(open);
reset();
}
}, [isOpen, open, reset]);
useEffect(() => {
const shrinking_selector = '#content'; // '.block-editor-editor'
if (open && trayRef) {
const ed = document.querySelector(shrinking_selector);
if (!ed) return;
const edstyle = window.getComputedStyle(ed);
const ed_rect = ed.getBoundingClientRect();
const padding = parseInt(edstyle.paddingRight, 10);
const tray_left = window.innerWidth - trayRef.offsetWidth;
if (ed_rect.right > tray_left) {
ed.style.boxSizing = 'border-box';
ed.style.width = `${ed_rect.width - (ed_rect.right - tray_left - padding)}px`;
}
} else {
const ed = document.querySelector(shrinking_selector);
if (!ed) return;
ed.style.boxSizing = containerStyle.boxSizing || '';
ed.style.width = containerStyle.width || '';
ed.style.transition = containerStyle.transition || '';
}
}, [containerStyle, open, trayRef]);
const getResponse = useCallback(prompt => {
setWaitingForResponse(true);
// the .finally triggered the error even though there is a .catch
doFetchApi({
path: '/api/v1/rich_content/generate',
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
// context_id: contextId,
// context_type: contextType,
course_id: contextId,
prompt,
current_copy: task === 'modify' ? currentContent : undefined,
type_of_request: task
})
}).then(result => {
const {
json
} = result;
if (json.error) {
chatMessagesRef.current.push({
id: msgid(),
type: 'error',
message: formatMessage(json.error)
});
} else {
chatMessagesRef.current.push({
id: msgid(),
type: 'response',
message: json.content
});
}
}).catch(async err => {
const err_result = await err.response.json();
const msg = err_result.error || formatMessage('An error occurred processing your request');
chatMessagesRef.current.push({
id: msgid(),
type: 'error',
message: msg
});
}).finally(() => {
setWaitingForResponse(false);
});
}, [contextId, currentContent, task]);
const handleCloseTray = useCallback(() => {
onClose();
chatMessagesRef.current.push({
id: msgid(),
type: 'message',
message: task === 'modify' ? getModifyTaskMessage() : generateTaskMessage
});
setUserPrompt('');
}, [getModifyTaskMessage, onClose, task]);
const handleChangeTask = useCallback((event, data) => {
setTask(data.value);
setWaitingForResponse(false);
chatMessagesRef.current.push({
id: msgid(),
type: 'message',
message: data.value === 'modify' ? getModifyTaskMessage() : generateTaskMessage
});
}, [getModifyTaskMessage]);
const handlePromptChange = useCallback(e => {
setUserPrompt(e.target.value);
}, []);
const handleSubmitPrompt = useCallback(() => {
chatMessagesRef.current.push({
id: msgid(),
type: 'user',
message: userPrompt.trim()
});
getResponse(userPrompt);
setUserPrompt('');
}, [getResponse, userPrompt]);
const handleInsertResponse = useCallback(responseText => {
onInsertContent(responseText);
}, [onInsertContent]);
const handleCopyResponse = useCallback(async responseText => {
try {
if (ClipboardItem.supports('text/html')) {
const htmlBlob = new Blob([responseText], {
type: 'text/html'
});
await navigator.clipboard.write([new ClipboardItem({
'text/html': htmlBlob
})]);
} else {
const div = document.createElement('div');
div.innerHTML = responseText;
await navigator.clipboard.writeText(div.textContent || '');
}
showFlashAlert({
message: formatMessage('Response copied to clipboard'),
type: 'success',
err: undefined
});
} catch (err) {
showFlashAlert({
message: formatMessage('Failed to copy response'),
type: 'error',
err: undefined
});
}
}, []);
const handleRefreshResponse = useCallback(() => {
getResponse(userPrompt);
}, [getResponse, userPrompt]);
const handleDislikeResponse = useCallback(() => {
console.log('dislike response'); // TODO: what?
}, []);
const handleShowWholeResponse = useCallback(event => {
const msgId = event.target.dataset.messageId;
const message = chatMessagesRef.current.find(msg => msg.id === msgId);
if (message) {
setResponseHtml(message.message);
}
}, []);
const handleCloseResponseModal = useCallback(() => {
setResponseHtml('');
}, []);
const handleInsertFromModal = useCallback(() => {
handleInsertResponse(responseHtml);
handleCloseResponseModal();
}, [handleCloseResponseModal, handleInsertResponse, responseHtml]);
const handleReplaceFromModal = useCallback(() => {
onReplaceContent(responseHtml);
handleCloseResponseModal();
}, [handleCloseResponseModal, onReplaceContent, responseHtml]);
const sharkfin = () => {
return /*#__PURE__*/React.createElement("svg", {
width: "14",
height: "14",
viewBox: "0 0 14 14",
xmlns: "http://www.w3.org/2000/svg"
}, /*#__PURE__*/React.createElement("polyline", {
points: "0,14 0,0 14,14",
fill: "none",
stroke: "#ccc",
strokeWidth: "1"
}), /*#__PURE__*/React.createElement("polyline", {
points: "0,14 14,14",
stroke: "white",
strokeWidth: "2"
}));
};
const renderResponse = msgId => {
const message = chatMessagesRef.current.find(msg => msg.id === msgId);
if (!message) {
return /*#__PURE__*/React.createElement("span", null, formatMessage("I'm sorry, but I cannot find the AI's answer"));
}
const div = document.createElement('div');
div.innerHTML = message.message;
return /*#__PURE__*/React.createElement("div", {
style: {
display: 'flex',
flexDirection: 'column'
}
}, /*#__PURE__*/React.createElement(TruncateText, {
maxLines: 3
}, div.textContent), /*#__PURE__*/React.createElement("span", {
style: {
alignSelf: 'end'
}
}, /*#__PURE__*/React.createElement(CondensedButton, {
onClick: handleShowWholeResponse,
"data-message-id": msgId
}, formatMessage('Show all'))));
};
// TODO: should the response box get truncated?
const renderChatBox = (message, key) => {
return /*#__PURE__*/React.createElement("div", {
id: message.id,
className: "ai-chat-box",
key: key,
style: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'start',
rowGap: '4px'
}
}, /*#__PURE__*/React.createElement(SVGIcon, {
src: AIAvatarSVG,
size: "small"
}), /*#__PURE__*/React.createElement("div", {
style: {
padding: '.5rem',
border: '1px solid #ccc',
borderRadius: '.5rem',
position: 'relative'
}
}, message.type === 'waiting' && /*#__PURE__*/React.createElement(Spinner, {
renderTitle: message.message,
size: "x-small"
}), (message.type === 'message' || message.type === 'user') && message.message, message.type === 'response' && renderResponse(message.id), message.type === 'error' && /*#__PURE__*/React.createElement("span", null, message.message), /*#__PURE__*/React.createElement("div", {
style: {
position: 'absolute',
top: '-18px',
// Adjust this value to position the sharkfin
left: '40px' // Adjust this value to align the sharkfin horizontally
}
}, sharkfin())), message.type === 'response' ?
/*#__PURE__*/
/* TODO: why is it to wide w/o maxWidth? */
React.createElement("div", {
style: {
display: 'flex',
gap: '8px',
justifyContent: 'end',
maxWidth: '95%',
margin: '5px 0'
}
}, /*#__PURE__*/React.createElement(IconButton, {
screenReaderLabel: formatMessage('Insert'),
withBackground: false,
withBorder: false,
onClick: handleInsertResponse.bind(null, message.message)
}, /*#__PURE__*/React.createElement(SVGIcon, {
src: InsertSVG,
size: "x-small"
})), /*#__PURE__*/React.createElement(IconButton, {
screenReaderLabel: formatMessage('Copy'),
withBackground: false,
withBorder: false,
onClick: handleCopyResponse.bind(null, message.message)
}, /*#__PURE__*/React.createElement(SVGIcon, {
src: CopySVG,
size: "x-small"
})), /*#__PURE__*/React.createElement(IconButton, {
screenReaderLabel: formatMessage('Retry'),
withBackground: false,
withBorder: false,
onClick: handleRefreshResponse
}, /*#__PURE__*/React.createElement(SVGIcon, {
src: RefreshSVG,
size: "x-small"
})), /*#__PURE__*/React.createElement(IconButton, {
screenReaderLabel: formatMessage('Dislike'),
withBackground: false,
withBorder: false,
onClick: handleDislikeResponse
}, /*#__PURE__*/React.createElement(SVGIcon, {
src: DislikeSVG,
size: "x-small"
}))) : null);
};
const renderChatMessages = () => {
const messages = chatMessagesRef.current.map(message => {
return renderChatBox(message, message.id);
});
if (waitingForResponse) {
messages.push(renderChatBox({
id: msgid(),
type: 'waiting',
message: formatMessage('Waiting for response')
}, 'ai-waiting-message'));
}
return messages;
};
return /*#__PURE__*/React.createElement(Tray, {
contentRef: el => setTrayRef(el),
label: "AIToolsTray",
mountNode: mountNode,
open: open,
placement: "end",
size: "small",
onClose: handleCloseTray
}, /*#__PURE__*/React.createElement(View, {
as: "div",
padding: "small",
position: "relative",
height: "100vh",
overflowY: "hidden"
}, /*#__PURE__*/React.createElement("div", {
style: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
height: '100%',
minHeight: '1px',
maxHeight: '100%'
}
}, /*#__PURE__*/React.createElement(Flex, {
margin: "0 0 medium",
gap: "small"
}, /*#__PURE__*/React.createElement(CloseButton, {
placement: "end",
onClick: handleCloseTray,
screenReaderLabel: "Close"
}), /*#__PURE__*/React.createElement(SVGIcon, {
src: AIWandSVG,
size: "x-small"
}), /*#__PURE__*/React.createElement(Heading, {
level: "h3"
}, formatMessage('Writing Assistant'))), /*#__PURE__*/React.createElement(SimpleSelect, {
renderLabel: formatMessage('What would you like to do?'),
value: task,
onChange: handleChangeTask
}, /*#__PURE__*/React.createElement(SimpleSelect.Option, {
id: "modify",
value: "modify",
isDisabled: currentContent.content.trim().length === 0
}, formatMessage('Modify')), /*#__PURE__*/React.createElement(SimpleSelect.Option, {
id: "generate",
value: "generate"
}, formatMessage('Compose'))), /*#__PURE__*/React.createElement("div", {
style: {
flexGrow: 1,
overflowY: 'auto'
}
}, /*#__PURE__*/React.createElement("div", {
ref: chatContainerRef,
style: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
justifyContent: 'end',
minHeight: '100%'
}
}, renderChatMessages())), /*#__PURE__*/React.createElement(View, {
as: "div",
padding: "small 0 0 0",
borderWidth: "small 0 0 0"
}, /*#__PURE__*/React.createElement(TextArea, {
id: "ai-prompt",
label: /*#__PURE__*/React.createElement(ScreenReaderContent, null, formatMessage('Enter text')),
resize: "vertical",
value: userPrompt,
onChange: handlePromptChange
})), /*#__PURE__*/React.createElement("div", {
style: {
alignSelf: 'end'
}
}, /*#__PURE__*/React.createElement(Button, {
onClick: handleSubmitPrompt,
interaction: waitingForResponse || !userPrompt.trim() ? 'disabled' : 'enabled'
}, formatMessage('Submit')))), responseHtml && /*#__PURE__*/React.createElement(AIResponseModal, {
open: true,
onClose: handleCloseResponseModal,
html: responseHtml,
onInsert: handleInsertFromModal,
onReplace: handleReplaceFromModal
})));
};
AIToolsTray.propTypes = {
open: _pt.bool.isRequired,
contextId: _pt.string.isRequired,
contextType: _pt.string.isRequired,
currentContent: _pt.shape({
type: _pt.oneOf(['selection', 'full']).isRequired,
content: _pt.string.isRequired
}).isRequired,
onClose: _pt.func.isRequired,
onInsertContent: _pt.func.isRequired,
onReplaceContent: _pt.func.isRequired
};