mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 10:42:38 +00:00
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:
@@ -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
|
||||||
|
|||||||
23
app/javascript/dashboard/api/integrations/openapi.js
Normal file
23
app/javascript/dashboard/api/integrations/openapi.js
Normal 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();
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -111,7 +111,8 @@
|
|||||||
},
|
},
|
||||||
"PLACEHOLDER": {
|
"PLACEHOLDER": {
|
||||||
"AGENT": "Search agents",
|
"AGENT": "Search agents",
|
||||||
"TEAM": "Search teams"
|
"TEAM": "Search teams",
|
||||||
|
"INPUT": "Search for agents"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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...
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
39
lib/integrations/openai/processor_service.rb
Normal file
39
lib/integrations/openai/processor_service.rb
Normal 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
|
||||||
BIN
public/dashboard/images/integrations/openai.png
Normal file
BIN
public/dashboard/images/integrations/openai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
51
spec/lib/integrations/openai/processor_service_spec.rb
Normal file
51
spec/lib/integrations/openai/processor_service_spec.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user