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 @@
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
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
+
+