[ADD] web_editor: create/transform content with a LLM

This commit implements a dialog for content generation using ChatGPT,
which can be opened via the Powerbox or via a button in the toolbar.

If text is selected, the dialog opens in "alternatives" mode. In this
mode, five alternative versions of the selected text are generated. The
user can choose one and it will be inserted in lieu of the selection.
Five buttons can be used to guide the AI to make the content longer,
shorter, more professional, more friendly, or more persuasive. Clicking
any of these buttons will restart the generation process with the new
parameters.

If no text is selected, the dialog opens in "prompt" mode. In this mode,
the dialog contains a textarea in which the user can input a prompt
which is then used to generate content. The user can then exchange with
the AI back and forth until the content is satisfactory. Any of the AI's
responses can be inserted into the document by clicking its "Insert"
button.

When content is inserted into the document, a green frame is shown
around it for two seconds, to make it clear to the user that something
has been inserted and where.

If an error occurs, it is inserted in the dialog like other responses
but in red and it can't be inserted into the document.

Please find the PR of the IAP part (Odoo Language Generator) here :
https://github.com/odoo/iap-apps/pull/703

task-3383324

closes odoo/odoo#137064

Signed-off-by: David Monjoie (dmo) <dmo@odoo.com>
Co-authored-by: Antoine Guenet (age) <age@odoo.com>
Co-authored-by: Lou (loha) <loha@odoo.com>
This commit is contained in:
Antoine Guenet
2023-10-20 11:22:06 +00:00
co-authored by Lou
parent d0bb595a18
commit 386a2fdebf
10 changed files with 564 additions and 2 deletions
+23 -2
View File
@@ -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!"))
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -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;
}
}
}
}
@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_edior.ChatGPTAlternativesDialog">
<Dialog size="'lg'" title="'AI Copywriter'">
<div class="md-8">
<div class="mb-3">
<t t-foreach="Object.entries(props.alternativesModes)"
t-as="alternative" t-key="alternative_index">
<button type="button" class="btn me-2 btn-sm"
t-att-class="state.alternativesMode == alternative[0] ? 'btn-success' : 'btn-info'"
t-on-click="switchAlternativesMode" t-att-data-mode="alternative[0]">
<t t-out="alternative[1]"/>
</button>
</t>
</div>
<div class="list-group">
<t t-foreach="state.messages" t-as="message" t-key="message_index">
<t t-if="message.isError">
<div class="list-group-item o-chatgpt-alternative border-danger bg-danger o-message-error">
<t t-out="message.text"/>
</div>
</t>
<t t-else="">
<button type="button" class="list-group-item list-group-item-action o-chatgpt-alternative"
t-on-click="selectMessage"
t-att-data-message-index="message_index"
t-att-class="{ active: state.selectedMessage == message_index }">
<t t-out="formatContent(message.text)"/>
</button>
</t>
</t>
<div t-if="state.messages.length &lt; props.numberOfAlternatives and !(state.messages.length and state.messages[state.messages.length - 1].isError)" class="d-flex mt-2 align">
<img src="/web/static/img/spin.svg" alt="Loading..." class="me-2"
style="filter:invert(1); opacity: 0.5; width: 30px; height: 30px;"/>
<p class="m-0 text-muted align-self-center">
<em>Generating alternatives...</em>
</p>
</div>
</div>
</div>
<!-- FOOTER -->
<t t-set-slot="footer">
<button class="btn btn-primary" t-on-click="_confirm"
t-att-disabled="typeof state.selectedMessage !== 'number'">Insert</button>
<button class="btn btn-secondary" t-on-click="_cancel">Cancel</button>
</t>
</Dialog>
</t>
</templates>
@@ -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));
}
}
@@ -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();
}
}
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="web_edior.ChatGPTPromptDialog">
<Dialog size="'lg'" title="'Generate Text with AI'">
<div t-foreach="state.messages" t-as="message" t-key="message_index"
class="position-relative py-1 px-3"
t-att-class="message_index ? 'mt-2' : 'mt-0'">
<div class="position-relative d-flex flex-shrink-0">
<div class="d-flex flex-shrink-0" style="flex-basis: 42px; max-width: 42px;">
<div class="position-relative bg-view" style="width: 36px; height: 36px;">
<img class="w-100 h-100 rounded"
t-att-src="message.author === 'user' ? userAvatarUrl : assistantAvatarUrl"/>
</div>
</div>
<div class="w-100 o-min-width-0">
<div class="d-flex flex-wrap align-items-baseline mb-1 lh-1">
<strong class="me-1 text-truncate"><t t-esc="message.author === 'user' ? 'You' : 'OdooBot'"/></strong>
</div>
<div class="position-relative d-flex">
<div class="o-min-width-0">
<div class="position-relative d-flex">
<div class="position-relative overflow-x-auto d-inline-block">
<div class="rounded-bottom-3 position-absolute top-0 start-0 w-100 h-100 border opacity-25"
t-att-class="{
'border-success bg-success-light': message.author === 'user' and message.isError !== true,
'border-info bg-info-light': message.author === 'assistant' and message.isError !== true,
'border-danger bg-danger': message.isError,
}"/>
<div class="position-relative text-break mb-0 py-2 px-3 align-self-start rounded-end-3 rounded-bottom-3 o-chatgpt-message">
<!-- INSERT BUTTON -->
<button t-if="message.author === 'assistant' and 'text' in message and message.isError !== true"
t-att-data-message-index="message_index"
class="o-message-insert d-block float-end p-1 mb-1 btn btn-info opacity-75 fst-italic ms-3"
t-on-click="insertMessage">Insert</button>
<div class="d-inline">
<!-- ASSISTANT IS THINKING -->
<div t-if="message.author === 'assistant' and !('text' in message)" class="d-flex align">
<img src="/web/static/img/spin.svg" alt="Loading..." class="me-2"
style="filter:invert(1); opacity: 0.5; width: 30px; height: 30px;"/>
<p class="m-0 text-muted align-self-center">
<em>Thinking...</em>
</p>
</div>
<!-- ASSISTANT OR USER MESSAGE -->
<div t-elif="message.isError" class="o-message-error">
<t t-out="message.text"/>
</div>
<t t-else="">
<t t-out="formatContent(message.text)"/>
</t>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div t-if="!state.messages.length" style="min-height: 150px;"/> <!-- ensure min height -->
<!-- FOOTER -->
<t t-set-slot="footer">
<div class="md-8 mb-8 d-flex bg-view flex-grow-1 border rounded-3">
<div class="o-prompt-input position-relative flex-grow-1">
<textarea type="text" id="promptInput" t-ref="promptInput"
name="promptInput" t-on-keydown="onTextareaKeydown"
class="form-control bg-view px-3 border-0 overflow-auto"
placeholder="Send a message" autocomplete="off"
t-model="state.prompt" required="required"></textarea>
</div>
<div class="border-start-0 rounded-0 rounded-end-3 align-self-center ms-2 me-3">
<button class="btn" t-att-class="state.prompt.length ? 'btn-success' : 'disabled'" t-on-click="submitPrompt">
<i class="fa fa-paper-plane"></i>
</button>
</div>
</div>
</t>
</Dialog>
</t>
</templates>
@@ -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(
@@ -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;
}
@@ -175,6 +175,10 @@
<a id="media-description" href="#" title="Edit media description" class="btn editor-ignore">Description</a>
</div>
<div id="chatgpt" class="btn-group">
<div id="open-chatgpt" title="Generate or transform content with AI" class="fa fa-magic fa-fw btn editor-ignore"></div>
</div>
<div t-if="props.showImageShape" id="image-shape" class="btn-group">
<div id="rounded" title="Shape: Rounded" class="fa fa-square fa-fw btn editor-ignore"></div>
<div id="rounded-circle" title="Shape: Circle" class="fa fa-circle-o fa-fw btn editor-ignore"></div>