diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 1fd886ee95d..da0c8bca85f 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -17,19 +17,21 @@ from math import floor from os.path import join as opj from odoo.http import request, Response -from odoo import http, tools, _, SUPERUSER_ID +from odoo import http, tools, _, SUPERUSER_ID, release from odoo.addons.http_routing.models.ir_http import slug, unslug from odoo.addons.web_editor.tools import get_video_url_data -from odoo.exceptions import UserError, MissingError, ValidationError +from odoo.exceptions import UserError, MissingError, AccessError from odoo.tools.misc import file_open from odoo.tools.mimetypes import guess_mimetype from odoo.tools.image import image_data_uri, binary_to_image +from odoo.addons.iap.tools import iap_tools from odoo.addons.base.models.assetsbundle import AssetsBundle from ..models.ir_attachment import SUPPORTED_IMAGE_MIMETYPES logger = logging.getLogger(__name__) DEFAULT_LIBRARY_ENDPOINT = 'https://media-api.odoo.com' +DEFAULT_OLG_ENDPOINT = 'https://olg.api.odoo.com' class Web_Editor(http.Controller): @@ -762,3 +764,22 @@ class Web_Editor(http.Controller): @http.route('/web_editor/tests', type='http', auth="user") def test_suite(self, mod=None, **kwargs): return request.render('web_editor.tests') + + @http.route("/web_editor/generate_text", type="json", auth="user") + def generate_text(self, prompt, conversation_history): + try: + IrConfigParameter = request.env['ir.config_parameter'].sudo() + olg_api_endpoint = IrConfigParameter.get_param('web_editor.olg_api_endpoint', DEFAULT_OLG_ENDPOINT) + response = iap_tools.iap_jsonrpc(olg_api_endpoint + "/api/olg/1/chat", params={ + 'prompt': prompt, + 'conversation_history': conversation_history or [], + 'version': release.version, + }, timeout=300) + if response['status'] == 'success': + return response['content'] + elif response['status'] == 'error_prompt_too_long': + raise UserError(_("Sorry, your prompt is too long. Try to say it in fewer words.")) + else: + raise UserError(_("Sorry, we could not generate a response. Please try again later.")) + except AccessError: + raise AccessError(_("Oops, it looks like our AI is unreachable!")) diff --git a/addons/web_editor/static/src/img/odoobot_transparent.png b/addons/web_editor/static/src/img/odoobot_transparent.png new file mode 100644 index 00000000000..46e3d84f2c2 Binary files /dev/null and b/addons/web_editor/static/src/img/odoobot_transparent.png differ diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_alternatives_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_alternatives_dialog.js new file mode 100644 index 00000000000..1c4479d0287 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_alternatives_dialog.js @@ -0,0 +1,97 @@ +/** @odoo-module **/ + +import { ChatGPTDialog } from '@web_editor/js/wysiwyg/widgets/chatgpt_dialog'; +import { useState } from "@odoo/owl"; + +export class ChatGPTAlternativesDialog extends ChatGPTDialog { + static template = 'web_edior.ChatGPTAlternativesDialog'; + static props = { + ...super.props, + originalText: String, + alternativesModes: { type: Object, optional: true }, + numberOfAlternatives: { type: Number, optional: true }, + }; + static defaultProps = { + alternativesModes: { + short: 'Shorten', + long: 'Lengthen', + friendly: 'Friendly', + professional: 'Professional', + persuasive: 'Persuasive', + }, + numberOfAlternatives: 3, + }; + + setup() { + super.setup(); + this.state = useState({ + selectedMessage: null, + conversationHistory: [{ + role: 'system', + content: 'You are a helpful assistant, your goal is to help the user write alternatives to their text.', + }, + { + role: 'user', + content: 'Consider the following text : \n' + this.props.originalText, + }, + { + role: 'assistant', + content: 'Thanks for this text, what do you need me to do with it?', + }], + messages: [], + alternativesMode: '', + }); + this._generateAlternatives(); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + switchAlternativesMode(ev) { + this.state.alternativesMode = ev.currentTarget.getAttribute('data-mode'); + this.state.messages = []; + this._generateAlternatives(); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + async _generateAlternatives() { + let wasError = false; + let messageIndex = 0; + while (!wasError && messageIndex < this.props.numberOfAlternatives) { + let query = messageIndex ? 'Write one alternative version of the original text.' : 'Try again another single version of the original text.'; + if (this.state.alternativesMode && !messageIndex) { + query += ` Make it more ${this.state.alternativesMode} than your last answer.`; + } + query += messageIndex ? '' : 'The original text was : \n' + this.props.originalText; + await this._generate(query, (content, isError) => { + if (isError) { + wasError = true; + } else { + this.state.conversationHistory.push({ + role: 'user', + content: query, + }, { + role: 'assistant', + content, + }); + } + this.state.messages = [...this.state.messages, { + author: 'assistant', + text: content, + isError, + }]; + }).catch(() => { + wasError = true; + this.state.messages = []; + }); + messageIndex += 1; + if (wasError) { + break; + } + } + } +} diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_alternatives_dialog.xml b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_alternatives_dialog.xml new file mode 100644 index 00000000000..c2acbafcb3b --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_alternatives_dialog.xml @@ -0,0 +1,51 @@ + + + + + +
+
+ + + +
+
+ + +
+ +
+
+ + + +
+
+ Loading... +

+ Generating alternatives... +

+
+
+
+ + + + + + +
+
+
diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_dialog.js new file mode 100644 index 00000000000..14c90ee6f2a --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_dialog.js @@ -0,0 +1,109 @@ +/** @odoo-module **/ + +import { Component, markup } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { Dialog } from "@web/core/dialog/dialog"; +import { escape } from "@web/core/utils/strings"; +import { _t } from "@web/core/l10n/translation"; + +/** + * General component for common logic between different dialogs. + */ +export class ChatGPTDialog extends Component { + static components = { Dialog }; + static props = { + close: Function, + insert: Function, + }; + + setup() { + this.rpc = useService('rpc'); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + selectMessage(ev) { + this.state.selectedMessage = +ev.currentTarget.getAttribute('data-message-index'); + } + insertMessage(ev) { + this.selectMessage(ev); + this._confirm(); + } + formatContent(content) { + return markup([...this._postprocessGeneratedContent(content).childNodes].map(child => { + // Escape all text. + const nodes = new Set([...child.querySelectorAll('*')].flatMap(node => node.childNodes)); + nodes.forEach(node => { + if (node.nodeType === Node.TEXT_NODE) { + node.textContent = escape(node.textContent); + } + }); + return child.outerHTML; + }).join('')); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _postprocessGeneratedContent(content) { + const lines = content.split('\n').filter(line => line.trim().length); + const fragment = document.createDocumentFragment(); + let parentUl, parentOl; + let lineIndex = 0; + for (const line of lines) { + if (line.trim().startsWith('- ')) { + // Create or continue an unordered list. + parentUl = parentUl || document.createElement('ul'); + const li = document.createElement('li'); + li.innerText = line.trim().slice(2); + parentUl.appendChild(li); + } else if ( + (parentOl && line.startsWith(`${parentOl.children.length + 1}. `)) || + (!parentOl && line.startsWith('1. ') && lines[lineIndex + 1]?.startsWith('2. ')) + ) { + // Create or continue an ordered list (only if the line starts + // with the next number in the current ordered list (or 1 if no + // ordered list was in progress and it's followed by a 2). + parentOl = parentOl || document.createElement('ol'); + const li = document.createElement('li'); + li.innerText = line.slice(line.indexOf('.') + 2); + parentOl.appendChild(li); + } else { + // Insert any list in progress, and a new block for the current + // line. + [parentUl, parentOl].forEach(list => list && fragment.appendChild(list)); + parentUl = parentOl = undefined; + const block = document.createElement(line.startsWith('Title: ') ? 'h2' : 'p'); + block.innerText = line; + fragment.appendChild(block); + } + lineIndex += 1; + } + [parentUl, parentOl].forEach(list => list && fragment.appendChild(list)); + return fragment; + } + _cancel() { + this.props.close(); + } + _confirm() { + try { + this.props.close(); + const text = this.state.messages[this.state.selectedMessage]?.text; + this.props.insert(this._postprocessGeneratedContent(text || '')); + } catch (e) { + this.props.close(); + throw e; + } + } + _generate(prompt, callback) { + return this.rpc('/web_editor/generate_text', { + prompt, + conversation_history: this.state.conversationHistory, + }, { shadow: true }) + .then(content => callback(content)) + .catch(error => callback(_t(error.data?.message || error.message), true)); + } +} diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_prompt_dialog.js b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_prompt_dialog.js new file mode 100644 index 00000000000..5c0ba12f522 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_prompt_dialog.js @@ -0,0 +1,102 @@ +/** @odoo-module **/ + +import { ChatGPTDialog } from '@web_editor/js/wysiwyg/widgets/chatgpt_dialog'; +import { useState, useEffect, useRef } from "@odoo/owl"; +import { useAutofocus } from "@web/core/utils/hooks"; +import { session } from "@web/session"; +import { browser } from "@web/core/browser/browser"; + +export class ChatGPTPromptDialog extends ChatGPTDialog { + static template = 'web_edior.ChatGPTPromptDialog'; + static props = { + ...super.props, + initialPrompt: { type: String, optional: true }, + }; + static defaultProps = { + initialPrompt: '', + }; + + setup() { + super.setup(); + this.assistantAvatarUrl = `${browser.location.origin}/web_editor/static/src/img/odoobot_transparent.png`; + this.userAvatarUrl = `${browser.location.origin}/web/image?model=res.users&field=avatar_128&id=${encodeURIComponent(session.uid)}`; + this.state = useState({ + prompt: this.props.initialPrompt, + conversationHistory: [{ + role: 'system', + content: 'You are a helpful assistant, your goal is to help the user write their document.', + }, + { + role: 'assistant', + content: 'What do you need ?', + }], + messages: [], + }); + this.promptInputRef = useRef('promptInput'); + useAutofocus({ refName: 'promptInput' }); + useEffect(() => { + // Resize the textarea to fit its content. + this.promptInputRef.el.style.height = 0; + this.promptInputRef.el.style.height = this.promptInputRef.el.scrollHeight + 'px'; + }, () => [this.state.prompt]); + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + onTextareaKeydown(ev) { + if (ev.key === 'Enter' && !ev.shiftKey) { + this.submitPrompt(ev); + } + } + submitPrompt(ev) { + this._freezeInput(); + ev.preventDefault(); + const prompt = this.state.prompt; + this.state.messages.push({ author: 'user', text: prompt }); + const datetime = new Date(); + const conversation = { role: 'user', content: prompt }; + this.state.conversationHistory.push(conversation); + this.state.messages.push({ author: 'assistant', datetime }); + this.state.prompt = ''; + this._generate(prompt, (content, isError) => { + if (isError) { + // There was an error, remove the prompt from the history. + this.state.conversationHistory = this.state.conversationHistory.filter(c => c !== conversation); + } else { + // There was no error, add the response to the history. + this.state.conversationHistory.push({ role: 'assistant', content }); + } + const messageIndex = this.state.messages.findIndex(m => m.datetime === datetime); + this.state.messages[messageIndex] = { author: 'assistant', text: content, isError }; + this._unfreezeInput(); + }); + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _freezeInput() { + this.promptInputRef.el.setAttribute('disabled', ''); + } + _unfreezeInput() { + this.promptInputRef.el.removeAttribute('disabled'); + this.promptInputRef.el.focus(); + } + /** + * @override + */ + _cancel() { + this._freezeInput(); + super._cancel(); + } + /** + * @override + */ + _confirm() { + this._freezeInput(); + super._confirm(); + } +} diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_prompt_dialog.xml b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_prompt_dialog.xml new file mode 100644 index 00000000000..83bfb2cbec3 --- /dev/null +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/chatgpt_prompt_dialog.xml @@ -0,0 +1,82 @@ + + + + + +
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ + +
+ +
+ Loading... +

+ Thinking... +

+
+ +
+ +
+ + + +
+
+
+
+
+
+
+
+
+
+ + + +
+
+ +
+
+ +
+
+
+
+
+
diff --git a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js index 200e91bd913..86195ab4a6c 100644 --- a/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js +++ b/addons/web_editor/static/src/js/wysiwyg/wysiwyg.js @@ -11,6 +11,8 @@ import * as OdooEditorLib from "@web_editor/js/editor/odoo-editor/src/OdooEditor import { Toolbar } from "@web_editor/js/editor/toolbar"; import { LinkPopoverWidget } from '@web_editor/js/wysiwyg/widgets/link_popover_widget'; import { AltDialog } from '@web_editor/js/wysiwyg/widgets/alt_dialog'; +import { ChatGPTPromptDialog } from '@web_editor/js/wysiwyg/widgets/chatgpt_prompt_dialog'; +import { ChatGPTAlternativesDialog } from '@web_editor/js/wysiwyg/widgets/chatgpt_alternatives_dialog'; import { ImageCrop } from '@web_editor/js/wysiwyg/widgets/image_crop'; import * as wysiwygUtils from "@web_editor/js/common/wysiwyg_utils"; @@ -1415,6 +1417,55 @@ export class Wysiwyg extends Component { }); } } + /** + * Open one of the ChatGPTDialogs to generate or modify content. + * + * @param {'prompt'|'alternatives'} [mode='prompt'] + */ + openChatGPTDialog(mode = 'prompt') { + const restore = preserveCursor(this.odooEditor.document); + const params = { + insert: content => { + this.odooEditor.historyPauseSteps(); + const insertedNodes = this.odooEditor.execCommand('insert', content); + this.odooEditor.historyUnpauseSteps(); + this.notification.add(_t('Your content was successfully generated.'), { + title: _t('Content generated'), + type: 'success', + }); + this.odooEditor.historyStep(); + // Add a frame around the inserted content to highlight it for 2 + // seconds. + const start = insertedNodes?.length && closestElement(insertedNodes[0]); + const end = insertedNodes?.length && closestElement(insertedNodes[insertedNodes.length - 1]); + if (start && end) { + const divContainer = this.odooEditor.editable.parentElement; + let [parent, left] = [start.offsetParent, start.offsetLeft]; + while (parent && !parent.contains(divContainer)) { + left += parent.offsetLeft; + parent = parent.offsetParent; + } + const div = document.createElement('div'); + div.classList.add('o-chatgpt-content'); + div.style.left = `${left - 10}px`; + div.style.top = `${start.offsetTop - 10}px`; + div.style.width = `${Math.max(start.clientWidth, end.clientWidth) + 20}px`; + div.style.height = `${end.offsetTop + end.clientHeight - start.offsetTop + 20}px`; + divContainer.prepend(div); + setTimeout(() => div.remove(), 2000); + } + }, + }; + if (mode === 'alternatives') { + params.originalText = this.odooEditor.document.getSelection().toString() || ''; + } + this.odooEditor.document.getSelection().collapseToEnd(); + this.env.services.dialog.add( + mode === 'prompt' ? ChatGPTPromptDialog : ChatGPTAlternativesDialog, + params, + { onClose: restore }, + ); + } /** * Removes the current Link. */ @@ -1725,6 +1776,10 @@ export class Wysiwyg extends Component { }); break; } + case 'open-chatgpt': { + this.openChatGPTDialog(this.odooEditor.document.getSelection()?.isCollapsed ? 'prompt' : 'alternatives'); + break; + } } }; if (!options.snippets) { @@ -1738,6 +1793,7 @@ export class Wysiwyg extends Component { }) $toolbar.find('#media-insert, #media-replace, #media-description').click(openTools); $toolbar.find('#create-link').click(openTools); + $toolbar.find('#open-chatgpt').click(openTools); $toolbar.find('#image-shape div, #fa-spin').click(e => { if (!this.lastMediaClicked) { return; @@ -2292,6 +2348,15 @@ export class Wysiwyg extends Component { } }, }, + { + category: _t('AI Tools'), + name: _t('ChatGPT'), + description: _t('Generate or transform content with AI.'), + fontawesome: 'fa-magic', + priority: 1, + isDisabled: () => !this.odooEditor.isSelectionInBlockRoot(), + callback: async () => this.openChatGPTDialog(), + }, ]; if (!editorOptions.inlineStyle) { commands.push( diff --git a/addons/web_editor/static/src/scss/wysiwyg.scss b/addons/web_editor/static/src/scss/wysiwyg.scss index 5df2a32148c..3a4d51f9076 100644 --- a/addons/web_editor/static/src/scss/wysiwyg.scss +++ b/addons/web_editor/static/src/scss/wysiwyg.scss @@ -714,3 +714,34 @@ img.o_we_selected_image { textarea.o_codeview { min-height: 400px; } + +.o-chatgpt-content { + position: absolute; + background-color: lightgreen; + opacity: 0.3; + animation: fade 1.5s ease-in-out; + border: dashed 5px green; + z-index: 1; +} +.o-prompt-input { + position: relative; + // see .o-mail-Composer-input + > textarea { + padding-top: 10px; // carefully crafted to have the text in the middle in chat window + padding-bottom: 10px; + min-height: 40px; + max-height: 110px; + resize: none; + } +} +button.o-message-insert { + line-height: 1; +} +.o-chatgpt-message > div > *:last-child, .o-chatgpt-alternative > *:last-child { + margin-bottom: 0; +} +.o-message-error { + color: #d44c59; + font-weight: bold; + --bg-opacity: 0.25; +} diff --git a/addons/web_editor/static/src/xml/editor.xml b/addons/web_editor/static/src/xml/editor.xml index 0e8cf1f7fd1..659dde98eed 100644 --- a/addons/web_editor/static/src/xml/editor.xml +++ b/addons/web_editor/static/src/xml/editor.xml @@ -175,6 +175,10 @@ Description +
+
+
+