feat: Ability to improve drafts in the editor using GPT integration (#6957)

ref: https://github.com/chatwoot/chatwoot/issues/6436
fixes: https://linear.app/chatwoot/issue/CW-1552/ability-to-rephrase-text-in-the-editor-using-gpt-integration

---------

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2023-04-24 23:52:23 +05:30
committed by GitHub
parent f6e0453bb2
commit 92fa9c4fdc
22 changed files with 480 additions and 7 deletions

View File

@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:update, :destroy] before_action :fetch_hook, except: [:create]
before_action :check_authorization before_action :check_authorization
def create def create
@@ -10,6 +10,10 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
@hook.update!(permitted_params.slice(:status, :settings)) @hook.update!(permitted_params.slice(:status, :settings))
end end
def process_event
render json: { message: @hook.process_event(params[:event]) }
end
def destroy def destroy
@hook.destroy! @hook.destroy!
head :ok head :ok

View File

@@ -0,0 +1,23 @@
/* global axios */
import ApiClient from '../ApiClient';
class OpenAIAPI extends ApiClient {
constructor() {
super('integrations', { accountScoped: true });
}
processEvent({ name = 'rephrase', content, tone, hookId }) {
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: name,
data: {
tone,
content,
},
},
});
}
}
export default new OpenAIAPI();

View File

@@ -0,0 +1,171 @@
<template>
<div v-if="isAIIntegrationEnabled" class="position-relative">
<woot-button
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleDropdown"
/>
<div
v-if="showDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open ai-modal"
>
<h4 class="sub-block-title margin-top-1">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
</h4>
<p>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
</p>
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
</label>
<div class="tone__item">
<select v-model="activeTone" class="status--filter small">
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
{{ tone.value }}
</option>
</select>
</div>
<div class="modal-footer flex-container align-right">
<woot-button variant="clear" size="small" @click="closeDropdown">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
</woot-button>
<woot-button
:is-loading="isGenerating"
size="small"
@click="processText"
>
{{ buttonText }}
</woot-button>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import OpenAPI from 'dashboard/api/integrations/openapi';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin, clickaway],
props: {
conversationId: {
type: Number,
default: 0,
},
message: {
type: String,
default: '',
},
},
data() {
return {
isGenerating: false,
showDropdown: false,
activeTone: 'professional',
tones: [
{
key: 'professional',
value: this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.PROFESSIONAL'
),
},
{
key: 'friendly',
value: this.$t('INTEGRATION_SETTINGS.OPEN_AI.TONE.OPTIONS.FRIENDLY'),
},
],
};
},
computed: {
...mapGetters({ appIntegrations: 'integrations/getAppIntegrations' }),
isAIIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
);
},
hookId() {
return this.appIntegrations.find(
integration => integration.id === 'openai' && !!integration.hooks.length
).hooks[0].id;
},
buttonText() {
return this.isGenerating
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
},
},
mounted() {
if (!this.appIntegrations.length) {
this.$store.dispatch('integrations/get');
}
},
methods: {
toggleDropdown() {
this.showDropdown = !this.showDropdown;
},
closeDropdown() {
this.showDropdown = false;
},
async processText() {
this.isGenerating = true;
try {
const result = await OpenAPI.processEvent({
hookId: this.hookId,
type: 'rephrase',
content: this.message,
tone: this.activeTone,
});
const {
data: { message: generatedMessage },
} = result;
this.$emit('replace-text', generatedMessage || this.message);
this.closeDropdown();
} catch (error) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
} finally {
this.isGenerating = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.ai-modal {
width: 400px;
right: 0;
left: 0;
padding: var(--space-normal);
bottom: 34px;
position: absolute;
span {
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
p {
color: var(--s-600);
}
label {
margin-bottom: var(--space-smaller);
}
.status--filter {
background-color: var(--color-background-light);
border: 1px solid var(--color-border);
font-size: var(--font-size-small);
height: var(--space-large);
padding: 0 var(--space-medium) 0 var(--space-small);
}
.modal-footer {
gap: var(--space-smaller);
}
}
</style>

View File

@@ -91,6 +91,12 @@
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote" v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
:conversation-id="conversationId" :conversation-id="conversationId"
/> />
<AIAssistanceButton
v-if="message"
:conversation-id="conversationId"
:message="message"
@replace-text="replaceText"
/>
<transition name="modal-fade"> <transition name="modal-fade">
<div <div
v-show="$refs.upload && $refs.upload.dropActive" v-show="$refs.upload && $refs.upload.dropActive"
@@ -129,12 +135,13 @@ import {
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP, ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
} from 'shared/constants/messages'; } from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton'; import VideoCallButton from '../VideoCallButton';
import AIAssistanceButton from '../AIAssistanceButton.vue';
import { REPLY_EDITOR_MODES } from './constants'; import { REPLY_EDITOR_MODES } from './constants';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
export default { export default {
name: 'ReplyBottomPanel', name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton }, components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin], mixins: [eventListenerMixins, uiSettingsMixin, inboxMixin],
props: { props: {
mode: { mode: {
@@ -217,6 +224,10 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
message: {
type: String,
default: '',
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
@@ -303,6 +314,9 @@ export default {
send_with_signature: !this.sendWithSignature, send_with_signature: !this.sendWithSignature,
}); });
}, },
replaceText(text) {
this.$emit('replace-text', text);
},
}, },
}; };
</script> </script>

View File

@@ -120,8 +120,10 @@
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause" :toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder" :toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker" :toggle-emoji-picker="toggleEmojiPicker"
:message="message"
@selectWhatsappTemplate="openWhatsappTemplateModal" @selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor" @toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
/> />
<whatsapp-templates <whatsapp-templates
:inbox-id="inbox.id" :inbox-id="inbox.id"

View File

@@ -111,7 +111,8 @@
}, },
"PLACEHOLDER": { "PLACEHOLDER": {
"AGENT": "Search agents", "AGENT": "Search agents",
"TEAM": "Search teams" "TEAM": "Search teams",
"INPUT": "Search for agents"
} }
} }
} }

View File

@@ -82,6 +82,23 @@
"JOIN_ERROR": "There was an error joining the call, please try again", "JOIN_ERROR": "There was an error joining the call, please try again",
"CREATE_ERROR": "There was an error creating a meeting link, please try again" "CREATE_ERROR": "There was an error creating a meeting link, please try again"
}, },
"OPEN_AI": {
"TITLE": "Improve With AI",
"SUBTITLE": "An improved reply will be generated using AI, based on your current draft.",
"TONE": {
"TITLE": "Tone",
"OPTIONS": {
"PROFESSIONAL": "Professional",
"FRIENDLY": "Friendly"
}
},
"BUTTONS": {
"GENERATE": "Generate",
"GENERATING": "Generating...",
"CANCEL": "Cancel"
},
"GENERATE_ERROR": "There was an error processing the content, please try again"
},
"DELETE": { "DELETE": {
"BUTTON_TEXT": "Delete", "BUTTON_TEXT": "Delete",
"API": { "API": {

View File

@@ -45,7 +45,7 @@
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM') $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM')
" "
:input-placeholder=" :input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.INPUT_PLACEHOLDER') $t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.INPUT')
" "
@click="onClickAssignTeam" @click="onClickAssignTeam"
/> />

View File

@@ -17,7 +17,9 @@ const state = {
}; };
const isAValidAppIntegration = integration => { const isAValidAppIntegration = integration => {
return ['dialogflow', 'dyte', 'google_translate'].includes(integration.id); return ['dialogflow', 'dyte', 'google_translate', 'openai'].includes(
integration.id
);
}; };
export const getters = { export const getters = {
getIntegrations($state) { getIntegrations($state) {

View File

@@ -178,6 +178,7 @@
"translate-outline": "M16.953 5.303a1 1 0 0 0-1.906-.606c-.124.389-.236.899-.324 1.344-.565.012-1.12 0-1.652-.038a1 1 0 1 0-.142 1.995c.46.032.934.048 1.416.047a25.649 25.649 0 0 0-.24 1.698c-1.263.716-2.142 1.684-2.636 2.7-.624 1.283-.7 2.857.239 3.883.675.736 1.704.758 2.499.588.322-.068.654-.176.988-.32a1 1 0 0 0 1.746-.93 13.17 13.17 0 0 0-.041-.115 8.404 8.404 0 0 0 2.735-4.06c.286.251.507.55.658.864.284.594.334 1.271.099 1.91-.234.633-.78 1.313-1.84 1.843a1 1 0 0 0 .895 1.789c1.44-.72 2.385-1.758 2.821-2.94a4.436 4.436 0 0 0-.17-3.464 4.752 4.752 0 0 0-2.104-2.165C19.998 9.22 20 9.11 20 9a1 1 0 0 0-1.974-.23 5.984 5.984 0 0 0-1.796.138c.047-.305.102-.626.166-.964a20.142 20.142 0 0 0 2.842-.473 1 1 0 0 0-.476-1.942c-.622.152-1.286.272-1.964.358.048-.208.1-.409.155-.584Zm-3.686 8.015c.166-.34.414-.697.758-1.037.02.348.053.67.098.973.083.56.207 1.048.341 1.477a3.41 3.41 0 0 1-.674.227c-.429.092-.588.019-.614.006l-.004-.001c-.162-.193-.329-.774.095-1.645Zm4.498-2.562a6.362 6.362 0 0 1-1.568 2.73 7.763 7.763 0 0 1-.095-.525 10.294 10.294 0 0 1-.088-1.904c.033-.013.067-.024.1-.036l1.651-.265Zm0 0-1.651.265c.602-.212 1.155-.29 1.651-.265ZM7.536 6.29a6.342 6.342 0 0 0-4.456.331 1 1 0 0 0 .848 1.811 4.342 4.342 0 0 1 3.049-.222c.364.107.568.248.69.37.12.123.203.27.257.454.067.225.087.446.09.69a8.195 8.195 0 0 0-.555-.117c-1.146-.199-2.733-.215-4.262.64-1.271.713-1.796 2.168-1.682 3.448.12 1.326.94 2.679 2.572 3.136 1.48.414 2.913-.045 3.877-.507l.08-.04a1 1 0 0 0 1.96-.281V10.5c0-.053.002-.12.005-.2.012-.417.034-1.16-.168-1.838a3.043 3.043 0 0 0-.755-1.29c-.394-.398-.91-.694-1.547-.881h-.003Zm-.419 5.288c.344.06.647.143.887.222v2.197a7.021 7.021 0 0 1-.905.524c-.792.38-1.682.605-2.473.384-.698-.195-1.06-.742-1.119-1.389-.062-.693.243-1.286.667-1.523.987-.553 2.06-.569 2.943-.415Z", "translate-outline": "M16.953 5.303a1 1 0 0 0-1.906-.606c-.124.389-.236.899-.324 1.344-.565.012-1.12 0-1.652-.038a1 1 0 1 0-.142 1.995c.46.032.934.048 1.416.047a25.649 25.649 0 0 0-.24 1.698c-1.263.716-2.142 1.684-2.636 2.7-.624 1.283-.7 2.857.239 3.883.675.736 1.704.758 2.499.588.322-.068.654-.176.988-.32a1 1 0 0 0 1.746-.93 13.17 13.17 0 0 0-.041-.115 8.404 8.404 0 0 0 2.735-4.06c.286.251.507.55.658.864.284.594.334 1.271.099 1.91-.234.633-.78 1.313-1.84 1.843a1 1 0 0 0 .895 1.789c1.44-.72 2.385-1.758 2.821-2.94a4.436 4.436 0 0 0-.17-3.464 4.752 4.752 0 0 0-2.104-2.165C19.998 9.22 20 9.11 20 9a1 1 0 0 0-1.974-.23 5.984 5.984 0 0 0-1.796.138c.047-.305.102-.626.166-.964a20.142 20.142 0 0 0 2.842-.473 1 1 0 0 0-.476-1.942c-.622.152-1.286.272-1.964.358.048-.208.1-.409.155-.584Zm-3.686 8.015c.166-.34.414-.697.758-1.037.02.348.053.67.098.973.083.56.207 1.048.341 1.477a3.41 3.41 0 0 1-.674.227c-.429.092-.588.019-.614.006l-.004-.001c-.162-.193-.329-.774.095-1.645Zm4.498-2.562a6.362 6.362 0 0 1-1.568 2.73 7.763 7.763 0 0 1-.095-.525 10.294 10.294 0 0 1-.088-1.904c.033-.013.067-.024.1-.036l1.651-.265Zm0 0-1.651.265c.602-.212 1.155-.29 1.651-.265ZM7.536 6.29a6.342 6.342 0 0 0-4.456.331 1 1 0 0 0 .848 1.811 4.342 4.342 0 0 1 3.049-.222c.364.107.568.248.69.37.12.123.203.27.257.454.067.225.087.446.09.69a8.195 8.195 0 0 0-.555-.117c-1.146-.199-2.733-.215-4.262.64-1.271.713-1.796 2.168-1.682 3.448.12 1.326.94 2.679 2.572 3.136 1.48.414 2.913-.045 3.877-.507l.08-.04a1 1 0 0 0 1.96-.281V10.5c0-.053.002-.12.005-.2.012-.417.034-1.16-.168-1.838a3.043 3.043 0 0 0-.755-1.29c-.394-.398-.91-.694-1.547-.881h-.003Zm-.419 5.288c.344.06.647.143.887.222v2.197a7.021 7.021 0 0 1-.905.524c-.792.38-1.682.605-2.473.384-.698-.195-1.06-.742-1.119-1.389-.062-.693.243-1.286.667-1.523.987-.553 2.06-.569 2.943-.415Z",
"eye-show-outline": "M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z", "eye-show-outline": "M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z",
"eye-hide-outline": "M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z", "eye-hide-outline": "M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z",
"wand-outline": "m13.314 7.565l-.136.126l-10.48 10.488a2.27 2.27 0 0 0 3.211 3.208L16.388 10.9a2.251 2.251 0 0 0-.001-3.182l-.157-.146a2.25 2.25 0 0 0-2.916-.007Zm-.848 2.961l1.088 1.088l-8.706 8.713a.77.77 0 1 1-1.089-1.088l8.707-8.713Zm4.386 4.48L16.75 15a.75.75 0 0 0-.743.648L16 15.75v.75h-.75a.75.75 0 0 0-.743.648l-.007.102c0 .38.282.694.648.743l.102.007H16v.75c0 .38.282.694.648.743l.102.007a.75.75 0 0 0 .743-.648l.007-.102V18h.75a.75.75 0 0 0 .743-.648L19 17.25a.75.75 0 0 0-.648-.743l-.102-.007h-.75v-.75a.75.75 0 0 0-.648-.743L16.75 15l.102.007Zm-1.553-6.254l.027.027a.751.751 0 0 1 0 1.061l-.711.713l-1.089-1.089l.73-.73a.75.75 0 0 1 1.043.018ZM6.852 5.007L6.75 5a.75.75 0 0 0-.743.648L6 5.75v.75h-.75a.75.75 0 0 0-.743.648L4.5 7.25c0 .38.282.693.648.743L5.25 8H6v.75c0 .38.282.693.648.743l.102.007a.75.75 0 0 0 .743-.648L7.5 8.75V8h.75a.75.75 0 0 0 .743-.648L9 7.25a.75.75 0 0 0-.648-.743L8.25 6.5H7.5v-.75a.75.75 0 0 0-.648-.743L6.75 5l.102.007Zm12-2L18.75 3a.75.75 0 0 0-.743.648L18 3.75v.75h-.75a.75.75 0 0 0-.743.648l-.007.102c0 .38.282.693.648.743L17.25 6H18v.75c0 .38.282.693.648.743l.102.007a.75.75 0 0 0 .743-.648l.007-.102V6h.75a.75.75 0 0 0 .743-.648L21 5.25a.75.75 0 0 0-.648-.743L20.25 4.5h-.75v-.75a.75.75 0 0 0-.648-.743L18.75 3l.102.007Z",
"grab-handle-outline": [ "grab-handle-outline": [
"M5 4C5 3.44772 5.44772 3 6 3H10C10.5523 3 11 3.44772 11 4V7C11 7.55228 10.5523 8 10 8H6C5.44772 8 5 7.55228 5 7V4Z", "M5 4C5 3.44772 5.44772 3 6 3H10C10.5523 3 11 3.44772 11 4V7C11 7.55228 10.5523 8 10 8H6C5.44772 8 5 7.55228 5 7V4Z",
"M13 4C13 3.44772 13.4477 3 14 3H18C18.5523 3 19 3.44772 19 4V7C19 7.55228 18.5523 8 18 8H14C13.4477 8 13 7.55228 13 7V4Z", "M13 4C13 3.44772 13.4477 3 14 3H18C18.5523 3 19 3.44772 19 4V7C19 7.55228 18.5523 8 18 8H14C13.4477 8 13 7.55228 13 7V4Z",

View File

@@ -25,6 +25,8 @@ class Integrations::Hook < ApplicationRecord
validate :validate_settings_json_schema validate :validate_settings_json_schema
validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } } validates :app_id, uniqueness: { scope: [:account_id], unless: -> { app.present? && app.params[:allow_multiple_hooks].present? } }
# TODO: This seems to be only used for slack at the moment
# We can add a validator when storing the integration settings and toggle this in future
enum status: { disabled: 0, enabled: 1 } enum status: { disabled: 0, enabled: 1 }
belongs_to :account belongs_to :account
@@ -45,6 +47,15 @@ class Integrations::Hook < ApplicationRecord
update(status: 'disabled') update(status: 'disabled')
end end
def process_event(event)
case app_id
when 'openai'
Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai'
else
'No processor found'
end
end
private private
def ensure_hook_type def ensure_hook_type

View File

@@ -7,6 +7,10 @@ class HookPolicy < ApplicationPolicy
@account_user.administrator? @account_user.administrator?
end end
def process_event?
true
end
def destroy? def destroy?
@account_user.administrator? @account_user.administrator?
end end

View File

@@ -128,3 +128,27 @@ google_translate:
}, },
] ]
visible_properties: ['project_id'] visible_properties: ['project_id']
openai:
id: openai
logo: openai.png
i18n_key: openai
action: /openai
hook_type: account
allow_multiple_hooks: false
settings_json_schema: {
"type": "object",
"properties": {
"api_key": { "type": "string" },
},
"required": ["api_key"],
"additionalProperties": false,
}
settings_form_schema: [
{
"label": "API Key",
"type": "text",
"name": "api_key",
"validation": "required",
},
]
visible_properties: ['api_key']

View File

@@ -187,6 +187,9 @@ en:
google_translate: google_translate:
name: "Google Translate" name: "Google Translate"
description: "Make it easier for agents to translate messages by adding a Google Translate Integration. Google translate helps to identify the language automatically and convert it to the language chosen by the agent/account admin." description: "Make it easier for agents to translate messages by adding a Google Translate Integration. Google translate helps to identify the language automatically and convert it to the language chosen by the agent/account admin."
openai:
name: "OpenAI"
description: "Integrate powerful AI features into Chatwoot by leveraging the GPT models from OpenAI."
public_portal: public_portal:
search: search:
search_placeholder: Search for article by title or body... search_placeholder: Search for article by title or body...

View File

@@ -177,7 +177,11 @@ Rails.application.routes.draw do
resources :webhooks, only: [:index, :create, :update, :destroy] resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do namespace :integrations do
resources :apps, only: [:index, :show] resources :apps, only: [:index, :show]
resources :hooks, only: [:create, :update, :destroy] resources :hooks, only: [:create, :update, :destroy] do
member do
post :process_event
end
end
resource :slack, only: [:create, :update, :destroy], controller: 'slack' resource :slack, only: [:create, :update, :destroy], controller: 'slack'
resource :dyte, controller: 'dyte', only: [] do resource :dyte, controller: 'dyte', only: [] do
collection do collection do

View File

@@ -0,0 +1,39 @@
class Integrations::Openai::ProcessorService
pattr_initialize [:hook!, :event!]
def perform
rephrase_message if event['name'] == 'rephrase'
end
private
def rephrase_body(tone, message)
{
model: 'gpt-3.5-turbo',
messages: [
{ role: 'system', content: "You are a helpful support agent. Please rephrase the following response to a more #{tone} tone." },
{ role: 'user', content: message }
]
}.to_json
end
def rephrase_message
response = make_api_call(rephrase_body(event['data']['tone'], event['data']['content']))
JSON.parse(response)['choices'].first['message']['content']
end
def make_api_call(body)
headers = {
'Content-Type' => 'application/json',
'Authorization' => "Bearer #{hook.settings['api_key']}"
}
response = HTTParty.post(
'https://api.openai.com/v1/chat/completions',
headers: headers,
body: body
)
response.body
end
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -28,6 +28,18 @@ RSpec.describe 'Integration Apps API', type: :request do
expect(apps['action']).to be_nil expect(apps['action']).to be_nil
end end
it 'will not return sensitive information for openai app for agents' do
openai = create(:integrations_hook, :openai, account: account)
get api_v1_account_integrations_apps_url(account),
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
app = JSON.parse(response.body)['payload'].find { |int_app| int_app['id'] == openai.app.id }
expect(app['hooks'].first['settings']).to be_nil
end
it 'returns all active apps with sensitive information if user is an admin' do it 'returns all active apps with sensitive information if user is an admin' do
first_app = Integrations::App.all.find(&:active?) first_app = Integrations::App.all.find(&:active?)
get api_v1_account_integrations_apps_url(account), get api_v1_account_integrations_apps_url(account),
@@ -53,6 +65,18 @@ RSpec.describe 'Integration Apps API', type: :request do
expect(slack_app['action']).to include('client_id=client_id') expect(slack_app['action']).to include('client_id=client_id')
end end
end end
it 'will return sensitive information for openai app for admins' do
openai = create(:integrations_hook, :openai, account: account)
get api_v1_account_integrations_apps_url(account),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
app = JSON.parse(response.body)['payload'].find { |int_app| int_app['id'] == openai.app.id }
expect(app['hooks'].first['settings']).not_to be_nil
end
end end
end end
@@ -65,7 +89,8 @@ RSpec.describe 'Integration Apps API', type: :request do
end end
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :administrator) } let(:agent) { create(:user, account: account, role: :agent) }
let(:admin) { create(:user, account: account, role: :administrator) }
it 'returns details of the app' do it 'returns details of the app' do
get api_v1_account_integrations_app_url(account_id: account.id, id: 'slack'), get api_v1_account_integrations_app_url(account_id: account.id, id: 'slack'),
@@ -77,6 +102,30 @@ RSpec.describe 'Integration Apps API', type: :request do
expect(app['id']).to eql('slack') expect(app['id']).to eql('slack')
expect(app['name']).to eql('Slack') expect(app['name']).to eql('Slack')
end end
it 'will not return sensitive information for openai app for agents' do
openai = create(:integrations_hook, :openai, account: account)
get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id),
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
app = JSON.parse(response.body)
expect(app['hooks'].first['settings']).to be_nil
end
it 'will return sensitive information for openai app for admins' do
openai = create(:integrations_hook, :openai, account: account)
get api_v1_account_integrations_app_url(account_id: account.id, id: openai.app.id),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
app = JSON.parse(response.body)
expect(app['hooks'].first['settings']).not_to be_nil
end
end end
end end
end end

View File

@@ -77,6 +77,33 @@ RSpec.describe 'Integration Hooks API', type: :request do
end end
end end
describe 'POST /api/v1/accounts/{account.id}/integrations/hooks/{hook_id}/process_event' do
let(:hook) { create(:integrations_hook, account: account) }
let(:params) { { event: 'rephrase', payload: { test: 'test' } } }
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post process_event_api_v1_account_integrations_hook_url(account_id: account.id, id: hook.id),
params: params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
it 'will process the events' do
post process_event_api_v1_account_integrations_hook_url(account_id: account.id, id: hook.id),
params: params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['message']).to eq('No processor found')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/integrations/hooks/{hook_id}' do describe 'DELETE /api/v1/accounts/{account.id}/integrations/hooks/{hook_id}' do
let(:hook) { create(:integrations_hook, account: account) } let(:hook) { create(:integrations_hook, account: account) }

View File

@@ -21,5 +21,10 @@ FactoryBot.define do
app_id { 'google_translate' } app_id { 'google_translate' }
settings { { project_id: 'test', credentials: {} } } settings { { project_id: 'test', credentials: {} } }
end end
trait :openai do
app_id { 'openai' }
settings { { api_key: 'api_key' } }
end
end end
end end

View File

@@ -0,0 +1,51 @@
require 'rails_helper'
RSpec.describe Integrations::Openai::ProcessorService do
subject { described_class.new(hook: hook, event: event) }
let(:hook) { create(:integrations_hook, :openai) }
let(:expected_headers) { { 'Authorization' => "Bearer #{hook.settings['api_key']}" } }
let(:openai_response) do
{
'choices' => [
{
'message' => {
'content' => 'This is a rephrased test message.'
}
}
]
}.to_json
end
describe '#perform' do
context 'when event name is rephrase' do
let(:event) { { 'name' => 'rephrase', 'data' => { 'tone' => 'friendly', 'content' => 'This is a test message' } } }
it 'returns the rephrased message using the tone in data' do
request_body = {
'model' => 'gpt-3.5-turbo',
'messages' => [
{ 'role' => 'system',
'content' => "You are a helpful support agent. Please rephrase the following response to a more #{event['data']['tone']} tone." },
{ 'role' => 'user', 'content' => event['data']['content'] }
]
}.to_json
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
.with(body: request_body, headers: expected_headers)
.to_return(status: 200, body: openai_response, headers: {})
result = subject.perform
expect(result).to eq('This is a rephrased test message.')
end
end
context 'when event name is not rephrase' do
let(:event) { { 'name' => 'unknown', 'data' => {} } }
it 'returns nil' do
expect(subject.perform).to be_nil
end
end
end
end

View File

@@ -27,4 +27,25 @@ RSpec.describe Integrations::Hook, type: :model do
end end
end end
end end
describe 'process_event' do
let(:account) { create(:account) }
let(:params) { { event: 'rephrase', payload: { test: 'test' } } }
it 'returns no processor found for hooks with out processor defined' do
hook = create(:integrations_hook, account: account)
expect(hook.process_event(params)).to eq('No processor found')
end
it 'returns results from procesor for openai hook' do
hook = create(:integrations_hook, :openai, account: account)
openai_double = double
allow(Integrations::Openai::ProcessorService).to receive(:new).and_return(openai_double)
allow(openai_double).to receive(:perform).and_return('test')
expect(hook.process_event(params)).to eq('test')
expect(Integrations::Openai::ProcessorService).to have_received(:new).with(event: params, hook: hook)
expect(openai_double).to have_received(:perform)
end
end
end end