Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc60b6b6fa | ||
|
|
2484a4f074 | ||
|
|
c179393a0b | ||
|
|
ce822e1e58 | ||
|
|
90e29883fa | ||
|
|
0b69b13469 | ||
|
|
cca0cf740b | ||
|
|
b67ba61bda | ||
|
|
011bbca28f | ||
|
|
b493870943 | ||
|
|
b66cd77c8d | ||
|
|
1da0a2ff0f | ||
|
|
8d03354a4c | ||
|
|
eee6ccc6f5 | ||
|
|
ab1cb7af86 | ||
|
|
7fba7b946e | ||
|
|
f63cef20bf | ||
|
|
61425b6a3b | ||
|
|
82f5dbe6c1 | ||
|
|
7f057127b0 | ||
|
|
c38dfdb739 | ||
|
|
ead5b46620 | ||
|
|
772eeafecb | ||
|
|
8f1cd32fab | ||
|
|
1a95a99710 | ||
|
|
0fe2bf8647 |
@@ -0,0 +1,134 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
/**
|
||||
* A client for the Captain Tasks API.
|
||||
* @extends ApiClient
|
||||
*/
|
||||
class TasksAPI extends ApiClient {
|
||||
/**
|
||||
* Creates a new TasksAPI instance.
|
||||
*/
|
||||
constructor() {
|
||||
super('captain/tasks', { accountScoped: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an event using the Captain Tasks API.
|
||||
* @param {Object} options - The options for the event.
|
||||
* @param {string} [options.type='improve'] - The type of event to process.
|
||||
* @param {string} [options.content] - The content of the event.
|
||||
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the result of the event processing.
|
||||
*/
|
||||
processEvent({ type = 'improve', content, conversationId }, signal) {
|
||||
// Route to appropriate endpoint based on type
|
||||
if (type === 'summarize') {
|
||||
return this.summarize(conversationId, signal);
|
||||
}
|
||||
|
||||
if (type === 'reply_suggestion') {
|
||||
return this.replySuggestion(conversationId, signal);
|
||||
}
|
||||
|
||||
if (type === 'label_suggestion') {
|
||||
return this.labelSuggestion(conversationId, signal);
|
||||
}
|
||||
|
||||
// All other types are rewrite operations
|
||||
return this.rewrite({ content, operation: type, conversationId }, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites content with a specific operation.
|
||||
* @param {Object} options - The rewrite options.
|
||||
* @param {string} options.content - The content to rewrite.
|
||||
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
|
||||
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the rewritten content.
|
||||
*/
|
||||
rewrite({ content, operation, conversationId }, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/rewrite`,
|
||||
{
|
||||
content,
|
||||
operation,
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes a conversation.
|
||||
* @param {string} conversationId - The conversation ID to summarize.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the summary.
|
||||
*/
|
||||
summarize(conversationId, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/summarize`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a reply suggestion for a conversation.
|
||||
* @param {string} conversationId - The conversation ID.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the reply suggestion.
|
||||
*/
|
||||
replySuggestion(conversationId, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/reply_suggestion`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets label suggestions for a conversation.
|
||||
* @param {string} conversationId - The conversation ID.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with label suggestions.
|
||||
*/
|
||||
labelSuggestion(conversationId, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/label_suggestion`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to continue refining a previous task result.
|
||||
* @param {Object} options - The follow-up options.
|
||||
* @param {Object} options.followUpContext - The follow-up context from a previous task.
|
||||
* @param {string} options.message - The follow-up message/request from the user.
|
||||
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
|
||||
*/
|
||||
followUp({ followUpContext, message, conversationId }, signal) {
|
||||
return axios.post(
|
||||
`${this.url}/follow_up`,
|
||||
{
|
||||
follow_up_context: followUpContext,
|
||||
message,
|
||||
conversation_display_id: conversationId,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new TasksAPI();
|
||||
@@ -1,81 +0,0 @@
|
||||
/* global axios */
|
||||
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
/**
|
||||
* Represents the data object for a OpenAI hook.
|
||||
* @typedef {Object} ConversationMessageData
|
||||
* @property {string} [tone] - The tone of the message.
|
||||
* @property {string} [content] - The content of the message.
|
||||
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A client for the OpenAI API.
|
||||
* @extends ApiClient
|
||||
*/
|
||||
class OpenAIAPI extends ApiClient {
|
||||
/**
|
||||
* Creates a new OpenAIAPI instance.
|
||||
*/
|
||||
constructor() {
|
||||
super('integrations', { accountScoped: true });
|
||||
|
||||
/**
|
||||
* The conversation events supported by the API.
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.conversation_events = [
|
||||
'summarize',
|
||||
'reply_suggestion',
|
||||
'label_suggestion',
|
||||
];
|
||||
|
||||
/**
|
||||
* The message events supported by the API.
|
||||
* @type {string[]}
|
||||
*/
|
||||
this.message_events = ['rephrase'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an event using the OpenAI API.
|
||||
* @param {Object} options - The options for the event.
|
||||
* @param {string} [options.type='rephrase'] - The type of event to process.
|
||||
* @param {string} [options.content] - The content of the event.
|
||||
* @param {string} [options.tone] - The tone of the event.
|
||||
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
|
||||
* @param {string} options.hookId - The ID of the hook to use for processing the event.
|
||||
* @returns {Promise} A promise that resolves with the result of the event processing.
|
||||
*/
|
||||
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
|
||||
/**
|
||||
* @type {ConversationMessageData}
|
||||
*/
|
||||
let data = {
|
||||
tone,
|
||||
content,
|
||||
};
|
||||
|
||||
// Always include conversation_display_id when available for session tracking
|
||||
if (conversationId) {
|
||||
data.conversation_display_id = conversationId;
|
||||
}
|
||||
|
||||
// For conversation-level events, only send conversation_display_id
|
||||
if (this.conversation_events.includes(type)) {
|
||||
data = {
|
||||
conversation_display_id: conversationId,
|
||||
};
|
||||
}
|
||||
|
||||
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
|
||||
event: {
|
||||
name: type,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new OpenAIAPI();
|
||||
@@ -94,6 +94,19 @@
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--violet-1: 253 252 254;
|
||||
--violet-2: 250 248 255;
|
||||
--violet-3: 244 240 254;
|
||||
--violet-4: 235 228 255;
|
||||
--violet-5: 225 217 255;
|
||||
--violet-6: 212 202 254;
|
||||
--violet-7: 194 178 248;
|
||||
--violet-8: 169 153 236;
|
||||
--violet-9: 110 86 207;
|
||||
--violet-10: 100 84 196;
|
||||
--violet-11: 101 85 183;
|
||||
--violet-12: 47 38 95;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
@@ -209,6 +222,19 @@
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--violet-1: 20 17 31;
|
||||
--violet-2: 27 21 37;
|
||||
--violet-3: 41 31 67;
|
||||
--violet-4: 50 37 85;
|
||||
--violet-5: 60 46 105;
|
||||
--violet-6: 71 56 135;
|
||||
--violet-7: 86 70 151;
|
||||
--violet-8: 110 86 171;
|
||||
--violet-9: 110 86 207;
|
||||
--violet-10: 125 109 217;
|
||||
--violet-11: 169 153 236;
|
||||
--violet-12: 226 221 254;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
|
||||
@@ -485,12 +485,12 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-briefcase',
|
||||
to: accountScopedRoute('general_settings_index'),
|
||||
},
|
||||
// {
|
||||
// name: 'Settings Captain',
|
||||
// label: t('SIDEBAR.CAPTAIN_AI'),
|
||||
// icon: 'i-woot-captain',
|
||||
// to: accountScopedRoute('captain_settings_index'),
|
||||
// },
|
||||
{
|
||||
name: 'Settings Captain',
|
||||
label: t('SIDEBAR.CAPTAIN_AI'),
|
||||
icon: 'i-woot-captain',
|
||||
to: accountScopedRoute('captain_settings_index'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Agents',
|
||||
label: t('SIDEBAR.AGENTS'),
|
||||
|
||||
@@ -48,9 +48,10 @@ export default {
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
async generateAIContent(type = 'rephrase') {
|
||||
async generateAIContent(type = 'improve') {
|
||||
this.isGenerating = true;
|
||||
this.generatedContent = await this.processEvent(type);
|
||||
const { message } = await this.processEvent(type);
|
||||
this.generatedContent = message;
|
||||
this.isGenerating = false;
|
||||
},
|
||||
applyText() {
|
||||
|
||||
@@ -46,11 +46,11 @@ const fileName = file => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex overflow-auto max-h-[12.5rem]">
|
||||
<div class="flex flex-wrap gap-y-1 gap-x-2 overflow-auto max-h-[12.5rem]">
|
||||
<div
|
||||
v-for="(attachment, index) in nonRecordedAudioAttachments"
|
||||
:key="attachment.id"
|
||||
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem] mb-1"
|
||||
class="flex items-center p-1 bg-n-slate-3 gap-1 rounded-md w-[15rem]"
|
||||
>
|
||||
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
|
||||
<img
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
|
||||
|
||||
import {
|
||||
buildMessageSchema,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
EditorState,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Give copilot additional prompts, or ask anything else...',
|
||||
},
|
||||
generatedContent: { type: String, default: '' },
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'blur',
|
||||
'input',
|
||||
'update:modelValue',
|
||||
'keyup',
|
||||
'focus',
|
||||
'keydown',
|
||||
'send',
|
||||
]);
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
// Minimal schema with no marks or nodes for copilot input
|
||||
const copilotSchema = buildMessageSchema([], []);
|
||||
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
enabledMenuOptions = []
|
||||
) => {
|
||||
return EditorState.create({
|
||||
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
|
||||
plugins: buildEditor({
|
||||
schema: copilotSchema,
|
||||
placeholder,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// we don't need them to be reactive
|
||||
// It cases weird issues where the objects are proxied
|
||||
// and then the editor doesn't work as expected
|
||||
let editorView = null;
|
||||
let state = null;
|
||||
|
||||
// reactive data
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
|
||||
// element refs
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
function contentFromEditor() {
|
||||
if (editorView) {
|
||||
return MessageMarkdownSerializer.serialize(editorView.state.doc);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function focusEditorInputField() {
|
||||
const { tr } = editorView.state;
|
||||
const selection = Selection.atEnd(tr.doc);
|
||||
|
||||
editorView.dispatch(tr.setSelection(selection));
|
||||
editorView.focus();
|
||||
}
|
||||
|
||||
function emitOnChange() {
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
emit('input', contentFromEditor());
|
||||
}
|
||||
|
||||
function onKeyup() {
|
||||
emit('keyup');
|
||||
}
|
||||
|
||||
function onKeydown() {
|
||||
emit('keydown');
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
emit('blur');
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
emit('focus');
|
||||
}
|
||||
|
||||
function checkSelection(editorState) {
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
isTextSelected.value = hasSelection;
|
||||
}
|
||||
|
||||
// computed properties
|
||||
const plugins = computed(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
const enabledMenuOptions = computed(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
function reloadState() {
|
||||
state = createState(
|
||||
props.modelValue,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
enabledMenuOptions.value
|
||||
);
|
||||
editorView.updateState(state);
|
||||
focusEditorInputField();
|
||||
}
|
||||
|
||||
function createEditorView() {
|
||||
editorView = new EditorView(editor.value, {
|
||||
state: state,
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
if (tx.docChanged) {
|
||||
emitOnChange();
|
||||
}
|
||||
checkSelection(state);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: onKeyup,
|
||||
focus: onFocus,
|
||||
blur: onBlur,
|
||||
keydown: onKeydown,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
emit('send');
|
||||
}
|
||||
|
||||
// watchers
|
||||
watch(
|
||||
computed(() => props.modelValue),
|
||||
(newValue = '') => {
|
||||
if (newValue !== contentFromEditor()) {
|
||||
reloadState();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.editorId),
|
||||
() => {
|
||||
reloadState();
|
||||
}
|
||||
);
|
||||
|
||||
// lifecycle
|
||||
onMounted(() => {
|
||||
state = createState(
|
||||
props.modelValue,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
enabledMenuOptions.value
|
||||
);
|
||||
|
||||
createEditorView();
|
||||
editorView.updateState(state);
|
||||
|
||||
if (props.autofocus) {
|
||||
focusEditorInputField();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 mb-4">
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
|
||||
>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(generatedContent, false)"
|
||||
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4 underline decoration-n-iris-8 underline-offset-auto decoration-solid decoration-[10%]"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor-root relative editor--copilot space-x-2">
|
||||
<div ref="editor" />
|
||||
<div class="flex items-center justify-end absolute right-2 bottom-2">
|
||||
<NextButton
|
||||
class="bg-n-iris-9 text-white !rounded-full"
|
||||
icon="i-lucide-arrow-up"
|
||||
solid
|
||||
sm
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||
|
||||
.editor--copilot {
|
||||
@apply bg-n-iris-5 rounded;
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
min-height: 5rem;
|
||||
max-height: 7.5rem !important;
|
||||
overflow: auto;
|
||||
@apply px-2 !important;
|
||||
|
||||
.empty-node {
|
||||
&::before {
|
||||
@apply text-n-iris-9 dark:text-n-iris-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['executeCopilotAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { draftMessage } = useAI();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
// for now, we don't allow improving just aprt of the selection
|
||||
// we will add this feature later. Once we do, we can revert the change
|
||||
const hasSelection = false;
|
||||
// const hasSelection = props.hasSelection
|
||||
|
||||
if (hasSelection) {
|
||||
items.push({
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
|
||||
),
|
||||
key: 'improve_selection',
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
} else if (
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
draftMessage.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
key: 'improve',
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
}
|
||||
|
||||
if (draftMessage.value) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
|
||||
),
|
||||
key: 'change_tone',
|
||||
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
|
||||
subMenuItems: [
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
|
||||
),
|
||||
key: 'professional',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
|
||||
),
|
||||
key: 'casual',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
|
||||
),
|
||||
key: 'straightforward',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
|
||||
),
|
||||
key: 'confident',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
|
||||
),
|
||||
key: 'friendly',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
|
||||
key: 'fix_spelling_grammar',
|
||||
icon: 'i-fluent-flow-sparkle-24-regular',
|
||||
}
|
||||
);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const generalMenuItems = computed(() => {
|
||||
const items = [];
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
|
||||
key: 'reply_suggestion',
|
||||
icon: 'i-fluent-chat-sparkle-16-regular',
|
||||
});
|
||||
}
|
||||
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
|
||||
key: 'summarize',
|
||||
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
|
||||
key: 'ask_copilot',
|
||||
icon: 'i-fluent-circle-sparkle-24-regular',
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const menuRef = useTemplateRef('menuRef');
|
||||
const { height: menuHeight } = useElementSize(menuRef);
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
// Smart submenu positioning based on available space
|
||||
const submenuPosition = computed(() => {
|
||||
const el = menuRef.value?.$el;
|
||||
if (!el) return 'ltr:right-full rtl:left-full';
|
||||
|
||||
const { left, right } = el.getBoundingClientRect();
|
||||
const SUBMENU_WIDTH = 200;
|
||||
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
|
||||
const spaceLeft = left;
|
||||
|
||||
// Prefer right, fallback to side with more space
|
||||
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
|
||||
|
||||
return showRight ? 'left-full' : 'right-full';
|
||||
});
|
||||
|
||||
// Computed style for selection menu positioning (only dynamic top offset)
|
||||
const selectionMenuStyle = computed(() => {
|
||||
// Dynamically calculate offset based on actual menu height + 10px gap
|
||||
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
|
||||
|
||||
return {
|
||||
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
const handleMenuItemClick = item => {
|
||||
// For items with submenus, do nothing on click (hover will show submenu)
|
||||
if (!item.subMenuItems) {
|
||||
emit('executeCopilotAction', item.key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubMenuItemClick = (parentItem, subItem) => {
|
||||
emit('executeCopilotAction', subItem.key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownBody
|
||||
ref="menuRef"
|
||||
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
|
||||
:class="{ 'selection-menu': hasSelection }"
|
||||
:style="hasSelection ? selectionMenuStyle : {}"
|
||||
>
|
||||
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
class="w-full relative group/submenu"
|
||||
>
|
||||
<Button
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
|
||||
@click="handleMenuItemClick(item)"
|
||||
>
|
||||
<template v-if="item.subMenuItems" #default>
|
||||
<div class="flex items-center gap-1 justify-between w-full">
|
||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-right"
|
||||
class="text-n-slate-10 size-3"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<!-- Hover Submenu -->
|
||||
<DropdownBody
|
||||
v-if="item.subMenuItems"
|
||||
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
|
||||
:class="submenuPosition"
|
||||
>
|
||||
<Button
|
||||
v-for="subItem in item.subMenuItems"
|
||||
:key="subItem.key + subItem.label"
|
||||
:label="subItem.label"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
|
||||
@click="handleSubMenuItemClick(item, subItem)"
|
||||
/>
|
||||
</DropdownBody>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
|
||||
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<Button
|
||||
v-for="(item, index) in generalMenuItems"
|
||||
:key="index"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
|
||||
@click="handleMenuItemClick(item)"
|
||||
/>
|
||||
</div>
|
||||
</DropdownBody>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.selection-menu {
|
||||
position: absolute !important;
|
||||
|
||||
// Default/LTR: position from left
|
||||
left: var(--selection-left);
|
||||
|
||||
// RTL: position from right instead
|
||||
[dir='rtl'] & {
|
||||
left: auto;
|
||||
right: var(--selection-right);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
|
||||
defineProps({
|
||||
isGeneratingContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
const { t } = useI18n();
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
const acceptLabel = computed(() => {
|
||||
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between items-center p-3 pt-0">
|
||||
<NextButton
|
||||
:label="t('GENERAL.DISCARD')"
|
||||
slate
|
||||
link
|
||||
class="!px-1 hover:!no-underline"
|
||||
sm
|
||||
:disabled="isGeneratingContent"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<NextButton
|
||||
:label="acceptLabel"
|
||||
class="bg-n-iris-9 text-white"
|
||||
solid
|
||||
sm
|
||||
:disabled="isGeneratingContent"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@ import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import TagTools from '../conversation/TagTools.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -23,6 +24,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
@@ -100,6 +102,7 @@ const emit = defineEmits([
|
||||
'focus',
|
||||
'input',
|
||||
'update:modelValue',
|
||||
'executeCopilotAction',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -107,6 +110,7 @@ const { t } = useI18n();
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote';
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(props.channelType, props.medium)
|
||||
@@ -116,7 +120,7 @@ const editorSchema = computed(() => {
|
||||
if (!props.channelType) return messageSchema;
|
||||
|
||||
const formatType = props.isPrivate
|
||||
? DEFAULT_FORMATTING
|
||||
? PRIVATE_NOTE_FORMATTING
|
||||
: effectiveChannelType.value;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return buildMessageSchema(formatting.marks, formatting.nodes);
|
||||
@@ -124,7 +128,7 @@ const editorSchema = computed(() => {
|
||||
|
||||
const editorMenuOptions = computed(() => {
|
||||
const formatType = props.isPrivate
|
||||
? DEFAULT_FORMATTING
|
||||
? PRIVATE_NOTE_FORMATTING
|
||||
: effectiveChannelType.value || DEFAULT_FORMATTING;
|
||||
const formatting = getFormattingForEditor(formatType);
|
||||
return formatting.menu;
|
||||
@@ -185,6 +189,21 @@ const editorRoot = useTemplateRef('editorRoot');
|
||||
const imageUpload = useTemplateRef('imageUpload');
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
if (actionKey === 'improve_selection' && editorView?.state) {
|
||||
const { from, to } = editorView.state.selection;
|
||||
const selectedText = editorView.state.doc.textBetween(from, to).trim();
|
||||
|
||||
if (from !== to && selectedText) {
|
||||
emit('executeCopilotAction', 'improve', selectedText);
|
||||
}
|
||||
} else {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
}
|
||||
|
||||
showSelectionMenu.value = false;
|
||||
};
|
||||
|
||||
const contentFromEditor = () => {
|
||||
return MessageMarkdownSerializer.serialize(editorView.state.doc);
|
||||
};
|
||||
@@ -367,13 +386,23 @@ function openFileBrowser() {
|
||||
imageUpload.value.click();
|
||||
}
|
||||
|
||||
function handleCopilotClick() {
|
||||
showSelectionMenu.value = !showSelectionMenu.value;
|
||||
}
|
||||
|
||||
function handleClickOutside(event) {
|
||||
// Check if the clicked element or its parents have the ignored class
|
||||
if (event.target.closest('.ProseMirror-copilot')) return;
|
||||
showSelectionMenu.value = false;
|
||||
}
|
||||
|
||||
function reloadState(content = props.modelValue) {
|
||||
const unrefContent = unref(content);
|
||||
state = createState(
|
||||
unrefContent,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
{ onImageUpload: openFileBrowser },
|
||||
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
|
||||
editorMenuOptions.value
|
||||
);
|
||||
|
||||
@@ -595,7 +624,12 @@ function insertContentIntoEditor(content, defaultFrom = 0) {
|
||||
const from = defaultFrom || editorView.state.selection.from || 0;
|
||||
// Use the editor's current schema to ensure compatibility with buildMessageSchema
|
||||
const currentSchema = editorView.state.schema;
|
||||
let node = new MessageMarkdownTransformer(currentSchema).parse(content);
|
||||
// Strip unsupported formatting before parsing to ensure content can be inserted
|
||||
// into channels that don't support certain markdown features (e.g., API channels)
|
||||
const sanitizedContent = stripUnsupportedFormatting(content, currentSchema);
|
||||
let node = new MessageMarkdownTransformer(currentSchema).parse(
|
||||
sanitizedContent
|
||||
);
|
||||
|
||||
insertNodeIntoEditor(node, from, undefined);
|
||||
}
|
||||
@@ -757,7 +791,7 @@ onMounted(() => {
|
||||
props.modelValue,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
{ onImageUpload: openFileBrowser },
|
||||
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
|
||||
editorMenuOptions.value
|
||||
);
|
||||
|
||||
@@ -802,6 +836,14 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
:search-key="toolSearchKey"
|
||||
@select-tool="content => insertSpecialContent('tool', content)"
|
||||
/>
|
||||
<CopilotMenuBar
|
||||
v-if="showSelectionMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="isTextSelected"
|
||||
:show-selection-menu="showSelectionMenu"
|
||||
:show-general-menu="false"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
<input
|
||||
ref="imageUpload"
|
||||
type="file"
|
||||
@@ -855,6 +897,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
@apply size-full;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-copilot svg {
|
||||
@apply fill-n-violet-9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -994,6 +1040,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
.ProseMirror-icon {
|
||||
@apply p-0.5 flex-shrink-0;
|
||||
}
|
||||
|
||||
.ProseMirror-copilot svg {
|
||||
@apply fill-n-violet-9;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menu-active {
|
||||
|
||||
@@ -12,6 +12,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isReplyRestricted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggleMode']);
|
||||
@@ -24,11 +28,17 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
|
||||
|
||||
/**
|
||||
* Computed boolean indicating if the editor is in private note mode
|
||||
* When disabled, always show NOTE mode regardless of actual mode prop
|
||||
* When isReplyRestricted is true, force switch to private note
|
||||
* Otherwise, respect the current mode prop
|
||||
* @type {ComputedRef<boolean>}
|
||||
*/
|
||||
const isPrivate = computed(() => {
|
||||
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
if (props.isReplyRestricted) {
|
||||
// Force switch to private note when replies are restricted
|
||||
return true;
|
||||
}
|
||||
// Otherwise respect the current mode
|
||||
return props.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -60,9 +70,9 @@ const translateValue = computed(() => {
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
|
||||
:disabled="disabled"
|
||||
:disabled="disabled || isReplyRestricted"
|
||||
:class="{
|
||||
'cursor-not-allowed': disabled,
|
||||
'cursor-not-allowed': disabled || isReplyRestricted,
|
||||
}"
|
||||
@click="$emit('toggleMode')"
|
||||
>
|
||||
@@ -75,7 +85,7 @@ const translateValue = computed(() => {
|
||||
<div
|
||||
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
|
||||
:class="{
|
||||
'transition-all duration-300': !disabled,
|
||||
'transition-all duration-300': !disabled && !isReplyRestricted,
|
||||
}"
|
||||
:style="{
|
||||
'--chip-width': width,
|
||||
|
||||
@@ -9,14 +9,13 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
components: { NextButton, FileUpload, VideoCallButton },
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
isNote: {
|
||||
@@ -98,6 +97,7 @@ export default {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
@@ -370,13 +370,6 @@ export default {
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<AIAssistanceButton
|
||||
v-if="!isFetchingAppIntegrations"
|
||||
:conversation-id="conversationId"
|
||||
:is-private-note="isOnPrivateNote"
|
||||
:message="message"
|
||||
@replace-text="replaceText"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import EditorModeToggle from './EditorModeToggle.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyTopPanel',
|
||||
components: {
|
||||
NextButton,
|
||||
EditorModeToggle,
|
||||
CopilotMenuBar,
|
||||
},
|
||||
directives: {
|
||||
OnClickOutside: vOnClickOutside,
|
||||
},
|
||||
props: {
|
||||
mode: {
|
||||
@@ -19,6 +26,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
@@ -28,7 +39,7 @@ export default {
|
||||
default: () => 0,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout'],
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
@@ -47,6 +58,21 @@ export default {
|
||||
: REPLY_EDITOR_MODES.REPLY;
|
||||
setReplyMode(newMode);
|
||||
};
|
||||
const showCopilotMenu = ref(false);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
const toggleCopilotMenu = () => {
|
||||
showCopilotMenu.value = !showCopilotMenu.value;
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyP': {
|
||||
action: () => handleNoteClick(),
|
||||
@@ -64,6 +90,10 @@ export default {
|
||||
handleReplyClick,
|
||||
handleNoteClick,
|
||||
REPLY_EDITOR_MODES,
|
||||
handleCopilotAction,
|
||||
showCopilotMenu,
|
||||
toggleCopilotMenu,
|
||||
handleClickOutside,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -90,11 +120,13 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
|
||||
<div
|
||||
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
|
||||
>
|
||||
<EditorModeToggle
|
||||
:mode="mode"
|
||||
:disabled="isReplyRestricted"
|
||||
class="mt-3"
|
||||
:disabled="disabled"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
@toggle-mode="handleModeToggle"
|
||||
/>
|
||||
<div class="flex items-center mx-4 my-0">
|
||||
@@ -104,11 +136,34 @@ export default {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<NextButton
|
||||
ghost
|
||||
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('togglePopout')"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
ghost
|
||||
:disabled="disabled"
|
||||
:class="{
|
||||
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
|
||||
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
|
||||
}"
|
||||
sm
|
||||
icon="i-ph-sparkle-fill"
|
||||
@click="toggleCopilotMenu"
|
||||
/>
|
||||
<CopilotMenuBar
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="false"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
ghost
|
||||
class="text-n-slate-11"
|
||||
sm
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('togglePopout')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import CopilotEditor from 'dashboard/components/widgets/WootWriter/CopilotEditor.vue';
|
||||
import CaptainLoader from 'dashboard/components/widgets/conversation/copilot/CaptainLoader.vue';
|
||||
|
||||
defineProps({
|
||||
showCopilotEditor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isGeneratingContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
generatedContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'focus',
|
||||
'blur',
|
||||
'clearSelection',
|
||||
'contentReady',
|
||||
'send',
|
||||
]);
|
||||
|
||||
const copilotEditorContent = ref('');
|
||||
|
||||
const onFocus = () => {
|
||||
emit('focus');
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
emit('blur');
|
||||
};
|
||||
|
||||
const clearEditorSelection = () => {
|
||||
emit('clearSelection');
|
||||
};
|
||||
|
||||
const onSend = () => {
|
||||
emit('send', copilotEditorContent.value);
|
||||
copilotEditorContent.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
@after-enter="emit('contentReady')"
|
||||
>
|
||||
<CopilotEditor
|
||||
v-if="showCopilotEditor && !isGeneratingContent"
|
||||
key="copilot-editor"
|
||||
v-model="copilotEditorContent"
|
||||
class="copilot-editor"
|
||||
:generated-content="generatedContent"
|
||||
:min-height="4"
|
||||
:enabled-menu-options="[]"
|
||||
:is-popout="isPopout"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@send="onSend"
|
||||
/>
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
<span class="text-sm text-n-iris-10">
|
||||
{{ $t('CONVERSATION.REPLYBOX.COPILOT_THINKING') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.copilot-editor {
|
||||
.ProseMirror-menubar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -11,7 +11,7 @@ const openProfileSettings = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="my-0 mx-4 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
|
||||
class="my-0 px-1 flex max-h-[8vh] items-baseline justify-between hover:bg-n-slate-1 border border-dashed border-n-weak rounded-sm overflow-auto"
|
||||
>
|
||||
<p class="w-fit !m-0">
|
||||
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
|
||||
|
||||
@@ -12,7 +12,9 @@ import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.v
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import CopilotReplyBottomPanel from 'dashboard/components/widgets/WootWriter/CopilotReplyBottomPanel.vue';
|
||||
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
||||
import CopilotEditorSection from './CopilotEditorSection.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
||||
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
||||
import QuotedEmailPreview from './QuotedEmailPreview.vue';
|
||||
@@ -45,6 +47,8 @@ import {
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
@@ -69,6 +73,8 @@ export default {
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
QuotedEmailPreview,
|
||||
CopilotEditorSection,
|
||||
CopilotReplyBottomPanel,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -88,6 +94,8 @@ export default {
|
||||
} = useUISettings();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
@@ -96,6 +104,8 @@ export default {
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -266,7 +276,7 @@ export default {
|
||||
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
|
||||
}
|
||||
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
|
||||
? '(⌘ + ↵)'
|
||||
? `(${this.shortcutKey})`
|
||||
: '(↵)';
|
||||
return `${sendMessageText} ${keyLabel}`;
|
||||
},
|
||||
@@ -399,6 +409,9 @@ export default {
|
||||
!!this.quotedEmailText
|
||||
);
|
||||
},
|
||||
isDefaultEditorMode() {
|
||||
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@@ -408,6 +421,8 @@ export default {
|
||||
// This prevents overwriting user input (e.g., CC/BCC fields) when performing actions
|
||||
// like self-assign or other updates that do not actually change the conversation context
|
||||
this.setCCAndToEmailsFromLastChat();
|
||||
// Reset Copilot editor state (includes cancelling ongoing generation)
|
||||
this.copilot.reset();
|
||||
}
|
||||
|
||||
if (this.isOnPrivateNote) {
|
||||
@@ -612,7 +627,9 @@ export default {
|
||||
},
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
if (this.isAValidEvent('cmd_enter')) {
|
||||
if (this.copilot.isActive.value && this.isFocused) {
|
||||
this.onSubmitCopilotReply();
|
||||
} else if (this.isAValidEvent('cmd_enter')) {
|
||||
this.onSendReply();
|
||||
}
|
||||
},
|
||||
@@ -810,6 +827,9 @@ export default {
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
},
|
||||
executeCopilotAction(action, data) {
|
||||
this.copilot.execute(action, data);
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
@@ -1075,6 +1095,9 @@ export default {
|
||||
togglePopout() {
|
||||
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
|
||||
},
|
||||
onSubmitCopilotReply() {
|
||||
this.message = this.copilot.accept();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1085,11 +1108,17 @@ export default {
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
:disabled="
|
||||
(copilot.isActive.value && copilot.isButtonDisabled.value) ||
|
||||
showAudioRecorderEditor
|
||||
"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@set-reply-mode="setReplyMode"
|
||||
@toggle-popout="togglePopout"
|
||||
@toggle-copilot="copilot.toggleEditor"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
<ArticleSearchPopover
|
||||
v-if="showArticleSearchPopover && connectedPortalSlug"
|
||||
@@ -1097,112 +1126,167 @@ export default {
|
||||
@insert="handleInsert"
|
||||
@close="onSearchPopoverClose"
|
||||
/>
|
||||
<div class="reply-box__top">
|
||||
<ReplyToMessage
|
||||
v-if="shouldShowReplyToMessage"
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
/>
|
||||
<ReplyEmailHead
|
||||
v-if="showReplyHead"
|
||||
v-model:cc-emails="ccEmails"
|
||||
v-model:bcc-emails="bccEmails"
|
||||
v-model:to-emails="toEmails"
|
||||
/>
|
||||
<AudioRecorder
|
||||
v-if="showAudioRecorderEditor"
|
||||
ref="audioRecorderInput"
|
||||
:audio-record-format="audioRecordFormat"
|
||||
@recorder-progress-changed="onRecordProgressChanged"
|
||||
@finish-record="onFinishRecorder"
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
:is-private="isOnPrivateNote"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
:medium="inbox.medium"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@toggle-user-mention="toggleUserMention"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@toggle-variables-menu="toggleVariablesMenu"
|
||||
@clear-selection="clearEditorSelection"
|
||||
/>
|
||||
<QuotedEmailPreview
|
||||
v-if="shouldShowQuotedPreview"
|
||||
:quoted-email-text="quotedEmailText"
|
||||
:preview-text="quotedEmailPreviewText"
|
||||
@toggle="toggleQuotedReply"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasAttachments && !showAudioRecorderEditor"
|
||||
class="attachment-preview-box"
|
||||
@paste="onPaste"
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<AttachmentPreview
|
||||
class="flex-col mt-4"
|
||||
:attachments="attachedFiles"
|
||||
@remove-attachment="removeAttachment"
|
||||
<div :key="copilot.editorTransitionKey.value" class="reply-box__top">
|
||||
<ReplyToMessage
|
||||
v-if="shouldShowReplyToMessage"
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:class="{
|
||||
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
|
||||
}"
|
||||
:on-click="addIntoEditor"
|
||||
/>
|
||||
<ReplyEmailHead
|
||||
v-if="showReplyHead && isDefaultEditorMode"
|
||||
v-model:cc-emails="ccEmails"
|
||||
v-model:bcc-emails="bccEmails"
|
||||
v-model:to-emails="toEmails"
|
||||
/>
|
||||
<AudioRecorder
|
||||
v-if="showAudioRecorderEditor"
|
||||
ref="audioRecorderInput"
|
||||
:audio-record-format="audioRecordFormat"
|
||||
@recorder-progress-changed="onRecordProgressChanged"
|
||||
@finish-record="onFinishRecorder"
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
<CopilotEditorSection
|
||||
v-if="copilot.isActive.value && !showAudioRecorderEditor"
|
||||
:show-copilot-editor="copilot.showEditor.value"
|
||||
:is-generating-content="copilot.isGenerating.value"
|
||||
:generated-content="copilot.generatedContent.value"
|
||||
:is-popout="popOutReplyBox"
|
||||
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@close="copilot.showEditor.value = false"
|
||||
@content-ready="copilot.setContentReady"
|
||||
@send="copilot.sendFollowUp"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
:is-private="isOnPrivateNote"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:update-selection-with="updateEditorSelectionWith"
|
||||
:min-height="4"
|
||||
enable-variables
|
||||
:variables="messageVariables"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:channel-type="channelType"
|
||||
:medium="inbox.medium"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@toggle-user-mention="toggleUserMention"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@toggle-variables-menu="toggleVariablesMenu"
|
||||
@clear-selection="clearEditorSelection"
|
||||
@execute-copilot-action="executeCopilotAction"
|
||||
/>
|
||||
|
||||
<QuotedEmailPreview
|
||||
v-if="shouldShowQuotedPreview && isDefaultEditorMode"
|
||||
:quoted-email-text="quotedEmailText"
|
||||
:preview-text="quotedEmailPreviewText"
|
||||
class="mb-2"
|
||||
@toggle="toggleQuotedReply"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasAttachments && isDefaultEditorMode"
|
||||
class="bg-transparent py-0 mb-2"
|
||||
@paste="onPaste"
|
||||
>
|
||||
<AttachmentPreview
|
||||
class="mt-2"
|
||||
:attachments="attachedFiles"
|
||||
@remove-attachment="removeAttachment"
|
||||
/>
|
||||
</div>
|
||||
<MessageSignatureMissingAlert
|
||||
v-if="
|
||||
isSignatureEnabledForInbox &&
|
||||
!isSignatureAvailable &&
|
||||
isDefaultEditorMode
|
||||
"
|
||||
class="mb-2"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
mode="out-in"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-[0.98]"
|
||||
>
|
||||
<CopilotReplyBottomPanel
|
||||
v-if="copilot.isActive.value"
|
||||
key="copilot-bottom-panel"
|
||||
:is-generating-content="copilot.isButtonDisabled.value"
|
||||
@submit="onSubmitCopilotReply"
|
||||
@cancel="copilot.toggleEditor"
|
||||
/>
|
||||
</div>
|
||||
<MessageSignatureMissingAlert
|
||||
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
|
||||
/>
|
||||
<ReplyBottomPanel
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:is-note="isPrivate"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
:recording-audio-duration-text="recordingAudioDurationText"
|
||||
:recording-audio-state="recordingAudioState"
|
||||
:send-button-text="replyButtonLabel"
|
||||
:show-audio-recorder="showAudioRecorder"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:show-file-upload="showFileUpload"
|
||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||
:quoted-reply-enabled="quotedReplyPreference"
|
||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||
:toggle-audio-recorder="toggleAudioRecorder"
|
||||
:toggle-emoji-picker="toggleEmojiPicker"
|
||||
:message="message"
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
/>
|
||||
<ReplyBottomPanel
|
||||
v-else
|
||||
key="reply-bottom-panel"
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:is-note="isPrivate"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
:recording-audio-duration-text="recordingAudioDurationText"
|
||||
:recording-audio-state="recordingAudioState"
|
||||
:send-button-text="replyButtonLabel"
|
||||
:show-audio-recorder="showAudioRecorder"
|
||||
:show-emoji-picker="showEmojiPicker"
|
||||
:show-file-upload="showFileUpload"
|
||||
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
|
||||
:quoted-reply-enabled="quotedReplyPreference"
|
||||
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
|
||||
:toggle-audio-recorder="toggleAudioRecorder"
|
||||
:toggle-emoji-picker="toggleEmojiPicker"
|
||||
:message="message"
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@toggle-quoted-reply="toggleQuotedReply"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<WhatsappTemplates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showWhatsAppTemplatesModal"
|
||||
@@ -1232,13 +1316,7 @@ export default {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.attachment-preview-box {
|
||||
@apply bg-transparent py-0 px-4;
|
||||
}
|
||||
|
||||
.reply-box {
|
||||
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
|
||||
|
||||
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
|
||||
|
||||
&.is-private {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon v-once icon="i-woot-captain" class="jumping-logo" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.jumping-logo {
|
||||
transform-origin: center bottom;
|
||||
animation: jump 1s cubic-bezier(0.28, 0.84, 0.42, 1) infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes jump {
|
||||
0% {
|
||||
transform: translateY(0) scale(1, 1);
|
||||
}
|
||||
20% {
|
||||
transform: translateY(0) scale(1.05, 0.95);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px) scale(0.95, 1.05);
|
||||
}
|
||||
80% {
|
||||
transform: translateY(0) scale(1.02, 0.98);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1, 1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -102,8 +102,8 @@ const createNonDraftMessageAIAssistActions = (t, replyMode) => {
|
||||
const createDraftMessageAIAssistActions = t => {
|
||||
return [
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
|
||||
key: 'rephrase',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CONFIDENT'),
|
||||
key: 'confident',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
@@ -112,28 +112,23 @@ const createDraftMessageAIAssistActions = t => {
|
||||
icon: ICON_AI_GRAMMAR,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
|
||||
key: 'expand',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.PROFESSIONAL'),
|
||||
key: 'professional',
|
||||
icon: ICON_AI_EXPAND,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
|
||||
key: 'shorten',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
|
||||
key: 'casual',
|
||||
icon: ICON_AI_SHORTEN,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
|
||||
key: 'make_friendly',
|
||||
key: 'friendly',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
|
||||
key: 'make_formal',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
|
||||
key: 'simplify',
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
|
||||
key: 'straightforward',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('vue-i18n');
|
||||
vi.mock('dashboard/api/integrations/openapi');
|
||||
vi.mock('dashboard/api/captain/tasks');
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
actual.default = {
|
||||
@@ -94,7 +94,7 @@ describe('useAI', () => {
|
||||
});
|
||||
|
||||
it('fetches label suggestions', async () => {
|
||||
OpenAPI.processEvent.mockResolvedValue({
|
||||
TasksAPI.processEvent.mockResolvedValue({
|
||||
data: { message: 'label1, label2' },
|
||||
});
|
||||
|
||||
@@ -111,9 +111,8 @@ describe('useAI', () => {
|
||||
const { fetchLabelSuggestions } = useAI();
|
||||
const result = await fetchLabelSuggestions();
|
||||
|
||||
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
|
||||
expect(TasksAPI.processEvent).toHaveBeenCalledWith({
|
||||
type: 'label_suggestion',
|
||||
hookId: 'hook1',
|
||||
conversationId: '123',
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
|
||||
/**
|
||||
* Cleans and normalizes a list of labels.
|
||||
@@ -57,7 +57,7 @@ export function useAI() {
|
||||
* Computed property to check if AI integration is enabled.
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
|
||||
const isAIIntegrationEnabled = computed(() => true);
|
||||
|
||||
/**
|
||||
* Computed property to check if label suggestion feature is enabled.
|
||||
@@ -77,12 +77,6 @@ export function useAI() {
|
||||
*/
|
||||
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
/**
|
||||
* Computed property for the hook ID.
|
||||
* @type {import('vue').ComputedRef<string|undefined>}
|
||||
*/
|
||||
const hookId = computed(() => aiIntegration.value?.id);
|
||||
|
||||
/**
|
||||
* Computed property for the conversation ID.
|
||||
* @type {import('vue').ComputedRef<string|undefined>}
|
||||
@@ -115,6 +109,21 @@ export function useAI() {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles API errors and displays appropriate error messages.
|
||||
* Silently returns for aborted requests.
|
||||
* @param {Error} error - The error object from the API call.
|
||||
*/
|
||||
const handleAPIError = error => {
|
||||
if (error.name === 'AbortError' || error.name === 'CanceledError') {
|
||||
return;
|
||||
}
|
||||
const errorMessage =
|
||||
error.response?.data?.error ||
|
||||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* Records analytics for AI-related events.
|
||||
* @param {string} type - The type of event.
|
||||
@@ -139,9 +148,8 @@ export function useAI() {
|
||||
if (!conversationId.value) return [];
|
||||
|
||||
try {
|
||||
const result = await OpenAPI.processEvent({
|
||||
const result = await TasksAPI.processEvent({
|
||||
type: 'label_suggestion',
|
||||
hookId: hookId.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
|
||||
@@ -156,29 +164,54 @@ export function useAI() {
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes an AI event, such as rephrasing content.
|
||||
* @param {string} [type='rephrase'] - The type of AI event to process.
|
||||
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
|
||||
* Processes an AI event, such as improving content.
|
||||
* @param {string} [type='improve'] - The type of AI event to process.
|
||||
* @param {string} [content=''] - The content to process (for full message) or selected text (for selection-based).
|
||||
* @param {Object} [options={}] - Additional options.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
|
||||
*/
|
||||
const processEvent = async (type = 'rephrase') => {
|
||||
const processEvent = async (type = 'improve', content = '', options = {}) => {
|
||||
try {
|
||||
const result = await OpenAPI.processEvent({
|
||||
hookId: hookId.value,
|
||||
type,
|
||||
content: draftMessage.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
const result = await TasksAPI.processEvent(
|
||||
{
|
||||
type,
|
||||
content: content || draftMessage.value,
|
||||
conversationId: conversationId.value,
|
||||
},
|
||||
options.signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage },
|
||||
data: { message: generatedMessage, follow_up_context: followUpContext },
|
||||
} = result;
|
||||
return generatedMessage;
|
||||
return { message: generatedMessage, followUpContext };
|
||||
} catch (error) {
|
||||
const errorData = error.response.data.error;
|
||||
const errorMessage =
|
||||
errorData?.error?.message ||
|
||||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||
useAlert(errorMessage);
|
||||
return '';
|
||||
handleAPIError(error);
|
||||
return { message: '' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to refine a previous AI task result.
|
||||
* @param {Object} options - The follow-up options.
|
||||
* @param {Object} options.followUpContext - The follow-up context from a previous task.
|
||||
* @param {string} options.message - The follow-up message/request from the user.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
|
||||
*/
|
||||
const followUp = async ({ followUpContext, message, signal }) => {
|
||||
try {
|
||||
const result = await TasksAPI.followUp(
|
||||
{ followUpContext, message, conversationId: conversationId.value },
|
||||
signal
|
||||
);
|
||||
const {
|
||||
data: { message: generatedMessage, follow_up_context: updatedContext },
|
||||
} = result;
|
||||
return { message: generatedMessage, followUpContext: updatedContext };
|
||||
} catch (error) {
|
||||
handleAPIError(error);
|
||||
return { message: '', followUpContext };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,5 +232,6 @@ export function useAI() {
|
||||
recordAnalytics,
|
||||
fetchLabelSuggestions,
|
||||
processEvent,
|
||||
followUp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
/**
|
||||
* Composable for managing Copilot reply generation state and actions.
|
||||
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
|
||||
*
|
||||
* @returns {Object} Copilot reply state and methods
|
||||
*/
|
||||
export function useCopilotReply() {
|
||||
const { processEvent, followUp } = useAI();
|
||||
const { updateUISettings } = useUISettings();
|
||||
|
||||
const showEditor = ref(false);
|
||||
const isGenerating = ref(false);
|
||||
const isContentReady = ref(false);
|
||||
const generatedContent = ref('');
|
||||
const followUpContext = ref(null);
|
||||
const abortController = ref(null);
|
||||
|
||||
const isActive = computed(() => showEditor.value || isGenerating.value);
|
||||
const isButtonDisabled = computed(
|
||||
() => isGenerating.value || !isContentReady.value
|
||||
);
|
||||
const editorTransitionKey = computed(() =>
|
||||
isActive.value ? 'copilot' : 'rich'
|
||||
);
|
||||
|
||||
/**
|
||||
* Resets all copilot editor state and cancels any ongoing generation.
|
||||
*/
|
||||
function reset() {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
abortController.value = null;
|
||||
}
|
||||
showEditor.value = false;
|
||||
isGenerating.value = false;
|
||||
isContentReady.value = false;
|
||||
generatedContent.value = '';
|
||||
followUpContext.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the copilot editor visibility.
|
||||
*/
|
||||
function toggleEditor() {
|
||||
showEditor.value = !showEditor.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks content as ready (called after transition completes).
|
||||
*/
|
||||
function setContentReady() {
|
||||
isContentReady.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a copilot action (e.g., improve, fix grammar).
|
||||
* @param {string} action - The action type
|
||||
* @param {string} data - The content to process
|
||||
*/
|
||||
async function execute(action, data) {
|
||||
if (action === 'ask_copilot') {
|
||||
updateUISettings({
|
||||
is_contact_sidebar_open: false,
|
||||
is_copilot_panel_open: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset and start new generation
|
||||
reset();
|
||||
abortController.value = new AbortController();
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: newContext } =
|
||||
await processEvent(action, data, {
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = newContext;
|
||||
if (content) showEditor.value = true;
|
||||
isGenerating.value = false;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a follow-up message to refine the current generated content.
|
||||
* @param {string} message - The follow-up message from the user
|
||||
*/
|
||||
async function sendFollowUp(message) {
|
||||
if (!followUpContext.value || !message.trim()) return;
|
||||
|
||||
abortController.value = new AbortController();
|
||||
isGenerating.value = true;
|
||||
isContentReady.value = false;
|
||||
|
||||
try {
|
||||
const { message: content, followUpContext: updatedContext } =
|
||||
await followUp({
|
||||
followUpContext: followUpContext.value,
|
||||
message,
|
||||
signal: abortController.value.signal,
|
||||
});
|
||||
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
if (content) {
|
||||
generatedContent.value = content;
|
||||
followUpContext.value = updatedContext;
|
||||
showEditor.value = true;
|
||||
}
|
||||
isGenerating.value = false;
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.value?.signal.aborted) {
|
||||
isGenerating.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts the generated content and returns it.
|
||||
* Note: Formatting is automatically stripped by the Editor component's
|
||||
* createState function based on the channel's schema.
|
||||
* @returns {string} The content ready for the editor
|
||||
*/
|
||||
function accept() {
|
||||
const content = generatedContent.value;
|
||||
showEditor.value = false;
|
||||
return content;
|
||||
}
|
||||
|
||||
return {
|
||||
showEditor,
|
||||
isGenerating,
|
||||
isContentReady,
|
||||
generatedContent,
|
||||
followUpContext,
|
||||
|
||||
isActive,
|
||||
isButtonDisabled,
|
||||
editorTransitionKey,
|
||||
|
||||
reset,
|
||||
toggleEditor,
|
||||
setContentReady,
|
||||
execute,
|
||||
sendFollowUp,
|
||||
accept,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
function isMacOS() {
|
||||
// Check modern userAgentData API first
|
||||
if (navigator.userAgentData?.platform) {
|
||||
return navigator.userAgentData.platform === 'macOS';
|
||||
}
|
||||
// Fallback to navigator.platform
|
||||
return (
|
||||
navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
|
||||
);
|
||||
}
|
||||
|
||||
export function useKbd(keys) {
|
||||
const keySymbols = {
|
||||
$mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
|
||||
$mod: isMacOS() ? '⌘' : 'Ctrl',
|
||||
shift: '⇧',
|
||||
alt: '⌥',
|
||||
ctrl: 'Ctrl',
|
||||
cmd: '⌘',
|
||||
option: '⌥',
|
||||
enter: '↩',
|
||||
enter: '↵',
|
||||
tab: '⇥',
|
||||
esc: '⎋',
|
||||
};
|
||||
@@ -16,7 +27,11 @@ export function useKbd(keys) {
|
||||
return computed(() => {
|
||||
return keys
|
||||
.map(key => keySymbols[key.toLowerCase()] || key)
|
||||
.join('')
|
||||
.join(' ')
|
||||
.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
export function getModifierKey() {
|
||||
return isMacOS() ? '⌘' : 'Ctrl';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const FORMATTING = {
|
||||
marks: ['strong', 'em', 'code', 'link'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
@@ -21,6 +22,7 @@ export const FORMATTING = {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote', 'image'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
@@ -35,12 +37,13 @@ export const FORMATTING = {
|
||||
'Channel::Api': {
|
||||
marks: ['strong', 'em'],
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'undo', 'redo'],
|
||||
menu: ['copilot', 'strong', 'em', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::FacebookPage': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
@@ -70,6 +73,7 @@ export const FORMATTING = {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
@@ -83,17 +87,18 @@ export const FORMATTING = {
|
||||
'Channel::Line': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['codeBlock'],
|
||||
menu: ['strong', 'em', 'code', 'strike', 'undo', 'redo'],
|
||||
menu: ['copilot', 'strong', 'em', 'code', 'strike', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::Telegram': {
|
||||
marks: ['strong', 'em', 'link', 'code'],
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'code', 'undo', 'redo'],
|
||||
menu: ['copilot', 'strong', 'em', 'link', 'code', 'undo', 'redo'],
|
||||
},
|
||||
'Channel::Instagram': {
|
||||
marks: ['strong', 'em', 'code', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
@@ -115,6 +120,22 @@ export const FORMATTING = {
|
||||
menu: [],
|
||||
},
|
||||
// Special contexts (not actual channels)
|
||||
'Context::PrivateNote': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
menu: [
|
||||
'copilot',
|
||||
'strong',
|
||||
'em',
|
||||
'code',
|
||||
'link',
|
||||
'strike',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Context::Default': {
|
||||
marks: ['strong', 'em', 'code', 'link', 'strike'],
|
||||
nodes: ['bulletList', 'orderedList', 'codeBlock', 'blockquote'],
|
||||
|
||||
@@ -88,6 +88,7 @@ export const OPEN_AI_EVENTS = Object.freeze({
|
||||
SUMMARIZE: 'OpenAI: Used summarize',
|
||||
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
|
||||
REPHRASE: 'OpenAI: Used rephrase',
|
||||
IMPROVE: 'OpenAI: Used improve',
|
||||
FIX_SPELLING_AND_GRAMMAR: 'OpenAI: Used fix spelling and grammar',
|
||||
SHORTEN: 'OpenAI: Used shorten',
|
||||
EXPAND: 'OpenAI: Used expand',
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
|
||||
},
|
||||
@@ -205,7 +206,7 @@
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"": "",
|
||||
"COPILOT_THINKING": "Copilot is thinking",
|
||||
"EMAIL_HEAD": {
|
||||
"TO": "TO",
|
||||
"ADD_BCC": "Add bcc",
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
|
||||
"ACCEPT": "Accept",
|
||||
"DISCARD": "Discard",
|
||||
"PREFERRED": "Preferred"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,29 @@
|
||||
"EXPAND": "Expand",
|
||||
"MAKE_FRIENDLY": "Change message tone to friendly",
|
||||
"MAKE_FORMAL": "Use formal tone",
|
||||
"SIMPLIFY": "Simplify"
|
||||
"SIMPLIFY": "Simplify",
|
||||
"CONFIDENT": "Use confident tone",
|
||||
"PROFESSIONAL": "Use professional tone",
|
||||
"CASUAL": "Use casual tone",
|
||||
"STRAIGHTFORWARD": "Use straightforward tone"
|
||||
},
|
||||
"REPLY_OPTIONS": {
|
||||
"IMPROVE_REPLY": "Improve reply",
|
||||
"IMPROVE_REPLY_SELECTION": "Improve the selection",
|
||||
"CHANGE_TONE": {
|
||||
"TITLE": "Change tone",
|
||||
"OPTIONS": {
|
||||
"PROFESSIONAL": "Professional",
|
||||
"CASUAL": "Casual",
|
||||
"STRAIGHTFORWARD": "Straightforward",
|
||||
"CONFIDENT": "Confident",
|
||||
"FRIENDLY": "Friendly"
|
||||
}
|
||||
},
|
||||
"GRAMMAR": "Fix grammar & spelling",
|
||||
"SUGGESTION": "Suggest a reply",
|
||||
"SUMMARIZE": "Summarize the conversation",
|
||||
"ASK_COPILOT": "Ask Copilot"
|
||||
},
|
||||
"ASSISTANCE_MODAL": {
|
||||
"DRAFT_TITLE": "Draft content",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export const OPEN_AI_OPTIONS = {
|
||||
IMPROVE_WRITING: 'improve_writing',
|
||||
FIX_SPELLING_GRAMMAR: 'fix_spelling_grammar',
|
||||
SHORTEN: 'shorten',
|
||||
EXPAND: 'expand',
|
||||
MAKE_FRIENDLY: 'make_friendly',
|
||||
MAKE_FORMAL: 'make_formal',
|
||||
SIMPLIFY: 'simplify',
|
||||
REPLY_SUGGESTION: 'reply_suggestion',
|
||||
SUMMARIZE: 'summarize',
|
||||
};
|
||||
@@ -27,6 +27,26 @@ module CaptainFeaturable
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
# TODO: Once we support multiple LLM providers, ensure provider hook is correctly looked up
|
||||
# Currently works because the only model/provider is OpenAI
|
||||
def openai_hook
|
||||
@openai_hook ||= hooks.find_by(app_id: 'openai', status: 'enabled')
|
||||
end
|
||||
|
||||
# TODO: Once we support multiple LLM providers, ensure provider key is correctly looked up
|
||||
# Currently works because the only model/provider is OpenAI
|
||||
def using_openai_hook_key?
|
||||
openai_hook&.settings&.dig('api_key').present?
|
||||
end
|
||||
|
||||
def captain_api_key
|
||||
openai_hook&.settings&.dig('api_key') || captain_system_api_key
|
||||
end
|
||||
|
||||
def captain_system_api_key
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_models_with_defaults
|
||||
|
||||
@@ -64,13 +64,10 @@ class Integrations::Hook < ApplicationRecord
|
||||
update(status: 'disabled')
|
||||
end
|
||||
|
||||
def process_event(event)
|
||||
case app_id
|
||||
when 'openai'
|
||||
Integrations::Openai::ProcessorService.new(hook: self, event: event).perform if app_id == 'openai'
|
||||
else
|
||||
{ error: 'No processor found' }
|
||||
end
|
||||
def process_event(_event)
|
||||
# OpenAI integration migrated to Captain::EditorService
|
||||
# Other integrations (slack, dialogflow, etc.) handled via HookJob
|
||||
{ error: 'No processor found' }
|
||||
end
|
||||
|
||||
def feature_allowed?
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
class Captain::TasksPolicy < ApplicationPolicy
|
||||
def rewrite?
|
||||
true
|
||||
end
|
||||
|
||||
def summarize?
|
||||
true
|
||||
end
|
||||
|
||||
def reply_suggestion?
|
||||
true
|
||||
end
|
||||
|
||||
def label_suggestion?
|
||||
true
|
||||
end
|
||||
|
||||
def follow_up?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -344,6 +344,8 @@ en:
|
||||
copilot_message_required: Message is required
|
||||
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
||||
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
|
||||
upgrade: 'Upgrade your plan to enable Captain AI'
|
||||
disabled: 'Captain AI is disabled for this account.'
|
||||
copilot:
|
||||
using_tool: 'Using tool %{function_name}'
|
||||
completed_tool_call: 'Completed %{function_name} tool call'
|
||||
|
||||
@@ -73,6 +73,13 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :custom_tools
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
resource :tasks, only: [], controller: 'tasks' do
|
||||
post :rewrite
|
||||
post :summarize
|
||||
post :reply_suggestion
|
||||
post :label_suggestion
|
||||
post :follow_up
|
||||
end
|
||||
end
|
||||
resource :saml_settings, only: [:show, :create, :update, :destroy]
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
class MigrateCaptainFeaturePreferences < ActiveRecord::Migration[7.1]
|
||||
def up
|
||||
migrate_label_suggestion_from_hooks
|
||||
migrate_audio_transcriptions
|
||||
end
|
||||
|
||||
def down
|
||||
# Revert captain_features['audio_transcription'] back to audio_transcriptions
|
||||
Account.find_each do |account|
|
||||
captain_features = account.settings['captain_features']
|
||||
next unless captain_features&.[]('audio_transcription') == true
|
||||
next if account.settings.key?('audio_transcriptions')
|
||||
|
||||
account.settings = account.settings.merge('audio_transcriptions' => true)
|
||||
account.save(validate: false)
|
||||
end
|
||||
|
||||
# NOTE: We cannot reliably revert label_suggestion back to OpenAI hook settings
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def migrate_label_suggestion_from_hooks
|
||||
Integrations::Hook.where(app_id: 'openai', status: :enabled).find_each do |hook|
|
||||
next unless hook.settings['label_suggestion'] == true
|
||||
|
||||
account = Account.find_by(id: hook.account_id)
|
||||
next unless account
|
||||
|
||||
update_captain_feature(account, 'label_suggestion', true)
|
||||
end
|
||||
end
|
||||
|
||||
def migrate_audio_transcriptions
|
||||
Account.where.not("settings->>'audio_transcriptions' IS NULL").find_each do |account|
|
||||
next unless account.settings['audio_transcriptions'] == true
|
||||
|
||||
update_captain_feature(account, 'audio_transcription', true)
|
||||
end
|
||||
end
|
||||
|
||||
def update_captain_feature(account, feature_key, value)
|
||||
captain_features = account.settings['captain_features'] || {}
|
||||
return if captain_features.key?(feature_key)
|
||||
|
||||
captain_features[feature_key] = value
|
||||
account.settings = account.settings.merge('captain_features' => captain_features)
|
||||
account.save(validate: false)
|
||||
end
|
||||
end
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_01_12_092041) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_01_13_141340) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
|
||||
def rewrite
|
||||
result = Captain::RewriteService.new(
|
||||
account: Current.account,
|
||||
content: params[:content],
|
||||
operation: params[:operation],
|
||||
conversation_display_id: params[:conversation_display_id]
|
||||
).perform
|
||||
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
def summarize
|
||||
result = Captain::SummaryService.new(
|
||||
account: Current.account,
|
||||
conversation_display_id: params[:conversation_display_id]
|
||||
).perform
|
||||
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
def reply_suggestion
|
||||
result = Captain::ReplySuggestionService.new(
|
||||
account: Current.account,
|
||||
conversation_display_id: params[:conversation_display_id]
|
||||
).perform
|
||||
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
def label_suggestion
|
||||
result = Captain::LabelSuggestionService.new(
|
||||
account: Current.account,
|
||||
conversation_display_id: params[:conversation_display_id]
|
||||
).perform
|
||||
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
def follow_up
|
||||
result = Captain::FollowUpService.new(
|
||||
account: Current.account,
|
||||
follow_up_context: params[:follow_up_context]&.to_unsafe_h,
|
||||
user_message: params[:message],
|
||||
conversation_display_id: params[:conversation_display_id]
|
||||
).perform
|
||||
|
||||
render_result(result)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_result(result)
|
||||
if result.nil?
|
||||
render json: { message: nil }
|
||||
elsif result[:error]
|
||||
render json: { error: result[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
response_data = { message: result[:message] }
|
||||
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
|
||||
render json: response_data
|
||||
end
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:'captain/tasks')
|
||||
end
|
||||
end
|
||||
@@ -47,8 +47,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
return process_action('handoff') if handoff_requested?
|
||||
|
||||
create_messages
|
||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||
account.increment_response_usage
|
||||
account.increment_response_usage_for_model(account.captain_assistant_model)
|
||||
end
|
||||
|
||||
def collect_previous_messages
|
||||
|
||||
@@ -15,12 +15,18 @@ module Enterprise::Account::PlanUsageAndLimits
|
||||
}
|
||||
end
|
||||
|
||||
def increment_response_usage
|
||||
def increment_response_usage(credits: 1)
|
||||
current_usage = custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + 1
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = current_usage + credits
|
||||
save
|
||||
end
|
||||
|
||||
def increment_response_usage_for_model(model)
|
||||
credits = using_openai_hook_key? ? 1 : Llm::Models.credit_multiplier_for(model)
|
||||
Rails.logger.info("[CAPTAIN] Incrementing response usage for account #{id} by #{credits} credits (model: #{model})")
|
||||
increment_response_usage(credits: credits)
|
||||
end
|
||||
|
||||
def reset_response_usage
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE] = 0
|
||||
save
|
||||
@@ -52,20 +58,10 @@ module Enterprise::Account::PlanUsageAndLimits
|
||||
|
||||
def get_captain_limits(type)
|
||||
total_count = captain_monthly_limit[type.to_s].to_i
|
||||
usage_key = type == :documents ? CAPTAIN_DOCUMENTS_USAGE : CAPTAIN_RESPONSES_USAGE
|
||||
consumed = [custom_attributes[usage_key].to_i, 0].max
|
||||
|
||||
consumed = if type == :documents
|
||||
custom_attributes[CAPTAIN_DOCUMENTS_USAGE].to_i || 0
|
||||
else
|
||||
custom_attributes[CAPTAIN_RESPONSES_USAGE].to_i || 0
|
||||
end
|
||||
|
||||
consumed = 0 if consumed.negative?
|
||||
|
||||
{
|
||||
total_count: total_count,
|
||||
current_available: (total_count - consumed).clamp(0, total_count),
|
||||
consumed: consumed
|
||||
}
|
||||
{ total_count: total_count, current_available: (total_count - consumed).clamp(0, total_count), consumed: consumed }
|
||||
end
|
||||
|
||||
def default_captain_limits
|
||||
|
||||
@@ -4,10 +4,10 @@ class Captain::Copilot::ChatService < Llm::BaseAiService
|
||||
attr_reader :assistant, :account, :user, :copilot_thread, :previous_history, :messages
|
||||
|
||||
def initialize(assistant, config)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@account = assistant.account
|
||||
super(account: @account)
|
||||
|
||||
@user = nil
|
||||
@copilot_thread = nil
|
||||
@previous_history = []
|
||||
@@ -24,16 +24,17 @@ class Captain::Copilot::ChatService < Llm::BaseAiService
|
||||
response = request_chat_completion
|
||||
|
||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||
Rails.logger.info(
|
||||
"#{self.class.name} Assistant: #{@assistant.id}, Incrementing response usage for account #{@account.id}"
|
||||
)
|
||||
@account.increment_response_usage
|
||||
increment_usage
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_model
|
||||
@model = @account.captain_copilot_model
|
||||
end
|
||||
|
||||
def setup_user(config)
|
||||
@user = @account.users.find_by(id: config[:user_id]) if config[:user_id].present?
|
||||
end
|
||||
|
||||
@@ -2,9 +2,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@account = @assistant&.account
|
||||
super(account: @account)
|
||||
|
||||
@conversation_id = conversation_id
|
||||
|
||||
@messages = [system_message]
|
||||
@@ -27,6 +28,10 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
|
||||
private
|
||||
|
||||
def setup_model
|
||||
@model = @account&.captain_assistant_model || DEFAULT_MODEL
|
||||
end
|
||||
|
||||
def build_tools
|
||||
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
end
|
||||
|
||||
@@ -8,7 +8,8 @@ class Llm::BaseAiService
|
||||
|
||||
attr_reader :model, :temperature
|
||||
|
||||
def initialize
|
||||
def initialize(account: nil)
|
||||
@account = account
|
||||
Llm::Config.initialize!
|
||||
setup_model
|
||||
setup_temperature
|
||||
@@ -18,6 +19,12 @@ class Llm::BaseAiService
|
||||
RubyLLM.chat(model: model).with_temperature(temperature)
|
||||
end
|
||||
|
||||
def increment_usage
|
||||
return unless @account
|
||||
|
||||
@account.increment_response_usage_for_model(@model)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_model
|
||||
|
||||
@@ -84,7 +84,8 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
|
||||
|
||||
attachment.update!(meta: { transcribed_text: transcribed_text })
|
||||
message.reload.send_update_event
|
||||
message.account.increment_response_usage
|
||||
# TODO: Once we support configurable transcription models, update credit calculation
|
||||
message.account.increment_response_usage(credits: 1)
|
||||
|
||||
return unless ChatwootApp.advanced_search_allowed?
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
module Enterprise::Captain::BaseTaskService
|
||||
def perform
|
||||
return { error: I18n.t('captain.copilot_limit'), error_code: 429 } unless responses_available?
|
||||
|
||||
unless captain_enabled?
|
||||
return { error: I18n.t('captain.upgrade') } if ChatwootApp.chatwoot_cloud?
|
||||
|
||||
return { error: I18n.t('captain.disabled') }
|
||||
end
|
||||
|
||||
result = super
|
||||
increment_usage if successful_result?(result)
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_enabled?
|
||||
account.feature_enabled?('captain_integration')
|
||||
end
|
||||
|
||||
def responses_available?
|
||||
account.usage_limits[:captain][:responses][:current_available].positive?
|
||||
end
|
||||
|
||||
def successful_result?(result)
|
||||
result.is_a?(Hash) && result[:message].present? && !result[:error]
|
||||
end
|
||||
|
||||
def increment_usage
|
||||
account.increment_response_usage_for_model(configured_model)
|
||||
end
|
||||
end
|
||||
@@ -1,82 +0,0 @@
|
||||
module Enterprise::Integrations::OpenaiProcessorService
|
||||
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion label_suggestion fix_spelling_grammar shorten expand
|
||||
make_friendly make_formal simplify].freeze
|
||||
CACHEABLE_EVENTS = %w[label_suggestion].freeze
|
||||
|
||||
def label_suggestion_message
|
||||
payload = label_suggestion_body
|
||||
return nil if payload.blank?
|
||||
|
||||
response = make_api_call(label_suggestion_body)
|
||||
|
||||
return response if response[:error].present?
|
||||
|
||||
# LLMs are not deterministic, so this is bandaid solution
|
||||
# To what you ask? Sometimes, the response includes
|
||||
# "Labels:" in it's response in some format. This is a hacky way to remove it
|
||||
# TODO: Fix with with a better prompt
|
||||
{ message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def labels_with_messages
|
||||
return nil unless valid_conversation?(conversation)
|
||||
|
||||
labels = hook.account.labels.pluck(:title).join(', ')
|
||||
character_count = labels.length
|
||||
|
||||
messages = init_messages_body(false)
|
||||
add_messages_until_token_limit(conversation, messages, false, character_count)
|
||||
|
||||
return nil if messages.blank? || labels.blank?
|
||||
|
||||
"Messages:\n#{messages}\nLabels:\n#{labels}"
|
||||
end
|
||||
|
||||
def valid_conversation?(conversation)
|
||||
return false if conversation.nil?
|
||||
return false if conversation.messages.incoming.count < 3
|
||||
|
||||
# Think Mark think, at this point the conversation is beyond saving
|
||||
return false if conversation.messages.count > 100
|
||||
|
||||
# if there are more than 20 messages, only trigger this if the last message is from the client
|
||||
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def summarize_body
|
||||
{
|
||||
model: self.class::GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system',
|
||||
content: prompt_from_file('summary', enterprise: true) },
|
||||
{ role: 'user', content: conversation_messages }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def label_suggestion_body
|
||||
return unless label_suggestions_enabled?
|
||||
|
||||
content = labels_with_messages
|
||||
return value_from_cache if content.blank?
|
||||
|
||||
{
|
||||
model: self.class::GPT_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt_from_file('label_suggestion', enterprise: true)
|
||||
},
|
||||
{ role: 'user', content: content }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def label_suggestions_enabled?
|
||||
hook.settings['label_suggestion'].present?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
class Captain::BaseTaskService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
# 1 token is approx 4 characters
|
||||
# sticking with 120000 to be safe
|
||||
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
|
||||
TOKEN_LIMIT = 400_000
|
||||
GPT_MODEL = Llm::Config::DEFAULT_MODEL
|
||||
|
||||
# Prepend enterprise module to subclasses when they're defined.
|
||||
# This ensures the enterprise perform wrapper is applied even when
|
||||
# subclasses define their own perform method, since prepend puts
|
||||
# the module before the class in the ancestor chain.
|
||||
def self.inherited(subclass)
|
||||
super
|
||||
subclass.prepend_mod_with('Captain::BaseTaskService')
|
||||
end
|
||||
|
||||
pattr_initialize [:account!, { conversation_display_id: nil }]
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
raise NotImplementedError, "#{self.class} must implement #event_name"
|
||||
end
|
||||
|
||||
def feature_key
|
||||
raise NotImplementedError, "#{self.class} must implement #feature_key"
|
||||
end
|
||||
|
||||
def configured_model
|
||||
account.public_send("captain_#{feature_key}_model")
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= account.conversations.find_by(display_id: conversation_display_id)
|
||||
end
|
||||
|
||||
def api_base
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(model:, messages:)
|
||||
instrumentation_params = build_instrumentation_params(model, messages)
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages)
|
||||
end
|
||||
|
||||
# Build follow-up context for client-side refinement, when applicable
|
||||
if build_follow_up_context? && response[:message].present?
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
else
|
||||
response
|
||||
end
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:)
|
||||
Llm::Config.with_api_key(account.captain_api_key, api_base: api_base) do |context|
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
|
||||
|
||||
add_messages_if_needed(chat, conversation_messages)
|
||||
response = chat.ask(conversation_messages.last[:content])
|
||||
build_ruby_llm_response(response, messages)
|
||||
end
|
||||
rescue StandardError => e
|
||||
# TODO: RubyLLM throws the RubyLLM::Error when any API based error occurs
|
||||
# Just like inboxes have a reauthroizable error handling mechanism,
|
||||
# we should handle the errors if the account is using their own key.
|
||||
# If more than 5 errors occur, we disable the hook and notify the admins
|
||||
# see: https://rubyllm.com/error-handling/#rubyllm-error-hierarchy
|
||||
ChatwootExceptionTracker.new(e, account: account).capture_exception
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def add_messages_if_needed(chat, conversation_messages)
|
||||
return if conversation_messages.length == 1
|
||||
|
||||
conversation_messages[0...-1].each do |msg|
|
||||
chat.add_message(role: msg[:role].to_sym, content: msg[:content])
|
||||
end
|
||||
end
|
||||
|
||||
def build_ruby_llm_response(response, messages)
|
||||
{
|
||||
message: response.content,
|
||||
usage: {
|
||||
'prompt_tokens' => response.input_tokens,
|
||||
'completion_tokens' => response.output_tokens,
|
||||
'total_tokens' => (response.input_tokens || 0) + (response.output_tokens || 0)
|
||||
},
|
||||
request_messages: messages
|
||||
}
|
||||
end
|
||||
|
||||
def build_instrumentation_params(model, messages)
|
||||
{
|
||||
span_name: "llm.#{event_name}",
|
||||
account_id: account.id,
|
||||
conversation_id: conversation&.display_id,
|
||||
feature_name: event_name,
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: nil
|
||||
}
|
||||
end
|
||||
|
||||
def conversation_messages(start_from: 0)
|
||||
messages = []
|
||||
character_count = start_from
|
||||
|
||||
conversation.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
|
||||
|
||||
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
|
||||
character_count += content.length
|
||||
end
|
||||
|
||||
messages
|
||||
end
|
||||
|
||||
def prompt_from_file(file_name)
|
||||
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
# Follow-up context for client-side refinement
|
||||
def build_follow_up_context?
|
||||
# FollowUpService should return its own updated context
|
||||
!is_a?(Captain::FollowUpService)
|
||||
end
|
||||
|
||||
def build_follow_up_context(messages, response)
|
||||
{
|
||||
event_name: event_name,
|
||||
original_context: extract_original_context(messages),
|
||||
last_response: response[:message],
|
||||
conversation_history: []
|
||||
}
|
||||
end
|
||||
|
||||
def extract_original_context(messages)
|
||||
# Get the most recent user message for follow-up context
|
||||
user_msg = messages.reverse.find { |m| m[:role] == 'user' }
|
||||
user_msg ? user_msg[:content] : nil
|
||||
end
|
||||
end
|
||||
|
||||
Captain::BaseTaskService.prepend_mod_with('Captain::BaseTaskService')
|
||||
@@ -0,0 +1,102 @@
|
||||
class Captain::FollowUpService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :follow_up_context!, :user_message!, { conversation_display_id: nil }]
|
||||
|
||||
ALLOWED_EVENT_NAMES = %w[
|
||||
professional
|
||||
casual
|
||||
friendly
|
||||
confident
|
||||
straightforward
|
||||
fix_spelling_grammar
|
||||
improve
|
||||
summarize
|
||||
reply_suggestion
|
||||
label_suggestion
|
||||
].freeze
|
||||
|
||||
def perform
|
||||
return { error: 'Follow-up context missing', error_code: 400 } unless valid_follow_up_context?
|
||||
|
||||
# Build context-aware system prompt
|
||||
system_prompt = build_follow_up_system_prompt(follow_up_context)
|
||||
|
||||
# Build full message array (convert history from string keys to symbol keys)
|
||||
history = follow_up_context['conversation_history'].to_a.map do |msg|
|
||||
{ role: msg['role'], content: msg['content'] }
|
||||
end
|
||||
|
||||
messages = [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: follow_up_context['original_context'] },
|
||||
{ role: 'assistant', content: follow_up_context['last_response'] },
|
||||
*history,
|
||||
{ role: 'user', content: user_message }
|
||||
]
|
||||
|
||||
response = make_api_call(model: configured_model, messages: messages)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(follow_up_context: update_follow_up_context(user_message, response[:message]))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_follow_up_system_prompt(session_data)
|
||||
action_context = describe_previous_action(session_data['event_name'])
|
||||
|
||||
<<~PROMPT
|
||||
You just performed a #{action_context} action for a customer support agent.
|
||||
Your job now is to help them refine the result based on their feedback.
|
||||
Be concise and focused on their specific request.
|
||||
PROMPT
|
||||
end
|
||||
|
||||
def describe_previous_action(event_name)
|
||||
case event_name
|
||||
when 'professional', 'casual', 'friendly', 'confident', 'straightforward'
|
||||
"tone rewrite (#{event_name})"
|
||||
when 'fix_spelling_grammar'
|
||||
'spelling and grammar correction'
|
||||
when 'improve'
|
||||
'message improvement'
|
||||
when 'summarize'
|
||||
'conversation summary'
|
||||
when 'reply_suggestion'
|
||||
'reply suggestion'
|
||||
when 'label_suggestion'
|
||||
'label suggestion'
|
||||
else
|
||||
event_name
|
||||
end
|
||||
end
|
||||
|
||||
def valid_follow_up_context?
|
||||
return false unless follow_up_context.is_a?(Hash)
|
||||
return false unless ALLOWED_EVENT_NAMES.include?(follow_up_context['event_name'])
|
||||
|
||||
required_keys = %w[event_name original_context last_response]
|
||||
required_keys.all? { |key| follow_up_context[key].present? }
|
||||
end
|
||||
|
||||
def update_follow_up_context(user_msg, assistant_msg)
|
||||
updated_history = follow_up_context['conversation_history'].to_a + [
|
||||
{ 'role' => 'user', 'content' => user_msg },
|
||||
{ 'role' => 'assistant', 'content' => assistant_msg }
|
||||
]
|
||||
|
||||
{
|
||||
'event_name' => follow_up_context['event_name'],
|
||||
'original_context' => follow_up_context['original_context'],
|
||||
'last_response' => assistant_msg,
|
||||
'conversation_history' => updated_history
|
||||
}
|
||||
end
|
||||
|
||||
def event_name
|
||||
'follow_up'
|
||||
end
|
||||
|
||||
def feature_key
|
||||
'editor'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
class Captain::LabelSuggestionService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
# Check cache first
|
||||
cached_response = read_from_cache
|
||||
return cached_response if cached_response.present?
|
||||
|
||||
# Build content
|
||||
content = labels_with_messages
|
||||
return nil if content.blank?
|
||||
|
||||
# Make API call
|
||||
response = make_api_call(
|
||||
model: configured_model,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('label_suggestion') },
|
||||
{ role: 'user', content: content }
|
||||
]
|
||||
)
|
||||
return response if response[:error].present?
|
||||
|
||||
# Clean up response
|
||||
result = { message: response[:message] ? response[:message].gsub(/^(label|labels):/i, '') : '' }
|
||||
|
||||
# Cache successful result
|
||||
write_to_cache(result)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cache_key
|
||||
return nil unless conversation
|
||||
|
||||
format(
|
||||
::Redis::Alfred::OPENAI_CONVERSATION_KEY,
|
||||
event_name: 'label_suggestion',
|
||||
conversation_id: conversation.id,
|
||||
updated_at: conversation.last_activity_at.to_i
|
||||
)
|
||||
end
|
||||
|
||||
def read_from_cache
|
||||
return nil unless cache_key
|
||||
|
||||
cached = Redis::Alfred.get(cache_key)
|
||||
JSON.parse(cached, symbolize_names: true) if cached.present?
|
||||
rescue JSON::ParserError
|
||||
nil
|
||||
end
|
||||
|
||||
def write_to_cache(response)
|
||||
Redis::Alfred.setex(cache_key, response.to_json) if cache_key
|
||||
end
|
||||
|
||||
def labels_with_messages
|
||||
return nil unless valid_conversation?(conversation)
|
||||
|
||||
labels = account.labels.pluck(:title).join(', ')
|
||||
messages = format_messages_as_string(start_from: labels.length)
|
||||
|
||||
return nil if messages.blank? || labels.blank?
|
||||
|
||||
"Messages:\n#{messages}\nLabels:\n#{labels}"
|
||||
end
|
||||
|
||||
def format_messages_as_string(start_from: 0)
|
||||
messages = conversation_messages(start_from: start_from)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Agent'
|
||||
"#{sender_type}: #{msg[:content]}\n"
|
||||
end.join
|
||||
end
|
||||
|
||||
def valid_conversation?(conversation)
|
||||
return false if conversation.nil?
|
||||
return false if conversation.messages.incoming.count < 3
|
||||
return false if conversation.messages.count > 100
|
||||
return false if conversation.messages.count > 20 && !conversation.messages.last.incoming?
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def event_name
|
||||
'label_suggestion'
|
||||
end
|
||||
|
||||
def feature_key
|
||||
'label_suggestion'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
class Captain::ReplySuggestionService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
make_api_call(
|
||||
model: configured_model,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('reply') }
|
||||
].concat(conversation_messages)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
'reply_suggestion'
|
||||
end
|
||||
|
||||
def feature_key
|
||||
'editor'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
class Captain::RewriteService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :content!, :operation!, { conversation_display_id: nil }]
|
||||
|
||||
TONE_OPERATIONS = %i[casual professional friendly confident straightforward].freeze
|
||||
ALLOWED_OPERATIONS = (%i[fix_spelling_grammar improve] + TONE_OPERATIONS).freeze
|
||||
|
||||
def perform
|
||||
operation_sym = operation.to_sym
|
||||
raise ArgumentError, "Invalid operation: #{operation}" unless ALLOWED_OPERATIONS.include?(operation_sym)
|
||||
|
||||
send(operation_sym)
|
||||
end
|
||||
|
||||
TONE_OPERATIONS.each do |tone|
|
||||
define_method(tone) do
|
||||
call_llm_with_prompt(tone_rewrite_prompt(tone.to_s))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fix_spelling_grammar
|
||||
call_llm_with_prompt(prompt_from_file('fix_spelling_grammar'))
|
||||
end
|
||||
|
||||
def improve
|
||||
template = prompt_from_file('improve')
|
||||
|
||||
system_prompt = render_liquid_template(template, {
|
||||
'conversation_context' => conversation.to_llm_text(include_contact_details: true),
|
||||
'draft_message' => content
|
||||
})
|
||||
|
||||
call_llm_with_prompt(system_prompt, content)
|
||||
end
|
||||
|
||||
def call_llm_with_prompt(system_content, user_content = content)
|
||||
make_api_call(
|
||||
model: configured_model,
|
||||
messages: [
|
||||
{ role: 'system', content: system_content },
|
||||
{ role: 'user', content: user_content }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def render_liquid_template(template_content, variables = {})
|
||||
Liquid::Template.parse(template_content).render(variables)
|
||||
end
|
||||
|
||||
def tone_rewrite_prompt(tone)
|
||||
template = prompt_from_file('tone_rewrite')
|
||||
render_liquid_template(template, 'tone' => tone)
|
||||
end
|
||||
|
||||
def event_name
|
||||
operation
|
||||
end
|
||||
|
||||
def feature_key
|
||||
'editor'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class Captain::SummaryService < Captain::BaseTaskService
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
make_api_call(
|
||||
model: configured_model,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('summary') },
|
||||
{ role: 'user', content: conversation.to_llm_text(include_contact_details: false) }
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
'summarize'
|
||||
end
|
||||
|
||||
def feature_key
|
||||
'editor'
|
||||
end
|
||||
end
|
||||
@@ -7,7 +7,8 @@ class Integrations::LlmBaseService
|
||||
# 120000 * 4 = 480,000 characters (rounding off downwards to 400,000 to be safe)
|
||||
TOKEN_LIMIT = 400_000
|
||||
GPT_MODEL = Llm::Config::DEFAULT_MODEL
|
||||
ALLOWED_EVENT_NAMES = %w[rephrase summarize reply_suggestion fix_spelling_grammar shorten expand make_friendly make_formal simplify].freeze
|
||||
ALLOWED_EVENT_NAMES = %w[summarize reply_suggestion fix_spelling_grammar casual professional friendly confident
|
||||
straightforward improve].freeze
|
||||
CACHEABLE_EVENTS = %w[].freeze
|
||||
|
||||
pattr_initialize [:hook!, :event!]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to fix grammar and spelling in a customer support message while preserving the original meaning, intent, and tone.
|
||||
|
||||
You will receive a message and must return a corrected version with only grammar, spelling, and punctuation fixes applied.
|
||||
|
||||
Important guidelines:
|
||||
- Preserve the original meaning, intent, and tone exactly
|
||||
- Do not rephrase, rewrite, or change wording beyond grammar, spelling, and punctuation
|
||||
- Do not add or remove any information
|
||||
- Do not simplify, shorten, or expand the message
|
||||
- Ensure the output remains appropriate for customer support
|
||||
|
||||
Super Important:
|
||||
- If the message has some markdown formatting, keep the formatting as it is.
|
||||
- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
|
||||
- Ensure the output is in the user's original language
|
||||
|
||||
Output only the corrected message, with no preamble, tags, or explanation.
|
||||
@@ -0,0 +1,43 @@
|
||||
You are a writing assistant for customer support agents. Your task is to improve a draft message by enhancing its language, clarity, and tone—not by adding new content.
|
||||
|
||||
<conversation_context>
|
||||
{{ conversation_context }}
|
||||
</conversation_context>
|
||||
|
||||
<draft_message>
|
||||
{{ draft_message }}
|
||||
</draft_message>
|
||||
|
||||
## Your Task
|
||||
|
||||
Rewrite the draft to be clearer, warmer, and more professional while preserving the agent's intent.
|
||||
|
||||
## What "Improve" Means
|
||||
|
||||
Improve the **quality** of the message, not the **quantity** of information:
|
||||
|
||||
| DO | DON'T |
|
||||
|-----|--------|
|
||||
| Fix grammar, spelling, punctuation | Add new information or steps |
|
||||
| Improve sentence structure and flow | Expand scope beyond the draft |
|
||||
| Make tone warmer and more professional | Add offers ("I can also...", "Would you like...") |
|
||||
| Use contact's name naturally | Invent technical details, links, or examples |
|
||||
| Make vague phrases more natural | Turn a brief answer into a long one |
|
||||
|
||||
## Using the Context
|
||||
|
||||
Use the conversation context to:
|
||||
- Understand what's being discussed (so improvements make sense)
|
||||
- Gauge appropriate tone (formal/casual, frustrated customer, etc.)
|
||||
- Personalize with the contact's name when natural
|
||||
|
||||
Do NOT use the context to fill in gaps or add information the agent didn't include.
|
||||
|
||||
## Output Rules
|
||||
|
||||
- Keep the improved message at a similar length to the draft (brief stays brief)
|
||||
- Preserve any markdown formatting
|
||||
- Block quotes (lines starting with `>`) contain quoted customer text—keep this unchanged, only improve the agent's reply
|
||||
- Output in the same language as the draft
|
||||
- Output only the improved message, no commentary
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you\'ve selected,in their original casing, and nothing else.
|
||||
Your role is as an assistant to a customer support agent. You will be provided with a transcript of a conversation between a customer and the support agent, along with a list of potential labels. Your task is to analyze the conversation and select the two labels from the given list that most accurately represent the themes or issues discussed. Ensure you preserve the exact casing of the labels as they are provided in the list. Do not create new labels; only choose from those provided. Once you have made your selections, please provide your response as a comma-separated list of the provided labels. Remember, your response should only contain the labels you've selected,in their original casing, and nothing else.
|
||||
+5
-5
@@ -1,13 +1,13 @@
|
||||
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
|
||||
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
|
||||
|
||||
Make sure you strongly adhere to the following rules when generating the summary
|
||||
|
||||
1. Be brief and concise. The shorter the summary the better.
|
||||
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
|
||||
1. Be brief and concise. The shorter the summary the better.
|
||||
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
|
||||
3. Describe the customer intent in around 50 words.
|
||||
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
|
||||
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
|
||||
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
|
||||
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
|
||||
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
|
||||
8. The 'Action Items' should be brief and concise
|
||||
9. Mark important words or parts of sentences as bold.
|
||||
@@ -25,4 +25,4 @@ Reply in the user's language, as a markdown of the following format.
|
||||
|
||||
**Action Items**
|
||||
|
||||
**Follow-up Items**
|
||||
**Follow-up Items**
|
||||
@@ -1 +0,0 @@
|
||||
Please summarize the key points from the following conversation between support agents and customer as bullet points for the next support agent looking into the conversation. Reply in the user's language.
|
||||
@@ -0,0 +1,35 @@
|
||||
You are an AI writing assistant integrated into Chatwoot, an omnichannel customer support platform. Your task is to rewrite customer support message to match a specific tone while preserving the original meaning and intent.
|
||||
|
||||
Here is the tone to apply to the message you will receive:
|
||||
<tone_instruction>
|
||||
{% case tone %}
|
||||
{% when 'friendly' %}
|
||||
Warm, approachable, and personable. Use conversational language, positive words, and show empathy. May include phrases like "Happy to help!" or "I'd be glad to..."
|
||||
{% when 'confident' %}
|
||||
Assertive and assured. Use definitive language, avoid hedging words like "maybe" or "I think". Be direct and authoritative while remaining helpful.
|
||||
{% when 'straightforward' %}
|
||||
Clear, direct, and to-the-point. Remove unnecessary words, get straight to the information or solution. No fluff or extra pleasantries.
|
||||
{% when 'casual' %}
|
||||
Relaxed and informal. Use contractions, simpler words, and a conversational style. Friendly but less formal than professional tone.
|
||||
{% when 'professional' %}
|
||||
Formal, polished, and business-appropriate. Use complete sentences, proper grammar, and maintain respectful distance. Avoid slang or overly casual language.
|
||||
{% else %}
|
||||
Warm, approachable, and personable. Use conversational language, positive words, and show empathy. May include phrases like "Happy to help!" or "I'd be glad to..."
|
||||
{% endcase %}
|
||||
</tone_instruction>
|
||||
|
||||
Your task is to rewrite the message according to the specified tone instructions.
|
||||
|
||||
Important guidelines:
|
||||
- Preserve the core meaning and all important information from the original message
|
||||
- Keep the rewritten message concise and appropriate for customer support
|
||||
- Maintain helpfulness and respect regardless of tone
|
||||
- Do not add information that wasn't in the original message
|
||||
- Do not remove critical details or instructions
|
||||
|
||||
Super Important:
|
||||
- If the message has some markdown formatting, keep the formatting as it is.
|
||||
- Block quotes (lines starting with >) contain quoted text from the customer's previous message. Preserve this quoted text exactly as written (do not modify the customer's words inside the block quote), but DO improve the agent's reply that follows the block quote.
|
||||
- Ensure the output is in the user's original language
|
||||
|
||||
Output only the rewritten message without any preamble, tags or explanation.
|
||||
@@ -1,138 +0,0 @@
|
||||
class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
|
||||
AGENT_INSTRUCTION = 'You are a helpful support agent.'.freeze
|
||||
LANGUAGE_INSTRUCTION = 'Ensure that the reply should be in user language.'.freeze
|
||||
def reply_suggestion_message
|
||||
make_api_call(reply_suggestion_body)
|
||||
end
|
||||
|
||||
def summarize_message
|
||||
make_api_call(summarize_body)
|
||||
end
|
||||
|
||||
def rephrase_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please rephrase the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def fix_spelling_grammar_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please fix the spelling and grammar of the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def shorten_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please shorten the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def expand_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please expand the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def make_friendly_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more friendly. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def make_formal_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please make the following response more formal. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
def simplify_message
|
||||
make_api_call(build_api_call_body("#{AGENT_INSTRUCTION} Please simplify the following response. " \
|
||||
"#{LANGUAGE_INSTRUCTION}"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prompt_from_file(file_name, enterprise: false)
|
||||
path = enterprise ? 'enterprise/lib/enterprise/integrations/openai_prompts' : 'lib/integrations/openai/openai_prompts'
|
||||
Rails.root.join(path, "#{file_name}.txt").read
|
||||
end
|
||||
|
||||
def build_api_call_body(system_content, user_content = event['data']['content'])
|
||||
{
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: system_content },
|
||||
{ role: 'user', content: user_content }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def conversation_messages(in_array_format: false)
|
||||
messages = init_messages_body(in_array_format)
|
||||
|
||||
add_messages_until_token_limit(conversation, messages, in_array_format)
|
||||
end
|
||||
|
||||
def add_messages_until_token_limit(conversation, messages, in_array_format, start_from = 0)
|
||||
character_count = start_from
|
||||
conversation.messages.where(message_type: [:incoming, :outgoing]).where(private: false).reorder('id desc').each do |message|
|
||||
character_count, message_added = add_message_if_within_limit(character_count, message, messages, in_array_format)
|
||||
break unless message_added
|
||||
end
|
||||
messages
|
||||
end
|
||||
|
||||
def add_message_if_within_limit(character_count, message, messages, in_array_format)
|
||||
content = message.content_for_llm
|
||||
if valid_message?(content, character_count)
|
||||
add_message_to_list(message, messages, in_array_format, content)
|
||||
character_count += content.length
|
||||
[character_count, true]
|
||||
else
|
||||
[character_count, false]
|
||||
end
|
||||
end
|
||||
|
||||
def valid_message?(content, character_count)
|
||||
content.present? && character_count + content.length <= TOKEN_LIMIT
|
||||
end
|
||||
|
||||
def add_message_to_list(message, messages, in_array_format, content)
|
||||
formatted_message = format_message(message, in_array_format, content)
|
||||
messages.prepend(formatted_message)
|
||||
end
|
||||
|
||||
def init_messages_body(in_array_format)
|
||||
in_array_format ? [] : ''
|
||||
end
|
||||
|
||||
def format_message(message, in_array_format, content)
|
||||
in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content)
|
||||
end
|
||||
|
||||
def format_message_in_array(message, content)
|
||||
{ role: (message.incoming? ? 'user' : 'assistant'), content: content }
|
||||
end
|
||||
|
||||
def format_message_in_string(message, content)
|
||||
sender_type = message.incoming? ? 'Customer' : 'Agent'
|
||||
"#{sender_type} #{message.sender&.name} : #{content}\n"
|
||||
end
|
||||
|
||||
def summarize_body
|
||||
{
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system',
|
||||
content: prompt_from_file('summary', enterprise: false) },
|
||||
{ role: 'user', content: conversation_messages }
|
||||
]
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def reply_suggestion_body
|
||||
{
|
||||
model: GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system',
|
||||
content: prompt_from_file('reply', enterprise: false) }
|
||||
].concat(conversation_messages(in_array_format: true))
|
||||
}.to_json
|
||||
end
|
||||
end
|
||||
|
||||
Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService')
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
require 'ruby_llm'
|
||||
|
||||
module Llm::Config
|
||||
DEFAULT_MODEL = 'gpt-4o-mini'.freeze
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'.freeze
|
||||
|
||||
class << self
|
||||
def initialized?
|
||||
@initialized ||= false
|
||||
|
||||
@@ -19,6 +19,10 @@ module Llm::Models
|
||||
models_for(feature).include?(model_name.to_s)
|
||||
end
|
||||
|
||||
def credit_multiplier_for(model_name)
|
||||
CONFIG.dig('models', model_name.to_s, 'credit_multiplier') || 1
|
||||
end
|
||||
|
||||
def feature_config(feature_key)
|
||||
feature = features[feature_key.to_s]
|
||||
return nil unless feature
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@iconify-json/fluent": "^1.2.32",
|
||||
"@iconify-json/material-symbols": "^1.2.10",
|
||||
"@lk77/vue3-color": "^3.0.6",
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
|
||||
Generated
+10
@@ -40,6 +40,9 @@ importers:
|
||||
'@highlightjs/vue-plugin':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
|
||||
'@iconify-json/fluent':
|
||||
specifier: ^1.2.32
|
||||
version: 1.2.36
|
||||
'@iconify-json/material-symbols':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10
|
||||
@@ -961,6 +964,9 @@ packages:
|
||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||
deprecated: Use @eslint/object-schema instead
|
||||
|
||||
'@iconify-json/fluent@1.2.36':
|
||||
resolution: {integrity: sha512-DhxwOu5Qiq09o2ehHeUK0I9lC01OeWFlxXP7pIslM0vbi8VuplrrJ7kMg21GJy87iOCevzxf6gTU7TLPGgSknw==}
|
||||
|
||||
'@iconify-json/logos@1.2.10':
|
||||
resolution: {integrity: sha512-qxaXKJ6fu8jzTMPQdHtNxlfx6tBQ0jXRbHZIYy5Ilh8Lx9US9FsAdzZWUR8MXV8PnWTKGDFO4ZZee9VwerCyMA==}
|
||||
|
||||
@@ -5513,6 +5519,10 @@ snapshots:
|
||||
|
||||
'@humanwhocodes/object-schema@2.0.3': {}
|
||||
|
||||
'@iconify-json/fluent@1.2.36':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify-json/logos@1.2.10':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }) }
|
||||
# Use gpt-5-mini (credit_multiplier: 1) to keep credit consumption predictable in tests
|
||||
let(:account) { create(:account, custom_attributes: { plan_name: 'startups' }, captain_models: { 'assistant' => 'gpt-5-mini' }) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::BaseTaskService, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:perform_result) { { message: 'Test response' } }
|
||||
|
||||
# Create a concrete test service class with enterprise module prepended
|
||||
let(:test_service_class) do
|
||||
result = perform_result
|
||||
klass = Class.new(described_class) do
|
||||
define_method(:perform) { result }
|
||||
|
||||
def event_name
|
||||
'test_event'
|
||||
end
|
||||
|
||||
def feature_key
|
||||
'editor'
|
||||
end
|
||||
end
|
||||
# Manually prepend enterprise module to test class
|
||||
klass.prepend(Enterprise::Captain::BaseTaskService)
|
||||
klass
|
||||
end
|
||||
|
||||
let(:service) { test_service_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
end
|
||||
|
||||
describe '#perform with enterprise usage tracking' do
|
||||
# Ensure captain is enabled by default for tests unless explicitly testing disabled state
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
|
||||
end
|
||||
|
||||
context 'when usage limit is exceeded' do
|
||||
before do
|
||||
allow(account).to receive(:usage_limits).and_return({
|
||||
captain: { responses: { current_available: 0 } }
|
||||
})
|
||||
end
|
||||
|
||||
it 'returns usage limit exceeded error' do
|
||||
result = service.perform
|
||||
expect(result[:error]).to eq(I18n.t('captain.copilot_limit'))
|
||||
expect(result[:error_code]).to eq(429)
|
||||
end
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
it 'increments response usage with credit multiplier on successful execution' do
|
||||
expect(account).to receive(:captain_editor_model).and_return('gpt-4.1-mini')
|
||||
expect(account).to receive(:increment_response_usage).with(credits: 1)
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'uses correct credit multiplier for higher cost models' do
|
||||
expect(account).to receive(:captain_editor_model).and_return('gpt-5.1')
|
||||
expect(account).to receive(:increment_response_usage).with(credits: 2)
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when account uses their own OpenAI key via hook' do
|
||||
let!(:openai_hook) { create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'user-own-key' }) }
|
||||
|
||||
it 'uses 1 credit regardless of model cost' do
|
||||
allow(account).to receive(:captain_editor_model).and_return('gpt-5.1')
|
||||
expect(account).to receive(:increment_response_usage).with(credits: 1)
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'uses 1 credit even for expensive models' do
|
||||
allow(account).to receive(:captain_editor_model).and_return('gpt-4.1')
|
||||
expect(account).to receive(:increment_response_usage).with(credits: 1)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when result has an error' do
|
||||
let(:perform_result) { { error: 'API Error' } }
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when result is nil' do
|
||||
let(:perform_result) { nil }
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when result is empty hash' do
|
||||
let(:perform_result) { {} }
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when result has blank message' do
|
||||
let(:perform_result) { { message: '' } }
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when result has nil message' do
|
||||
let(:perform_result) { { message: nil } }
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
it 'actually increments the usage counter in custom_attributes by credit multiplier' do
|
||||
allow(account).to receive(:captain_editor_model).and_return('gpt-4.1-mini')
|
||||
expect do
|
||||
service.perform
|
||||
account.reload
|
||||
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
|
||||
end
|
||||
|
||||
it 'increments by correct credits for expensive models' do
|
||||
allow(account).to receive(:captain_editor_model).and_return('gpt-5.1')
|
||||
expect do
|
||||
service.perform
|
||||
account.reload
|
||||
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(2)
|
||||
end
|
||||
|
||||
context 'when captain is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(false)
|
||||
end
|
||||
|
||||
context 'when on Chatwoot Cloud' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns upgrade error message' do
|
||||
result = service.perform
|
||||
expect(result[:error]).to eq(I18n.t('captain.upgrade'))
|
||||
end
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when self-hosted' do
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns disabled error message' do
|
||||
result = service.perform
|
||||
expect(result[:error]).to eq(I18n.t('captain.disabled'))
|
||||
end
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain is enabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
|
||||
end
|
||||
|
||||
it 'proceeds with the task' do
|
||||
result = service.perform
|
||||
expect(result[:message]).to eq('Test response')
|
||||
expect(result[:error]).to be_nil
|
||||
end
|
||||
|
||||
it 'increments usage' do
|
||||
expect(account).to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,120 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Openai::ProcessorService do
|
||||
subject { described_class.new(hook: hook, event: event) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, :openai, account: account) }
|
||||
|
||||
# Mock RubyLLM objects
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context) }
|
||||
let(:mock_config) { OpenStruct.new }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: 'This is a reply from openai.',
|
||||
input_tokens: nil,
|
||||
output_tokens: nil
|
||||
)
|
||||
end
|
||||
let(:mock_empty_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: '',
|
||||
input_tokens: nil,
|
||||
output_tokens: nil
|
||||
)
|
||||
end
|
||||
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context)
|
||||
allow(mock_context).to receive(:chat).and_return(mock_chat)
|
||||
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when event name is label_suggestion with labels with < 3 messages' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns nil' do
|
||||
create(:label, account: account)
|
||||
create(:label, account: account)
|
||||
|
||||
expect(subject.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is label_suggestion with labels with >3 messages' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
before do
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
|
||||
create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4')
|
||||
|
||||
create(:label, account: account)
|
||||
create(:label, account: account)
|
||||
|
||||
hook.settings['label_suggestion'] = 'true'
|
||||
end
|
||||
|
||||
it 'returns the label suggestions' do
|
||||
result = subject.perform
|
||||
expect(result).to eq({ message: 'This is a reply from openai.' })
|
||||
end
|
||||
|
||||
it 'returns empty string if openai response is blank' do
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_empty_response)
|
||||
|
||||
result = subject.perform
|
||||
expect(result[:message]).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is label_suggestion with no labels' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns nil' do
|
||||
result = subject.perform
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when event name is not one that can be processed' do
|
||||
let(:event) { { 'name' => 'unknown', 'data' => {} } }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(subject.perform).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when hook is not enabled' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
before do
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
|
||||
create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 2')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 3')
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent 4')
|
||||
|
||||
create(:label, account: account)
|
||||
create(:label, account: account)
|
||||
|
||||
hook.settings['label_suggestion'] = nil
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
expect(subject.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Enterprise::Account::PlanUsageAndLimits do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe '#increment_response_usage' do
|
||||
it 'increments usage by 1 by default' do
|
||||
initial_usage = account.custom_attributes['captain_responses_usage'].to_i
|
||||
account.increment_response_usage
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_usage']).to eq(initial_usage + 1)
|
||||
end
|
||||
|
||||
it 'increments usage by the specified credits' do
|
||||
initial_usage = account.custom_attributes['captain_responses_usage'].to_i
|
||||
account.increment_response_usage(credits: 3)
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_usage']).to eq(initial_usage + 3)
|
||||
end
|
||||
|
||||
it 'handles zero initial usage' do
|
||||
account.custom_attributes['captain_responses_usage'] = nil
|
||||
account.save!
|
||||
account.increment_response_usage(credits: 2)
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_usage']).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#increment_response_usage_for_model' do
|
||||
it 'increments by model credit multiplier when using system key' do
|
||||
# gpt-5.1 has credit_multiplier of 2
|
||||
account.increment_response_usage_for_model('gpt-5.1')
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_usage']).to eq(2)
|
||||
end
|
||||
|
||||
it 'increments by 1 when using hook key' do
|
||||
create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'user-key' })
|
||||
# gpt-5.1 has credit_multiplier of 2, but hook key overrides to 1
|
||||
account.increment_response_usage_for_model('gpt-5.1')
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_usage']).to eq(1)
|
||||
end
|
||||
|
||||
it 'defaults to 1 credit for unknown models' do
|
||||
account.increment_response_usage_for_model('unknown-model')
|
||||
account.reload
|
||||
expect(account.custom_attributes['captain_responses_usage']).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,296 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::BaseTaskService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
# Create a concrete test service class since BaseTaskService is abstract
|
||||
let(:test_service_class) do
|
||||
Class.new(described_class) do
|
||||
def perform
|
||||
{ message: 'Test response' }
|
||||
end
|
||||
|
||||
def event_name
|
||||
'test_event'
|
||||
end
|
||||
|
||||
def feature_key
|
||||
'editor'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:service) { test_service_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
# Stub captain enabled check to allow OSS specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'returns the expected result' do
|
||||
result = service.perform
|
||||
expect(result).to eq({ message: 'Test response' })
|
||||
end
|
||||
end
|
||||
|
||||
describe '#event_name' do
|
||||
it 'raises NotImplementedError for base class' do
|
||||
base_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
|
||||
expect { base_service.send(:event_name) }.to raise_error(NotImplementedError, /must implement #event_name/)
|
||||
end
|
||||
|
||||
it 'returns custom event name in subclass' do
|
||||
expect(service.send(:event_name)).to eq('test_event')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#feature_key' do
|
||||
it 'raises NotImplementedError for base class' do
|
||||
base_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
|
||||
expect { base_service.send(:feature_key) }.to raise_error(NotImplementedError, /must implement #feature_key/)
|
||||
end
|
||||
|
||||
it 'returns custom feature key in subclass' do
|
||||
expect(service.send(:feature_key)).to eq('editor')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#configured_model' do
|
||||
it 'returns the configured model for the feature_key' do
|
||||
expect(account).to receive(:captain_editor_model).and_return('gpt-5.1')
|
||||
expect(service.send(:configured_model)).to eq('gpt-5.1')
|
||||
end
|
||||
|
||||
it 'uses the default model when account model is not set' do
|
||||
allow(account).to receive(:captain_editor_model).and_return('gpt-4.1-mini')
|
||||
expect(service.send(:configured_model)).to eq('gpt-4.1-mini')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation' do
|
||||
it 'finds conversation by display_id' do
|
||||
expect(service.send(:conversation)).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'memoizes the conversation' do
|
||||
expect(account.conversations).to receive(:find_by).once.and_return(conversation)
|
||||
service.send(:conversation)
|
||||
service.send(:conversation)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_messages' do
|
||||
let(:message1) { create(:message, conversation: conversation, message_type: :incoming, content: 'Hello', created_at: 1.hour.ago) }
|
||||
let(:message2) { create(:message, conversation: conversation, message_type: :outgoing, content: 'Hi there', created_at: 30.minutes.ago) }
|
||||
let(:message3) { create(:message, conversation: conversation, message_type: :incoming, content: 'How are you?', created_at: 10.minutes.ago) }
|
||||
let(:private_message) { create(:message, conversation: conversation, message_type: :incoming, content: 'Private', private: true) }
|
||||
|
||||
before do
|
||||
message1
|
||||
message2
|
||||
message3
|
||||
private_message
|
||||
end
|
||||
|
||||
it 'returns messages in array format with role and content' do
|
||||
messages = service.send(:conversation_messages)
|
||||
|
||||
expect(messages).to be_an(Array)
|
||||
expect(messages.length).to eq(3)
|
||||
expect(messages[0]).to eq({ role: 'user', content: 'Hello' })
|
||||
expect(messages[1]).to eq({ role: 'assistant', content: 'Hi there' })
|
||||
expect(messages[2]).to eq({ role: 'user', content: 'How are you?' })
|
||||
end
|
||||
|
||||
it 'excludes private messages' do
|
||||
messages = service.send(:conversation_messages)
|
||||
contents = messages.pluck(:content)
|
||||
expect(contents).not_to include('Private')
|
||||
end
|
||||
|
||||
it 'respects token limit' do
|
||||
# Create messages that collectively exceed token limit
|
||||
# Message validation max is 150000, so create multiple large messages
|
||||
10.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: 'a' * 100_000, created_at: i.minutes.ago)
|
||||
end
|
||||
|
||||
messages = service.send(:conversation_messages)
|
||||
total_length = messages.sum { |m| m[:content].length }
|
||||
expect(total_length).to be <= Captain::BaseTaskService::TOKEN_LIMIT
|
||||
end
|
||||
|
||||
it 'respects start_from offset for token counting' do
|
||||
# With a start_from offset, fewer messages should fit
|
||||
start_from = Captain::BaseTaskService::TOKEN_LIMIT - 100
|
||||
messages = service.send(:conversation_messages, start_from: start_from)
|
||||
|
||||
total_length = messages.sum { |m| m[:content].length }
|
||||
expect(total_length).to be <= 100
|
||||
end
|
||||
end
|
||||
|
||||
describe '#make_api_call' do
|
||||
let(:model) { 'gpt-4' }
|
||||
let(:messages) { [{ role: 'system', content: 'Test' }, { role: 'user', content: 'Hello' }] }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'calls execute_ruby_llm_request with correct parameters' do
|
||||
expect(service).to receive(:execute_ruby_llm_request).with(model: model, messages: messages).and_call_original
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
|
||||
it 'instruments the LLM call' do
|
||||
expect(service).to receive(:instrument_llm_call).and_call_original
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
|
||||
it 'returns formatted response with tokens' do
|
||||
result = service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
expect(result[:message]).to eq('Response')
|
||||
expect(result[:usage]['prompt_tokens']).to eq(10)
|
||||
expect(result[:usage]['completion_tokens']).to eq(20)
|
||||
expect(result[:usage]['total_tokens']).to eq(30)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'chat setup' do
|
||||
let(:model) { 'gpt-4' }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_response).to receive(:input_tokens).and_return(10)
|
||||
allow(mock_response).to receive(:output_tokens).and_return(20)
|
||||
end
|
||||
|
||||
context 'with system instructions' do
|
||||
let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] }
|
||||
|
||||
it 'applies system instructions to chat' do
|
||||
expect(mock_chat).to receive(:with_instructions).with('You are helpful')
|
||||
expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response)
|
||||
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation history' do
|
||||
let(:messages) do
|
||||
[
|
||||
{ role: 'system', content: 'You are helpful' },
|
||||
{ role: 'user', content: 'First message' },
|
||||
{ role: 'assistant', content: 'First response' },
|
||||
{ role: 'user', content: 'Second message' }
|
||||
]
|
||||
end
|
||||
|
||||
it 'adds conversation history before asking' do
|
||||
expect(mock_chat).to receive(:with_instructions).with('You are helpful')
|
||||
expect(mock_chat).to receive(:add_message).with(role: :user, content: 'First message').ordered
|
||||
expect(mock_chat).to receive(:add_message).with(role: :assistant, content: 'First response').ordered
|
||||
expect(mock_chat).to receive(:ask).with('Second message').and_return(mock_response)
|
||||
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with single message' do
|
||||
let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] }
|
||||
|
||||
it 'does not add conversation history' do
|
||||
expect(mock_chat).to receive(:with_instructions).with('You are helpful')
|
||||
expect(mock_chat).not_to receive(:add_message)
|
||||
expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response)
|
||||
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'error handling' do
|
||||
let(:model) { 'gpt-4' }
|
||||
let(:messages) { [{ role: 'user', content: 'Hello' }] }
|
||||
let(:error) { StandardError.new('API Error') }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker) }
|
||||
|
||||
before do
|
||||
allow(Llm::Config).to receive(:with_api_key).and_raise(error)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
|
||||
allow(exception_tracker).to receive(:capture_exception)
|
||||
end
|
||||
|
||||
it 'tracks exceptions' do
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
|
||||
service.send(:make_api_call, model: model, messages: messages)
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
expect(exception_tracker).to receive(:capture_exception)
|
||||
result = service.send(:make_api_call, model: model, messages: messages)
|
||||
|
||||
expect(result[:error]).to eq('API Error')
|
||||
expect(result[:request_messages]).to eq(messages)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#prompt_from_file' do
|
||||
it 'reads prompt from file' do
|
||||
allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content'))
|
||||
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_original_context' do
|
||||
it 'returns the most recent user message' do
|
||||
messages = [
|
||||
{ role: 'user', content: 'First question' },
|
||||
{ role: 'assistant', content: 'First response' },
|
||||
{ role: 'user', content: 'Follow-up question' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to eq('Follow-up question')
|
||||
end
|
||||
|
||||
it 'returns nil when no user messages exist' do
|
||||
messages = [
|
||||
{ role: 'system', content: 'System prompt' },
|
||||
{ role: 'assistant', content: 'Response' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns the only user message when there is just one' do
|
||||
messages = [
|
||||
{ role: 'system', content: 'System prompt' },
|
||||
{ role: 'user', content: 'Single question' }
|
||||
]
|
||||
|
||||
result = service.send(:extract_original_context, messages)
|
||||
expect(result).to eq('Single question')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,164 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::FollowUpService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:user_message) { 'Make it more concise' }
|
||||
let(:follow_up_context) do
|
||||
{
|
||||
'event_name' => 'professional',
|
||||
'original_context' => 'Please help me with this issue',
|
||||
'last_response' => 'I would be happy to assist you with this matter.',
|
||||
'conversation_history' => [
|
||||
{ 'role' => 'user', 'content' => 'Make it shorter' },
|
||||
{ 'role' => 'assistant', 'content' => 'Happy to help with this.' }
|
||||
]
|
||||
}
|
||||
end
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
follow_up_context: follow_up_context,
|
||||
user_message: user_message,
|
||||
conversation_display_id: conversation.display_id
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation_display_id is provided' do
|
||||
it 'resolves conversation for instrumentation' do
|
||||
expect(service.send(:conversation)).to eq(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when follow-up context exists' do
|
||||
it 'constructs messages array with full conversation history' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
messages = args[:messages]
|
||||
|
||||
expect(messages).to match(
|
||||
[
|
||||
a_hash_including(role: 'system', content: include('tone rewrite (professional)')),
|
||||
{ role: 'user', content: 'Please help me with this issue' },
|
||||
{ role: 'assistant', content: 'I would be happy to assist you with this matter.' },
|
||||
{ role: 'user', content: 'Make it shorter' },
|
||||
{ role: 'assistant', content: 'Happy to help with this.' },
|
||||
{ role: 'user', content: 'Make it more concise' }
|
||||
]
|
||||
)
|
||||
|
||||
{ message: 'Refined response' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns updated follow-up context' do
|
||||
allow(service).to receive(:make_api_call).and_return({ message: 'Refined response' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Refined response')
|
||||
expect(result[:follow_up_context]['last_response']).to eq('Refined response')
|
||||
expect(result[:follow_up_context]['conversation_history'].length).to eq(4)
|
||||
expect(result[:follow_up_context]['conversation_history'][-2]['content']).to eq('Make it more concise')
|
||||
expect(result[:follow_up_context]['conversation_history'][-1]['content']).to eq('Refined response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when follow-up context is missing' do
|
||||
let(:follow_up_context) { nil }
|
||||
|
||||
it 'returns error with 400 code' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:error]).to eq('Follow-up context missing')
|
||||
expect(result[:error_code]).to eq(400)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_follow_up_system_prompt' do
|
||||
it 'describes tone rewrite actions' do
|
||||
%w[professional casual friendly confident straightforward].each do |tone|
|
||||
session = { 'event_name' => tone }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include("tone rewrite (#{tone})")
|
||||
expect(prompt).to include('help them refine the result')
|
||||
end
|
||||
end
|
||||
|
||||
it 'describes fix_spelling_grammar action' do
|
||||
session = { 'event_name' => 'fix_spelling_grammar' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('spelling and grammar correction')
|
||||
end
|
||||
|
||||
it 'describes improve action' do
|
||||
session = { 'event_name' => 'improve' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('message improvement')
|
||||
end
|
||||
|
||||
it 'describes summarize action' do
|
||||
session = { 'event_name' => 'summarize' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('conversation summary')
|
||||
end
|
||||
|
||||
it 'describes reply_suggestion action' do
|
||||
session = { 'event_name' => 'reply_suggestion' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('reply suggestion')
|
||||
end
|
||||
|
||||
it 'describes label_suggestion action' do
|
||||
session = { 'event_name' => 'label_suggestion' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('label suggestion')
|
||||
end
|
||||
|
||||
it 'uses event_name directly for unknown actions' do
|
||||
session = { 'event_name' => 'custom_action' }
|
||||
prompt = service.send(:build_follow_up_system_prompt, session)
|
||||
|
||||
expect(prompt).to include('custom_action')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#describe_previous_action' do
|
||||
it 'returns tone description for tone operations' do
|
||||
expect(service.send(:describe_previous_action, 'professional')).to eq('tone rewrite (professional)')
|
||||
expect(service.send(:describe_previous_action, 'casual')).to eq('tone rewrite (casual)')
|
||||
expect(service.send(:describe_previous_action, 'friendly')).to eq('tone rewrite (friendly)')
|
||||
expect(service.send(:describe_previous_action, 'confident')).to eq('tone rewrite (confident)')
|
||||
expect(service.send(:describe_previous_action, 'straightforward')).to eq('tone rewrite (straightforward)')
|
||||
end
|
||||
|
||||
it 'returns specific descriptions for other operations' do
|
||||
expect(service.send(:describe_previous_action, 'fix_spelling_grammar')).to eq('spelling and grammar correction')
|
||||
expect(service.send(:describe_previous_action, 'improve')).to eq('message improvement')
|
||||
expect(service.send(:describe_previous_action, 'summarize')).to eq('conversation summary')
|
||||
expect(service.send(:describe_previous_action, 'reply_suggestion')).to eq('reply suggestion')
|
||||
expect(service.send(:describe_previous_action, 'label_suggestion')).to eq('label suggestion')
|
||||
end
|
||||
|
||||
it 'returns event name for unknown operations' do
|
||||
expect(service.send(:describe_previous_action, 'unknown')).to eq('unknown')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,169 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::LabelSuggestionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:label1) { create(:label, account: account, title: 'bug') }
|
||||
let(:label2) { create(:label, account: account, title: 'feature-request') }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'bug, feature-request', input_tokens: 100, output_tokens: 20) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
label1
|
||||
label2
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
|
||||
end
|
||||
|
||||
describe '#label_suggestion_message' do
|
||||
context 'with valid conversation' do
|
||||
before do
|
||||
# Create enough incoming messages to pass validation
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}", created_at: i.minutes.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns label suggestions' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'removes "Labels:" prefix from response' do
|
||||
allow(mock_response).to receive(:content).and_return('Labels: bug, feature-request')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq(' bug, feature-request')
|
||||
end
|
||||
|
||||
it 'removes "Label:" prefix (singular) from response' do
|
||||
allow(mock_response).to receive(:content).and_return('label: bug')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq(' bug')
|
||||
end
|
||||
|
||||
it 'builds labels_with_messages format correctly' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
user_message = args[:messages].find { |m| m[:role] == 'user' }[:content]
|
||||
|
||||
expect(user_message).to include('Messages:')
|
||||
expect(user_message).to include('Labels:')
|
||||
expect(user_message).to include('bug, feature-request')
|
||||
{ message: 'bug' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid conversation' do
|
||||
it 'returns nil when conversation has less than 3 incoming messages' do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Message 1')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Message 2')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when conversation has more than 100 messages' do
|
||||
101.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
|
||||
end
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil when conversation has >20 messages and last is not incoming' do
|
||||
21.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: "Message #{i}")
|
||||
end
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'Agent reply')
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when caching' do
|
||||
before do
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}", created_at: i.minutes.ago)
|
||||
end
|
||||
end
|
||||
|
||||
it 'reads from cache on cache hit' do
|
||||
# Warm up cache
|
||||
service.perform
|
||||
|
||||
# Create new service instance to test cache read
|
||||
new_service = described_class.new(account: account, conversation_display_id: conversation.display_id)
|
||||
|
||||
expect(new_service).not_to receive(:make_api_call)
|
||||
result = new_service.perform
|
||||
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'writes to cache on cache miss' do
|
||||
expect(Redis::Alfred).to receive(:setex).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns nil for invalid cached JSON' do
|
||||
# Set invalid JSON in cache
|
||||
cache_key = service.send(:cache_key)
|
||||
Redis::Alfred.set(cache_key, 'invalid json')
|
||||
|
||||
result = service.perform
|
||||
|
||||
# Should make API call since cache read failed
|
||||
expect(result[:message]).to eq('bug, feature-request')
|
||||
end
|
||||
|
||||
it 'does not cache error responses' do
|
||||
error_response = { error: 'API Error', request_messages: [] }
|
||||
allow(service).to receive(:make_api_call).and_return(error_response)
|
||||
|
||||
expect(Redis::Alfred).not_to receive(:setex)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no labels exist' do
|
||||
before do
|
||||
Label.destroy_all
|
||||
3.times do |i|
|
||||
create(:message, conversation: conversation, message_type: :incoming,
|
||||
content: "Message #{i}")
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns nil' do
|
||||
result = service.perform
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::ReplySuggestionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Suggested reply', input_tokens: 100, output_tokens: 50) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:add_message)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:message1) { create(:message, conversation: conversation, message_type: :incoming, content: 'Hello') }
|
||||
let(:message2) { create(:message, conversation: conversation, message_type: :outgoing, content: 'Hi there') }
|
||||
|
||||
before do
|
||||
message1
|
||||
message2
|
||||
end
|
||||
|
||||
it 'uses conversation_messages to build message history' do
|
||||
expect(service).to receive(:conversation_messages).and_call_original
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'concatenates system prompt with conversation history' do
|
||||
allow(service).to receive(:prompt_from_file).with('reply').and_return('Help with reply')
|
||||
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expected_messages = [
|
||||
{ role: 'system', content: 'Help with reply' },
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' }
|
||||
]
|
||||
|
||||
expect(args[:messages]).to eq(expected_messages)
|
||||
{ message: 'Suggested reply' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'passes correct model to API' do
|
||||
expect(service).to receive(:make_api_call).with(
|
||||
hash_including(model: Captain::BaseTaskService::GPT_MODEL)
|
||||
).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns formatted response' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Suggested reply')
|
||||
expect(result[:usage]['prompt_tokens']).to eq(100)
|
||||
expect(result[:usage]['completion_tokens']).to eq(50)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,166 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::RewriteService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:content) { 'I need help with my order' }
|
||||
let(:operation) { 'fix_spelling_grammar' }
|
||||
let(:service) { described_class.new(account: account, content: content, operation: operation, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Rewritten text', input_tokens: 10, output_tokens: 5) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform with fix_spelling_grammar operation' do
|
||||
let(:operation) { 'fix_spelling_grammar' }
|
||||
|
||||
it 'uses fix_spelling_grammar prompt' do
|
||||
expect(service).to receive(:prompt_from_file).with('fix_spelling_grammar').and_return('Fix errors')
|
||||
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Fix errors')
|
||||
expect(args[:messages][1][:content]).to eq(content)
|
||||
{ message: 'Fixed' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tone rewrite methods' do
|
||||
let(:tone_prompt_template) { 'Rewrite in {{ tone }} tone' }
|
||||
|
||||
before do
|
||||
allow(service).to receive(:prompt_from_file).with('tone_rewrite').and_return(tone_prompt_template)
|
||||
end
|
||||
|
||||
describe '#perform with casual operation' do
|
||||
let(:operation) { 'casual' }
|
||||
|
||||
it 'uses casual tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in casual tone')
|
||||
{ message: 'Hey, need help?' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with professional operation' do
|
||||
let(:operation) { 'professional' }
|
||||
|
||||
it 'uses professional tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in professional tone')
|
||||
{ message: 'Professional text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with friendly operation' do
|
||||
let(:operation) { 'friendly' }
|
||||
|
||||
it 'uses friendly tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in friendly tone')
|
||||
{ message: 'Friendly text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with confident operation' do
|
||||
let(:operation) { 'confident' }
|
||||
|
||||
it 'uses confident tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in confident tone')
|
||||
{ message: 'Confident text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with straightforward operation' do
|
||||
let(:operation) { 'straightforward' }
|
||||
|
||||
it 'uses straightforward tone' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages][0][:content]).to eq('Rewrite in straightforward tone')
|
||||
{ message: 'Straightforward text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with improve operation' do
|
||||
let(:operation) { 'improve' }
|
||||
let(:improve_template) { 'Context: {{ conversation_context }}\nDraft: {{ draft_message }}' }
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Customer message')
|
||||
allow(service).to receive(:prompt_from_file).with('improve').and_return(improve_template)
|
||||
end
|
||||
|
||||
it 'uses conversation context and draft message with Liquid template' do
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
system_content = args[:messages][0][:content]
|
||||
|
||||
expect(system_content).to include('Context:')
|
||||
expect(system_content).to include('Draft: I need help with my order')
|
||||
expect(args[:messages][1][:content]).to eq(content)
|
||||
{ message: 'Improved text' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns formatted response' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Rewritten text')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with invalid operation' do
|
||||
it 'raises ArgumentError for unknown operation' do
|
||||
invalid_service = described_class.new(
|
||||
account: account,
|
||||
content: content,
|
||||
operation: 'invalid_operation',
|
||||
conversation_display_id: conversation.display_id
|
||||
)
|
||||
|
||||
expect { invalid_service.perform }.to raise_error(ArgumentError, /Invalid operation/)
|
||||
end
|
||||
|
||||
it 'prevents method injection attacks' do
|
||||
dangerous_service = described_class.new(
|
||||
account: account,
|
||||
content: content,
|
||||
operation: 'perform',
|
||||
conversation_display_id: conversation.display_id
|
||||
)
|
||||
|
||||
expect { dangerous_service.perform }.to raise_error(ArgumentError, /Invalid operation/)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::SummaryService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
let(:mock_response) { instance_double(RubyLLM::Message, content: 'Summary of conversation', input_tokens: 100, output_tokens: 50) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
# Stub captain enabled check to allow specs to test base functionality
|
||||
# without enterprise module interference
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'passes correct model to API' do
|
||||
expect(service).to receive(:make_api_call).with(
|
||||
hash_including(model: Captain::BaseTaskService::GPT_MODEL)
|
||||
).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'passes system prompt and conversation text as messages' do
|
||||
allow(service).to receive(:prompt_from_file).with('summary').and_return('Summarize this')
|
||||
|
||||
expect(service).to receive(:make_api_call) do |args|
|
||||
expect(args[:messages].length).to eq(2)
|
||||
expect(args[:messages][0][:role]).to eq('system')
|
||||
expect(args[:messages][0][:content]).to eq('Summarize this')
|
||||
expect(args[:messages][1][:role]).to eq('user')
|
||||
expect(args[:messages][1][:content]).to be_a(String)
|
||||
{ message: 'Summary' }
|
||||
end
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'returns formatted response' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('Summary of conversation')
|
||||
expect(result[:usage]['prompt_tokens']).to eq(100)
|
||||
expect(result[:usage]['completion_tokens']).to eq(50)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,201 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Integrations::Openai::ProcessorService do
|
||||
subject(:service) { described_class.new(hook: hook, event: event) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:hook) { create(:integrations_hook, :openai, account: account) }
|
||||
|
||||
# Mock RubyLLM objects
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context) }
|
||||
let(:mock_config) { OpenStruct.new }
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: 'This is a reply from openai.',
|
||||
input_tokens: nil,
|
||||
output_tokens: nil
|
||||
)
|
||||
end
|
||||
let(:mock_response_with_usage) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: 'This is a reply from openai.',
|
||||
input_tokens: 50,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(RubyLLM).to receive(:context).and_yield(mock_config).and_return(mock_context)
|
||||
allow(mock_context).to receive(:chat).and_return(mock_chat)
|
||||
|
||||
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
describe 'text transformation operations' do
|
||||
shared_examples 'text transformation operation' do |event_name|
|
||||
let(:event) { { 'name' => event_name, 'data' => { 'content' => 'This is a test' } } }
|
||||
|
||||
it 'returns the transformed text' do
|
||||
result = service.perform
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
end
|
||||
|
||||
it 'sends the user content to the LLM' do
|
||||
service.perform
|
||||
expect(mock_chat).to have_received(:ask).with('This is a test')
|
||||
end
|
||||
|
||||
it 'sets system instructions' do
|
||||
service.perform
|
||||
expect(mock_chat).to have_received(:with_instructions).with(a_string_including('You are a helpful support agent'))
|
||||
end
|
||||
end
|
||||
|
||||
it_behaves_like 'text transformation operation', 'rephrase'
|
||||
it_behaves_like 'text transformation operation', 'fix_spelling_grammar'
|
||||
it_behaves_like 'text transformation operation', 'shorten'
|
||||
it_behaves_like 'text transformation operation', 'expand'
|
||||
it_behaves_like 'text transformation operation', 'make_friendly'
|
||||
it_behaves_like 'text transformation operation', 'make_formal'
|
||||
it_behaves_like 'text transformation operation', 'simplify'
|
||||
end
|
||||
|
||||
describe 'conversation-based operations' do
|
||||
let!(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
create(:message, account: account, conversation: conversation, message_type: :incoming, content: 'hello agent')
|
||||
create(:message, account: account, conversation: conversation, message_type: :outgoing, content: 'hello customer')
|
||||
end
|
||||
|
||||
context 'with reply_suggestion event' do
|
||||
let(:event) { { 'name' => 'reply_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns the suggested reply' do
|
||||
result = service.perform
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
end
|
||||
|
||||
it 'adds conversation history before asking' do
|
||||
service.perform
|
||||
# Should add the first message as history, then ask with the last message
|
||||
expect(mock_chat).to have_received(:add_message).with(role: :user, content: 'hello agent')
|
||||
expect(mock_chat).to have_received(:ask).with('hello customer')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with summarize event' do
|
||||
let(:event) { { 'name' => 'summarize', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns the summary' do
|
||||
result = service.perform
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
end
|
||||
|
||||
it 'sends formatted conversation as a single message' do
|
||||
service.perform
|
||||
# Summarize sends conversation as a formatted string in one user message
|
||||
expect(mock_chat).to have_received(:ask).with(a_string_matching(/Customer.*hello agent.*Agent.*hello customer/m))
|
||||
end
|
||||
end
|
||||
|
||||
context 'with label_suggestion event and no labels' do
|
||||
let(:event) { { 'name' => 'label_suggestion', 'data' => { 'conversation_display_id' => conversation.display_id } } }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'edge cases' do
|
||||
context 'with unknown event name' do
|
||||
let(:event) { { 'name' => 'unknown', 'data' => {} } }
|
||||
|
||||
it 'returns nil' do
|
||||
expect(service.perform).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'response structure' do
|
||||
let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
|
||||
|
||||
context 'when response includes usage data' do
|
||||
before do
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response_with_usage)
|
||||
end
|
||||
|
||||
it 'returns message with usage data' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
expect(result[:usage]['prompt_tokens']).to eq(50)
|
||||
expect(result[:usage]['completion_tokens']).to eq(20)
|
||||
expect(result[:usage]['total_tokens']).to eq(70)
|
||||
end
|
||||
|
||||
it 'includes request_messages in response' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:request_messages]).to be_an(Array)
|
||||
expect(result[:request_messages].length).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when response does not include usage data' do
|
||||
it 'returns message with zero total tokens' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:message]).to eq('This is a reply from openai.')
|
||||
expect(result[:usage]['total_tokens']).to eq(0)
|
||||
end
|
||||
|
||||
it 'includes request_messages in response' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:request_messages]).to be_an(Array)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'endpoint configuration' do
|
||||
let(:event) { { 'name' => 'rephrase', 'data' => { 'content' => 'test message' } } }
|
||||
|
||||
context 'without CAPTAIN_OPEN_AI_ENDPOINT configured' do
|
||||
before { InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy }
|
||||
|
||||
it 'uses default OpenAI endpoint' do
|
||||
expect(Llm::Config).to receive(:with_api_key).with(
|
||||
hook.settings['api_key'],
|
||||
api_base: 'https://api.openai.com/v1'
|
||||
).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'with CAPTAIN_OPEN_AI_ENDPOINT configured' do
|
||||
before do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://custom.azure.com/')
|
||||
end
|
||||
|
||||
it 'uses custom endpoint' do
|
||||
expect(Llm::Config).to receive(:with_api_key).with(
|
||||
hook.settings['api_key'],
|
||||
api_base: 'https://custom.azure.com/v1'
|
||||
).and_call_original
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Llm::Models do
|
||||
describe '.credit_multiplier_for' do
|
||||
it 'returns the credit multiplier for a known model' do
|
||||
expect(described_class.credit_multiplier_for('gpt-4.1')).to eq(3)
|
||||
expect(described_class.credit_multiplier_for('gpt-4.1-mini')).to eq(1)
|
||||
expect(described_class.credit_multiplier_for('gpt-5.1')).to eq(2)
|
||||
end
|
||||
|
||||
it 'returns 1 for an unknown model' do
|
||||
expect(described_class.credit_multiplier_for('unknown-model')).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns 1 for nil' do
|
||||
expect(described_class.credit_multiplier_for(nil)).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -131,4 +131,48 @@ RSpec.describe CaptainFeaturable do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#captain_api_key' do
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'system-key')
|
||||
end
|
||||
|
||||
context 'when openai hook is configured with api key' do
|
||||
before do
|
||||
create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
|
||||
end
|
||||
|
||||
it 'returns the hook api key' do
|
||||
expect(account.captain_api_key).to eq('hook-key')
|
||||
end
|
||||
|
||||
it 'returns true for using_openai_hook_key?' do
|
||||
expect(account.using_openai_hook_key?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when openai hook is not configured' do
|
||||
it 'returns the system api key' do
|
||||
expect(account.captain_api_key).to eq('system-key')
|
||||
end
|
||||
|
||||
it 'returns false for using_openai_hook_key?' do
|
||||
expect(account.using_openai_hook_key?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when openai hook is disabled' do
|
||||
before do
|
||||
create(:integrations_hook, account: account, app_id: 'openai', status: 'disabled', settings: { 'api_key' => 'hook-key' })
|
||||
end
|
||||
|
||||
it 'returns the system api key' do
|
||||
expect(account.captain_api_key).to eq('system-key')
|
||||
end
|
||||
|
||||
it 'returns false for using_openai_hook_key?' do
|
||||
expect(account.using_openai_hook_key?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,27 +31,6 @@ RSpec.describe Integrations::Hook do
|
||||
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({ :error => '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
|
||||
|
||||
describe 'scopes' do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
@@ -259,6 +259,7 @@ const tailwindConfig = {
|
||||
'ph',
|
||||
'material-symbols',
|
||||
'teenyicons',
|
||||
'fluent',
|
||||
]),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -210,6 +210,21 @@ export const colors = {
|
||||
12: 'rgb(var(--gray-12) / <alpha-value>)',
|
||||
},
|
||||
|
||||
violet: {
|
||||
1: 'rgb(var(--violet-1) / <alpha-value>)',
|
||||
2: 'rgb(var(--violet-2) / <alpha-value>)',
|
||||
3: 'rgb(var(--violet-3) / <alpha-value>)',
|
||||
4: 'rgb(var(--violet-4) / <alpha-value>)',
|
||||
5: 'rgb(var(--violet-5) / <alpha-value>)',
|
||||
6: 'rgb(var(--violet-6) / <alpha-value>)',
|
||||
7: 'rgb(var(--violet-7) / <alpha-value>)',
|
||||
8: 'rgb(var(--violet-8) / <alpha-value>)',
|
||||
9: 'rgb(var(--violet-9) / <alpha-value>)',
|
||||
10: 'rgb(var(--violet-10) / <alpha-value>)',
|
||||
11: 'rgb(var(--violet-11) / <alpha-value>)',
|
||||
12: 'rgb(var(--violet-12) / <alpha-value>)',
|
||||
},
|
||||
|
||||
black: '#000000',
|
||||
brand: '#2781F6',
|
||||
background: 'rgb(var(--background-color) / <alpha-value>)',
|
||||
|
||||
Reference in New Issue
Block a user