diff --git a/castle/cms/browser/configure.zcml b/castle/cms/browser/configure.zcml index 6471f3bb4..37f41bd86 100644 --- a/castle/cms/browser/configure.zcml +++ b/castle/cms/browser/configure.zcml @@ -412,4 +412,12 @@ layer="..interfaces.ICastleLayer" /> + + diff --git a/castle/cms/browser/controlpanel/configure.zcml b/castle/cms/browser/controlpanel/configure.zcml index ea17f9308..56f0f996c 100644 --- a/castle/cms/browser/controlpanel/configure.zcml +++ b/castle/cms/browser/controlpanel/configure.zcml @@ -1,8 +1,9 @@ - - + + /> + /> + /> + /> + /> + /> + /> + /> + /> + /> + /> - + /> + /> + /> + /> + /> + /> + /> + /> + /> + /> - + + /> + /> + + /> + diff --git a/castle/cms/browser/controlpanel/openai.py b/castle/cms/browser/controlpanel/openai.py new file mode 100644 index 000000000..ded0e9f7c --- /dev/null +++ b/castle/cms/browser/controlpanel/openai.py @@ -0,0 +1,25 @@ +from plone.app.registry.browser.controlpanel import ( + RegistryEditForm, + ControlPanelFormWrapper, +) +from plone.supermodel import model + +import zope.schema as schema + +class IOpenAISettings(model.Schema): + openai_api_key = schema.TextLine( + title=u'OpenAI API Key', + default=None, + required=False, + ) + +class OpenAISettingsControlPanelForm(RegistryEditForm): + schema_prefix = 'castle' + schema = IOpenAISettings + id = 'OpenAISettingsControlPanel' + label = u'OpenAI Settings' + description = 'Settings to communicate with OpenAI API' + + +class OpenAISettingsControlPanel(ControlPanelFormWrapper): + form = OpenAISettingsControlPanelForm diff --git a/castle/cms/browser/openai.py b/castle/cms/browser/openai.py new file mode 100644 index 000000000..ffb89c8f1 --- /dev/null +++ b/castle/cms/browser/openai.py @@ -0,0 +1,91 @@ +from Products.Five import BrowserView +from plone.protect import ( + PostOnly, + protect, +) +from plone import api +import requests +import random +import json + +class OpenAI(BrowserView): + + @protect(PostOnly) + def __call__(self, REQUEST=None): + data = self.request.form.get("data", {}) + return json.dumps(self.openai_api_request(data)) + + def openai_api_request(self, data): + response = requests.post( + url="https://api.openai.com/v1/chat/completions", + data=json.dumps({ + "model": "gpt-3.5-turbo", + "messages": [{ + "role": "user", + "content": data, + }], + "temperature": 0.2, + "max_tokens": 500, + }), + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer {}".format(self.api_key) + }, + ) + return self.handle_response(response) + + def handle_response(self, response, num_retries=1, delay=1, max_retries=10): + if not response.ok: + try: + response_json = response.json() + except: # noqa + return self.error_response() + error = response_json.get("error", {}) + code = error.get("code", {}) + if response.status_code == 429 and code != "insufficient_quota": # nosec + if num_retries <= max_retries: + self.exponential_backoff(data=self.request.data, num_retries=num_retries, delay=delay) + try: + received_data = response.json() + except: # noqa + return self.error_response() + + status_code = response.status_code + success = 200 <= status_code < 300 + + if success: + status = "success" + message = received_data.get('choices')[0].get('message').get('content') + else: # openai api error + status = response_json.get("error", {}).get("code", {}) + message = received_data.get("error", {}).get("message", {}) + + return_data = { + "status": status, + "message": message, + } + + response = self.request.response + response.setStatus(status_code) + response.setHeader('Content-Type', 'application/json') + + return return_data + + def error_response(self): # unforseen error + response = self.request.response + response.setStatus(response.status_code) + response.setHeader('Content-Type', 'application/json') + return { + "status": "error", + "message": "unforseen error", + } + + @property + def api_key(self): + key = api.portal.get_registry_record("castle.openai_api_key", default="default_value") + return key + + def exponential_backoff(self, data, num_retries, delay, exponential_base=2): + num_retries = num_retries + 1 + delay *= exponential_base * (2 * random.random()) # nosec + self.openai_api_request(data=data, num_retries=num_retries, delay=delay) diff --git a/castle/cms/profiles/3017/controlpanel.xml b/castle/cms/profiles/3017/controlpanel.xml new file mode 100644 index 000000000..db1129355 --- /dev/null +++ b/castle/cms/profiles/3017/controlpanel.xml @@ -0,0 +1,17 @@ + + + + + Manage portal + + + diff --git a/castle/cms/profiles/3017/registry/controlpanel.xml b/castle/cms/profiles/3017/registry/controlpanel.xml new file mode 100644 index 000000000..18792f051 --- /dev/null +++ b/castle/cms/profiles/3017/registry/controlpanel.xml @@ -0,0 +1,12 @@ + + + + + + openai|++plone++castle/patterns/tinymce/js/openai.js + + + + diff --git a/castle/cms/profiles/3017/registry/mosaic.xml b/castle/cms/profiles/3017/registry/mosaic.xml new file mode 100644 index 000000000..b124075cd --- /dev/null +++ b/castle/cms/profiles/3017/registry/mosaic.xml @@ -0,0 +1,62 @@ + + + + + + openai + + + + + toolbar-openai + selection + OpenAI + openai + false + false + 750 + + + + + toolbar-openai + + + + + + toolbar-openai + + + + + + toolbar-openai + + + + + + toolbar-openai + + + + + + toolbar-openai + + + + + + toolbar-openai + + + + diff --git a/castle/cms/profiles/default/controlpanel.xml b/castle/cms/profiles/default/controlpanel.xml index 8ab18ec6b..1959e1795 100644 --- a/castle/cms/profiles/default/controlpanel.xml +++ b/castle/cms/profiles/default/controlpanel.xml @@ -125,4 +125,16 @@ i18n:attributes="title"> Plone Site Setup: Users and Groups + + + Manage portal + diff --git a/castle/cms/profiles/default/metadata.xml b/castle/cms/profiles/default/metadata.xml index 246079d28..6ce391ae7 100644 --- a/castle/cms/profiles/default/metadata.xml +++ b/castle/cms/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 3016 + 3017 profile-plone.app.querystring:default profile-plone.app.mosaic:default diff --git a/castle/cms/profiles/default/registry/controlpanel.xml b/castle/cms/profiles/default/registry/controlpanel.xml index 59030030d..6ccec888a 100644 --- a/castle/cms/profiles/default/registry/controlpanel.xml +++ b/castle/cms/profiles/default/registry/controlpanel.xml @@ -43,6 +43,7 @@ interface="Products.CMFPlone.interfaces.controlpanel.ITinyMCESchema" field="custom_plugins"> mce-table-buttons|++plone++castle/tinymce-table.js + openai|++plone++castle/patterns/tinymce/js/openai.js diff --git a/castle/cms/profiles/default/registry/mosaic.xml b/castle/cms/profiles/default/registry/mosaic.xml index 624659f39..eb21aa60a 100644 --- a/castle/cms/profiles/default/registry/mosaic.xml +++ b/castle/cms/profiles/default/registry/mosaic.xml @@ -107,6 +107,7 @@ tile-remove-format grid-row-dark grid-row-remove-format + openai @@ -563,12 +564,24 @@ 1000 + + toolbar-openai + selection + OpenAI + openai + false + false + 750 + + toolbar-removeformat toolbar-table toolbar-code + toolbar-openai toolbar-indent toolbar-outdent @@ -580,6 +593,7 @@ toolbar-removeformat toolbar-table toolbar-code + toolbar-openai toolbar-indent toolbar-outdent @@ -591,6 +605,7 @@ toolbar-removeformat toolbar-table toolbar-code + toolbar-openai toolbar-indent toolbar-outdent @@ -602,6 +617,7 @@ toolbar-removeformat toolbar-table toolbar-code + toolbar-openai toolbar-indent toolbar-outdent @@ -612,15 +628,18 @@ toolbar-removeformat toolbar-table toolbar-code + toolbar-openai toolbar-indent toolbar-outdent + toolbar-removeformat toolbar-table toolbar-code + toolbar-openai toolbar-indent toolbar-outdent diff --git a/castle/cms/registry.py b/castle/cms/registry.py index 1b0b143ef..3533b4a4e 100644 --- a/castle/cms/registry.py +++ b/castle/cms/registry.py @@ -35,7 +35,6 @@ def parseRegistry(self): result = super(CastleMosaicRegistry, self).parseRegistry() else: result = super(CastleMosaicRegistry, self).parseRegistry() - mng = get_tile_manager() for tile in mng.get_tiles(): if tile.get('hidden'): diff --git a/castle/cms/static/less/logged-in/icons/tinymce.less b/castle/cms/static/less/logged-in/icons/tinymce.less index 20ce2e9a7..0a64522ab 100644 --- a/castle/cms/static/less/logged-in/icons/tinymce.less +++ b/castle/cms/static/less/logged-in/icons/tinymce.less @@ -360,3 +360,10 @@ .mce-i-selected::before { content: url("@{tinymce-folder}/checkbox.svg"); } + +.mce-i-openai::before { + content: url("@{tinymce-folder}/openai.svg"); + width: 16px; + height: 16px; + margin: 0; +} \ No newline at end of file diff --git a/castle/cms/static/patterns/tinymce/js/openai.js b/castle/cms/static/patterns/tinymce/js/openai.js new file mode 100644 index 000000000..3bb2a1eb5 --- /dev/null +++ b/castle/cms/static/patterns/tinymce/js/openai.js @@ -0,0 +1,190 @@ +/** + * plugin.js + * + * Released under LGPL License. + * Copyright (c) 1999-2015 Ephox Corp. All rights reserved + * + * License: http://www.tinymce.com/license + * Contributing: http://www.tinymce.com/contributing + */ + +/*global tinymce:true */ +define(['jquery', 'tinymce'], function($, tinymce) { + // $.mosaic.actionManager.actions + tinymce.PluginManager.add('openai', function (editor) { + 'use strict'; + function showDialog() { + editor.windowManager.open({ + title: 'AI Assistant', + body: [ + { + type: 'textbox', + name: 'request', + placeholder: 'Ask the AI to edit or generate...', + }, + ], + height: 60, + width: 600, + onsubmit: async function(e) { + const { portalUrl } = document.querySelector( 'body' ).dataset; + + function dimScreen() { + let element = document.querySelector('#dimmer') + if (element === null){ + const dimmer = document.createElement('div'); + dimmer.id = 'dimmer' + dimmer.style.display = 'block' + dimmer.style.backgroundColor = 'black' + dimmer.style.position = 'fixed'; + dimmer.style.width = '100%'; + dimmer.style.height = '100%'; + dimmer.style.zIndex = 1000; + dimmer.style.top = '0px'; + dimmer.style.left = '0px'; + dimmer.style.opacity = .5; /* in FireFox */ + document.body.appendChild(dimmer) + } + else { + element.style.display = 'block' + } + } + + function hideDimmer() { + const element = document.querySelector('#dimmer') + element.style.display = 'none' + } + + function showSpinner() { + dimScreen() + let element = document.querySelector('#spinner') + if (element === null){ + const spinnerImg = document.createElement('img'); + spinnerImg.src = '++plone++castle/svg/tinymce/spinner-solid.svg' + spinnerImg.id = 'spinner' + spinnerImg.classList = 'text-center' + spinnerImg.style.position = 'fixed'; + spinnerImg.style.top = '50%'; + spinnerImg.style.left = '50%'; + spinnerImg.style.height = '100px'; + spinnerImg.style.width = '100px'; + spinnerImg.style.transform = 'translate(-50%, -50%)'; + spinnerImg.style.zIndex = '70000' + spinnerImg.style.display = 'block' + document.body.appendChild(spinnerImg) + spin() + } + else { + element.style.display = 'block' + } + } + + function hideSpinner() { + const spinnerElm = document.querySelector('#spinner') + spinnerElm.style.display = 'none' + hideDimmer() + } + + function spin() { + const loadingSpinning = [ + { transform: 'rotate(0)' }, + { transform: 'rotate(360deg)' }, + ]; + + const loadingTiming = { + duration: 2000, + iterations: Infinity, + }; + + const loading = document.querySelector('#spinner'); + loading.animate(loadingSpinning, loadingTiming); + } + + function delay(time) { + return new Promise(resolve => setTimeout(resolve, time)); + } + + function checkFormExists() { + if (document.querySelector('#openai-form') == null) { + const form = document.createElement('form'); + form.id = 'openai-form' + const requestInput = document.createElement('input'); + form.method = 'POST'; + + requestInput.name='data'; + requestInput.id='openai-request-input' + form.appendChild(requestInput); + + form.style.display = 'none' + document.body.appendChild(form); + } + } + + async function openAiRequest() { + checkFormExists() + const input = document.querySelector('#openai-request-input') + input.value = e.data.request + const form = document.querySelector('#openai-form') + + const request = await fetch(`${portalUrl}/@@openai-request`, { + method: 'POST', + body: new URLSearchParams(new FormData(form)) + }).then(async response => { + const jsonResponse = await response.json() + if (!response.ok) { + if (response.status === 401) { + hideSpinner() + await delay(100); + alert('Invalid authentication or api key. Please contact your administrator.') + + } + else if (response.status === 429) { + if (jsonResponse.error.type === 'insufficient_quota') { + hideSpinner() + await delay(100); + alert('You used up your monthly requests. Please message your administrator to load more.') + } + else { + hideSpinner() + await delay(100); + alert('Too many requests have been submited to OpenAI. Please try again later.') + } + } + else if (response.status >= 500) { + hideSpinner() + await delay(100); + alert('There has been an error connecting to OpenAI. Please try again later.') + } + else { + hideSpinner() + await delay(100); + alert('There has been an unexpected error. Please contact your administrator.') + } + } + else { + hideSpinner() + return await jsonResponse; + } + }) + .catch(async (error) => { + hideSpinner() + await delay(100); + alert('There has been an unexpected error. Please contact your administrator.') + }); + return request + } + + showSpinner() + const requestJson = openAiRequest() + const answer = await requestJson + editor.insertContent(answer.message); + } + }); + } + + editor.addButton('openai', { + icon: 'openai', + tooltip: 'OpenAI', + onclick: showDialog, + }); + }); +}) diff --git a/castle/cms/static/patterns/tinymce/pattern.js b/castle/cms/static/patterns/tinymce/pattern.js index f41e97593..0bb62b98b 100644 --- a/castle/cms/static/patterns/tinymce/pattern.js +++ b/castle/cms/static/patterns/tinymce/pattern.js @@ -18,6 +18,7 @@ define([ 'text!mockup-patterns-tinymce-url/templates/selection.xml', 'mockup-utils', 'mockup-patterns-tinymce-url/js/links', + 'mockup-patterns-tinymce-url/js/openai', 'mockup-i18n', 'translate', 'tinymce-modern-theme', @@ -124,12 +125,12 @@ define([ theme: 'modern', plugins: ['advlist', 'autolink', 'lists', 'charmap', 'print', 'preview', 'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen', 'insertdatetime', 'media', 'table', 'contextmenu', - 'paste', 'plonelink', 'ploneimage'], + 'paste', 'plonelink', 'ploneimage', 'openai'], menubar: 'edit table format tools view insert', toolbar: 'undo redo | styleselect | bold italic | ' + 'alignleft aligncenter alignright alignjustify | ' + 'bullist numlist outdent indent | ' + - 'unlink plonelink ploneimage', + 'unlink plonelink ploneimage openai', //'autoresize_max_height': 900, 'height': 400, // stick here because it's easier to config without diff --git a/castle/cms/static/plone-compiled.js b/castle/cms/static/plone-compiled.js index d1645da67..3161e70dc 100644 --- a/castle/cms/static/plone-compiled.js +++ b/castle/cms/static/plone-compiled.js @@ -5404,7 +5404,8 @@ define('mockup-i18n',[ 'use strict'; var I18N = function() { - var self = this; + var self = this || {}; + self.baseUrl = $('body').attr('data-i18ncatalogurl'); if (!self.baseUrl) { diff --git a/castle/cms/static/plone-compiled.min.js b/castle/cms/static/plone-compiled.min.js index 111774ae3..7c634e543 100644 --- a/castle/cms/static/plone-compiled.min.js +++ b/castle/cms/static/plone-compiled.min.js @@ -1,2 +1,2 @@ -!function(){function t(l,u){return function(t){var e=arguments.length;if(!(e<2||null==t))for(var n=1;n":">",'"':""","'":"'","`":"`"}),d=g.invert(a),N=(g.escape=P(a),g.unescape=P(d),g.result=function(t,e,n){e=null==t?void 0:t[e];return g.isFunction(e=void 0===e?n:e)?e.call(t):e},0),A=(g.uniqueId=function(t){var e=++N+"";return t?t+e:e},g.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g},/(.)^/),H={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},R=/\\|'|\r|\n|\u2028|\u2029/g;g.template=function(a,t,e){t=g.defaults({},t=!t&&e?e:t,g.templateSettings);var e=RegExp([(t.escape||A).source,(t.interpolate||A).source,(t.evaluate||A).source].join("|")+"|$","g"),s=0,r="__p+='";a.replace(e,function(t,e,n,i,o){return r+=a.slice(s,o).replace(R,O),s=o+t.length,e?r+="'+\n((__t=("+e+"))==null?'':_.escape(__t))+\n'":n?r+="'+\n((__t=("+n+"))==null?'':__t)+\n'":i&&(r+="';\n"+i+"\n__p+='"),t}),r+="';\n",r="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+(r=t.variable?r:"with(obj||{}){\n"+r+"}\n")+"return __p;\n";try{var n=new Function(t.variable||"obj","_",r)}catch(t){throw t.source=r,t}function i(t){return n.call(this,t,g)}e=t.variable||"obj";return i.source="function("+e+"){\n"+r+"}",i},g.chain=function(t){t=g(t);return t._chain=!0,t};g.mixin=function(n){g.each(g.functions(n),function(t){var e=g[t]=n[t];g.prototype[t]=function(){var t=[this._wrapped];return r.apply(t,arguments),Y(this,e.apply(g,t))}})},g.mixin(g),g.each(["pop","push","reverse","shift","sort","splice","unshift"],function(e){var n=o[e];g.prototype[e]=function(){var t=this._wrapped;return n.apply(t,arguments),"shift"!==e&&"splice"!==e||0!==t.length||delete t[0],Y(this,t)}}),g.each(["concat","join","slice"],function(t){var e=o[t];g.prototype[t]=function(){return Y(this,e.apply(this._wrapped,arguments))}}),g.prototype.value=function(){return this._wrapped},g.prototype.valueOf=g.prototype.toJSON=g.prototype.value,g.prototype.toString=function(){return""+this._wrapped},"function"==typeof define&&define.amd&&define("underscore",[],function(){return g})}.call(this),function(){Function.prototype.bind||(Function.prototype.bind=function(t){if("function"!=typeof this)throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");function e(){return i.apply(this instanceof o&&t?this:t,n.concat(Array.prototype.slice.call(arguments)))}var n=Array.prototype.slice.call(arguments,1),i=this,o=function(){};return o.prototype=this.prototype,e.prototype=new o,e});var n,i={DEBUG:10,INFO:20,WARN:30,ERROR:40,FATAL:50};function t(){}function e(){}function a(t,e){this._loggers={},this.name=t||"",this._parent=e||null,e||(this._enabled=!0,this._level=i.WARN)}function o(t){n=t}t.prototype={output:function(t,e,n){void 0!==window.console&&void 0!==console.log&&(t&&n.unshift(t+":"),t=n.join(" "),console.info,e<=i.DEBUG?(t="[DEBUG] "+t,console.log(t)):e<=i.INFO?console.info(t):e<=i.WARN?console.warn(t):console.error(t))}},e.prototype={output:function(t,e,n){t&&n.unshift(t+":"),(e<=i.DEBUG?(n.unshift("[DEBUG]"),console.log):e<=i.INFO?console.info:e<=i.WARN?console.warn:console.error).apply(console,n)}},a.prototype={getLogger:function(t){for(var e=t.split("."),n=this,i=this.name?[this.name]:[];e.length;){var o=e.shift();i.push(o),o in n._loggers||(n._loggers[o]=new a(i.join("."),n)),n=n._loggers[o]}return n},_getFlag:function(t){var e=this;for(t="_"+t;null!==e;){if(void 0!==e[t])return e[t];e=e._parent}return null},setEnabled:function(t){this._enabled=!!t},isEnabled:function(){this._getFlag("enabled")},setLevel:function(t){"number"==typeof t?this._level=t:"string"==typeof t&&(t=t.toUpperCase())in i&&(this._level=i[t])},getLevel:function(){return this._getFlag("level")},log:function(t,e){!e.length||!this._getFlag("enabled")||t>>0;if("function"!=typeof t)throw new TypeError(t+" is not a function");for(1n||t.top>e)},removeWildcardClass:function(t,e){var o;-1===e.indexOf("*")?t.removeClass(e):(o=(o=e.replace(/[\-\[\]{}()+?.,\\\^$|#\s]/g,"\\$&")).replace(/[*]/g,".*"),o=new RegExp("^"+o+"$"),t.filter("[class]").each(function(){for(var t=a(this),e=t.attr("class").split(/\s+/),n=[],i=0;i