feat: new Captain Editor (#13235)

Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
This commit is contained in:
Shivam Mishra 2026-01-21 13:39:07 +05:30 committed by GitHub
parent c77c9c9d8a
commit 6a482926b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 3887 additions and 1798 deletions

View File

@ -49,7 +49,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
}
@allowed_configs = mapping.fetch(

View File

@ -2,13 +2,6 @@
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
@ -48,6 +41,12 @@ help_center:
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'icon-captain'
config_key: 'captain'
# ------- Communication Channels ------- #
live_chat:

View File

@ -0,0 +1,107 @@
/* 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 });
}
/**
* 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();

View File

@ -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();

View File

@ -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;

View File

@ -1,160 +0,0 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
import { emitter } from 'shared/helpers/mitt';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
AIAssistanceModal,
AICTAModal,
AIAssistanceCTAButton,
},
emits: ['replaceText'],
setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings();
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
const { isAdmin } = useAdmin();
const initialMessage = ref('');
const initializeMessage = draftMsg => {
initialMessage.value = draftMsg;
};
const keyboardEvents = {
'$mod+KeyZ': {
action: () => {
if (initialMessage.value) {
emit('replaceText', initialMessage.value);
initialMessage.value = '';
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
updateUISettings,
isAdmin,
initialMessage,
initializeMessage,
recordAnalytics,
isAIIntegrationEnabled,
draftMessage,
};
},
data: () => ({
showAIAssistanceModal: false,
showAICtaModal: false,
aiOption: '',
}),
computed: {
...mapGetters({
isAChatwootInstance: 'globalConfig/isAChatwootInstance',
}),
isAICTAModalDismissed() {
return this.uiSettings.is_open_ai_cta_modal_dismissed;
},
// Display a AI CTA button for admins if the AI integration has not been added yet and the AI assistance modal has not been dismissed.
shouldShowAIAssistCTAButtonForAdmin() {
return (
this.isAdmin &&
!this.isAIIntegrationEnabled &&
!this.isAICTAModalDismissed &&
this.isAChatwootInstance
);
},
// Display a AI CTA button for agents and other admins who have not yet opened the AI assistance modal.
shouldShowAIAssistCTAButton() {
return this.isAIIntegrationEnabled && !this.isAICTAModalDismissed;
},
},
mounted() {
emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initializeMessage(this.draftMessage);
},
methods: {
hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
aiOption: this.aiOption,
});
this.showAIAssistanceModal = false;
},
openAIAssist() {
// Dismiss the CTA modal if it is not dismissed
if (!this.isAICTAModalDismissed) {
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
}
this.initializeMessage(this.draftMessage);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' });
},
hideAICtaModal() {
this.showAICtaModal = false;
},
openAICta() {
this.showAICtaModal = true;
},
onAIAssist(option) {
this.aiOption = option;
this.showAIAssistanceModal = true;
},
insertText(message) {
this.$emit('replaceText', message);
},
},
};
</script>
<template>
<div>
<div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton"
@open="openAIAssist"
/>
<NextButton
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
slate
faded
sm
@click="openAIAssist"
/>
<woot-modal
v-model:show="showAIAssistanceModal"
:on-close="hideAIAssistanceModal"
>
<AIAssistanceModal
:ai-option="aiOption"
@apply-text="insertText"
@close="hideAIAssistanceModal"
/>
</woot-modal>
</div>
<div v-else-if="shouldShowAIAssistCTAButtonForAdmin" class="relative">
<AIAssistanceCTAButton @click="openAICta" />
<woot-modal v-model:show="showAICtaModal" :on-close="hideAICtaModal">
<AICTAModal @close="hideAICtaModal" />
</woot-modal>
</div>
</div>
</template>

View File

@ -1,103 +0,0 @@
<script setup>
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['open']);
const onClick = () => {
emit('open');
};
</script>
<template>
<div class="relative">
<NextButton
class="cta-btn cta-btn-light dark:cta-btn-dark hover:cta-btn-light-hover dark:hover:cta-btn-dark-hover"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.AI_ASSIST')"
icon="i-ph-magic-wand"
sm
@click="onClick"
/>
<div
class="radar-ping-animation absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand"
/>
<div
class="absolute top-0 right-0 -mt-1 -mr-1 rounded-full w-3 h-3 bg-n-brand opacity-50"
/>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
/* Gradient animation */
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.cta-btn {
animation: gradient 5s ease infinite;
@apply text-n-slate-12 border-0 text-xs;
}
.cta-btn-light {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #f2f5f8, #f2f5f8);
}
.cta-btn-dark {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #313538, #313538);
}
.cta-btn-light-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #e3e5e7, #e3e5e7);
}
.cta-btn-dark-hover {
background: linear-gradient(
255.98deg,
rgba(161, 87, 246, 0.2) 15.83%,
rgba(71, 145, 247, 0.2) 81.39%
),
linear-gradient(0deg, #202425, #202425);
}
/* Radar ping animation */
@keyframes ping {
75%,
100% {
transform: scale(2);
opacity: 0;
}
}
.radar-ping-animation {
animation: ping 1s ease infinite;
}
}
</style>

View File

@ -1,118 +0,0 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import AILoader from './AILoader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
AILoader,
NextButton,
},
props: {
aiOption: {
type: String,
required: true,
},
},
emits: ['close', 'applyText'],
setup() {
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return { draftMessage, processEvent, recordAnalytics, formatMessage };
},
data() {
return {
generatedContent: '',
isGenerating: true,
};
},
computed: {
headerTitle() {
const translationKey = this.aiOption?.toUpperCase();
return translationKey
? this.$t(`INTEGRATION_SETTINGS.OPEN_AI.WITH_AI`, {
option: this.$t(
`INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.${translationKey}`
),
})
: '';
},
},
mounted() {
this.generateAIContent(this.aiOption);
},
methods: {
onClose() {
this.$emit('close');
},
async generateAIContent(type = 'rephrase') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
this.isGenerating = false;
},
applyText() {
this.recordAnalytics(this.aiOption);
this.$emit('applyText', this.generatedContent);
this.onClose();
},
},
};
</script>
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="headerTitle" />
<form
class="flex flex-col w-full modal-content"
@submit.prevent="applyText"
>
<div v-if="draftMessage" class="w-full">
<h4 class="mt-1 text-base text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.DRAFT_TITLE') }}
</h4>
<p v-dompurify-html="formatMessage(draftMessage, false)" />
<h4 class="mt-1 text-base text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.GENERATED_TITLE')
}}
</h4>
</div>
<div>
<AILoader v-if="isGenerating" />
<p v-else v-dompurify-html="formatMessage(generatedContent, false)" />
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.CANCEL')
"
@click.prevent="onClose"
/>
<NextButton
type="submit"
:disabled="!generatedContent"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.ASSISTANCE_MODAL.BUTTONS.APPLY')
"
/>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
.modal-content {
@apply pt-2 px-8 pb-8;
}
.container {
width: 100%;
}
</style>

View File

@ -1,130 +0,0 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAI } from 'dashboard/composables/useAI';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['close'],
setup() {
const { updateUISettings } = useUISettings();
const { recordAnalytics } = useAI();
const v$ = useVuelidate();
return { updateUISettings, v$, recordAnalytics };
},
data() {
return {
value: '',
};
},
validations: {
value: {
required,
},
},
methods: {
onClose() {
this.$emit('close');
},
onDismiss() {
useAlert(
this.$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DISMISS_MESSAGE')
);
this.updateUISettings({
is_open_ai_cta_modal_dismissed: true,
});
this.onClose();
},
async finishOpenAI() {
const payload = {
app_id: 'openai',
settings: {
api_key: this.value,
},
};
try {
await this.$store.dispatch('integrations/createHook', payload);
this.alertMessage = this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.SUCCESS_MESSAGE'
);
this.recordAnalytics(
OPEN_AI_EVENTS.ADDED_AI_INTEGRATION_VIA_CTA_BUTTON
);
this.onClose();
} catch (error) {
const errorMessage = error?.response?.data?.message;
this.alertMessage =
errorMessage || this.$t('INTEGRATION_APPS.ADD.API.ERROR_MESSAGE');
} finally {
useAlert(this.alertMessage);
}
},
openOpenAIDoc() {
window.open('https://www.chatwoot.com/blog/v2-17', '_blank');
},
},
};
</script>
<template>
<div class="flex-1 min-w-0 px-0">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.TITLE')"
:header-content="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.DESC')"
/>
<form
class="flex flex-col flex-wrap modal-content"
@submit.prevent="finishOpenAI"
>
<div class="w-full mt-2">
<woot-input
v-model="value"
type="text"
:class="{ error: v$.value.$error }"
:placeholder="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.KEY_PLACEHOLDER')
"
@blur="v$.value.$touch"
/>
</div>
<div class="flex flex-row justify-between w-full gap-2 px-0 py-2">
<NextButton
ghost
type="button"
class="!px-3"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.NEED_HELP')
"
@click.prevent="openOpenAIDoc"
/>
<div class="flex items-center gap-1">
<NextButton
faded
slate
type="reset"
:label="
$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.DISMISS')
"
@click.prevent="onDismiss"
/>
<NextButton
type="submit"
:disabled="v$.value.$invalid"
:label="$t('INTEGRATION_SETTINGS.OPEN_AI.CTA_MODAL.BUTTONS.FINISH')"
/>
</div>
</div>
</form>
</div>
</template>

View File

@ -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

View File

@ -0,0 +1,253 @@
<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 handleSubmit = () => emit('send');
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(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
}
return false; // Allow other keys to work normally
}
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,
},
});
}
// 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"
/>
</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>

View File

@ -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 { useCaptain } from 'dashboard/composables/useCaptain';
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 } = useCaptain();
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>

View File

@ -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>

View File

@ -16,13 +16,16 @@ 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';
import { useCaptain } from 'dashboard/composables/useCaptain';
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,13 +103,16 @@ const emit = defineEmits([
'focus',
'input',
'update:modelValue',
'executeCopilotAction',
]);
const { t } = useI18n();
const { captainTasksEnabled } = useCaptain();
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,17 +122,24 @@ const editorSchema = computed(() => {
if (!props.channelType) return messageSchema;
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return buildMessageSchema(formatting.marks, formatting.nodes);
});
const editorMenuOptions = computed(() => {
const formatType = props.isPrivate
? DEFAULT_FORMATTING
? PRIVATE_NOTE_FORMATTING
: effectiveChannelType.value || DEFAULT_FORMATTING;
const formatting = getFormattingForEditor(formatType);
const formatting = getFormattingForEditor(
formatType,
captainTasksEnabled.value
);
return formatting.menu;
});
@ -185,6 +198,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 +395,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 +633,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 +800,7 @@ onMounted(() => {
props.modelValue,
props.placeholder,
plugins.value,
{ onImageUpload: openFileBrowser },
{ onImageUpload: openFileBrowser, onCopilotClick: handleCopilotClick },
editorMenuOptions.value
);
@ -802,6 +845,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 +906,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply size-full;
}
}
.ProseMirror-copilot svg {
@apply fill-n-violet-9 text-n-violet-9 stroke-none;
}
}
}
@ -994,6 +1049,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 text-n-violet-9 stroke-none;
}
}
.ProseMirror-menu-active {

View File

@ -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,

View File

@ -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"

View File

@ -1,14 +1,22 @@
<script>
import { ref } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useCaptain } from 'dashboard/composables/useCaptain';
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 +27,10 @@ export default {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
isMessageLengthReachingThreshold: {
type: Boolean,
default: () => false,
@ -28,7 +40,7 @@ export default {
default: () => 0,
},
},
emits: ['setReplyMode', 'togglePopout'],
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@ -47,6 +59,23 @@ export default {
: REPLY_EDITOR_MODES.REPLY;
setReplyMode(newMode);
};
const { captainTasksEnabled } = useCaptain();
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 +93,11 @@ export default {
handleReplyClick,
handleNoteClick,
REPLY_EDITOR_MODES,
captainTasksEnabled,
handleCopilotAction,
showCopilotMenu,
toggleCopilotMenu,
handleClickOutside,
};
},
computed: {
@ -90,11 +124,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 +140,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 v-if="captainTasksEnabled" 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>

View File

@ -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>

View File

@ -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') }}

View File

@ -1,9 +1,8 @@
<script>
import { ref, provide } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
// components
@ -49,7 +48,6 @@ export default {
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const { isEnterprise } = useConfig();
const keyboardEvents = {
Escape: {
@ -61,22 +59,14 @@ export default {
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
const { captainTasksEnabled, getLabelSuggestions } = useCaptain();
provide('contextMenuElementTarget', conversationPanelRef);
return {
isEnterprise,
isPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
captainTasksEnabled,
getLabelSuggestions,
conversationPanelRef,
};
},
@ -104,10 +94,7 @@ export default {
},
shouldShowLabelSuggestions() {
return (
this.isOpen &&
this.isEnterprise &&
this.isAIIntegrationEnabled &&
!this.messageSentSinceOpened
this.isOpen && this.captainTasksEnabled && !this.messageSentSinceOpened
);
},
inboxId() {
@ -291,24 +278,15 @@ export default {
return;
}
if (!this.isEnterprise) {
return;
}
// Early exit if conversation already has labels - no need to suggest more
const existingLabels = this.currentChat?.labels || [];
if (existingLabels.length > 0) return;
// method available in mixin, need to ensure that integrations are present
await this.fetchIntegrationsIfRequired();
if (!this.isLabelSuggestionFeatureEnabled) {
if (!this.captainTasksEnabled) {
return;
}
this.labelSuggestions = await this.fetchLabelSuggestions({
conversationId: this.currentChat.id,
});
this.labelSuggestions = await this.getLabelSuggestions();
// once the labels are fetched, we need to scroll to bottom
// but we need to wait for the DOM to be updated

View File

@ -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';
@ -21,6 +23,7 @@ import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vu
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
@ -45,6 +48,8 @@ import {
removeSignature,
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@ -70,6 +75,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
CopilotEditorSection,
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@ -89,6 +96,8 @@ export default {
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@ -97,6 +106,8 @@ export default {
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
copilot,
shortcutKey,
};
},
data() {
@ -267,7 +278,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
? '(⌘ + ↵)'
? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@ -400,6 +411,9 @@ export default {
!!this.quotedEmailText
);
},
isDefaultEditorMode() {
return !this.showAudioRecorderEditor && !this.copilot.isActive.value;
},
},
watch: {
currentChat(conversation, oldConversation) {
@ -409,6 +423,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) {
@ -478,6 +494,7 @@ export default {
this.onNewConversationModalActive
);
emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
emitter.on(CMD_AI_ASSIST, this.executeCopilotAction);
},
unmounted() {
document.removeEventListener('paste', this.onPaste);
@ -488,6 +505,7 @@ export default {
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
emitter.off(CMD_AI_ASSIST, this.executeCopilotAction);
},
methods: {
handleInsert(article) {
@ -613,7 +631,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();
}
},
@ -830,6 +850,9 @@ export default {
this.updateEditorSelectionWith = content;
this.onFocus();
},
executeCopilotAction(action, data) {
this.copilot.execute(action, data);
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
@ -1095,6 +1118,9 @@ export default {
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
},
onSubmitCopilotReply() {
this.message = this.copilot.accept();
},
},
};
</script>
@ -1105,11 +1131,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"
@ -1117,112 +1149,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"
@ -1252,13 +1339,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 {

View File

@ -4,7 +4,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
// composables
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useTrack } from 'dashboard/composables';
// store & api
@ -33,9 +33,9 @@ export default {
},
},
setup() {
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
return { isAIIntegrationEnabled };
return { captainTasksEnabled };
},
data() {
return {
@ -78,7 +78,7 @@ export default {
},
shouldShowSuggestions() {
if (this.isDismissed) return false;
if (!this.isAIIntegrationEnabled) return false;
if (!this.captainTasksEnabled) return false;
return this.preparedLabels.length && this.chatLabels.length === 0;
},

View File

@ -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>

View File

@ -3,7 +3,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
@ -18,7 +18,7 @@ vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
vi.mock('vue-router');
vi.mock('dashboard/composables/useConversationLabels');
vi.mock('dashboard/composables/useAI');
vi.mock('dashboard/composables/useCaptain');
vi.mock('dashboard/composables/useAgentsList');
describe('useConversationHotKeys', () => {
@ -49,7 +49,7 @@ describe('useConversationHotKeys', () => {
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
useCaptain.mockReturnValue({ captainTasksEnabled: { value: true } });
useAgentsList.mockReturnValue({
agentsList: { value: [] },
assignableAgents: { value: mockAssignableAgents },
@ -67,7 +67,7 @@ describe('useConversationHotKeys', () => {
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
});
it('should include AI assist actions when AI integration is enabled', () => {
it('should include AI assist actions when captain tasks is enabled', () => {
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
@ -75,8 +75,8 @@ describe('useConversationHotKeys', () => {
expect(aiAssistAction).toBeDefined();
});
it('should not include AI assist actions when AI integration is disabled', () => {
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
it('should not include AI assist actions when captain tasks is disabled', () => {
useCaptain.mockReturnValue({ captainTasksEnabled: { value: false } });
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'

View File

@ -4,7 +4,7 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { emitter } from 'shared/helpers/mitt';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
@ -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,
},
];
@ -151,7 +146,7 @@ export function useConversationHotKeys() {
removeLabelFromConversation,
} = useConversationLabels();
const { isAIIntegrationEnabled } = useAI();
const { captainTasksEnabled } = useCaptain();
const { agentsList } = useAgentsList();
const currentChat = useMapGetter('getSelectedChat');
@ -386,7 +381,7 @@ export function useConversationHotKeys() {
...labelActions.value,
...assignPriorityActions.value,
];
if (isAIIntegrationEnabled.value) {
if (captainTasksEnabled.value) {
return [...defaultConversationHotKeys, ...AIAssistActions.value];
}
return defaultConversationHotKeys;

View File

@ -1,122 +0,0 @@
import { useAI } from '../useAI';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import OpenAPI from 'dashboard/api/integrations/openapi';
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/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useAI', () => {
const mockStore = {
dispatch: vi.fn(),
};
const mockGetters = {
'integrations/getUIFlags': { value: { isFetching: false } },
'draftMessages/get': { value: () => 'Draft message' },
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useStoreGetters.mockReturnValue(mockGetters);
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
});
it('initializes computed properties correctly', async () => {
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
useAI();
expect(uiFlags.value).toEqual({ isFetching: false });
expect(appIntegrations.value).toEqual([]);
expect(currentChat.value).toEqual({ id: '123' });
expect(replyMode.value).toBe('reply');
expect(draftMessage.value).toBe('Draft message');
});
it('fetches integrations if required', async () => {
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
});
it('does not fetch integrations if already loaded', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [{ id: 'openai' }],
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { fetchIntegrationsIfRequired } = useAI();
await fetchIntegrationsIfRequired();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('records analytics correctly', async () => {
// const mockTrack = analyticsHelper.track;
const { recordAnalytics } = useAI();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('fetches label suggestions', async () => {
OpenAPI.processEvent.mockResolvedValue({
data: { message: 'label1, label2' },
});
useMapGetter.mockImplementation(getter => {
const mockValues = {
'integrations/getAppIntegrations': [
{ id: 'openai', hooks: [{ id: 'hook1' }] },
],
getSelectedChat: { id: '123' },
};
return { value: mockValues[getter] };
});
const { fetchLabelSuggestions } = useAI();
const result = await fetchLabelSuggestions();
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
type: 'label_suggestion',
hookId: 'hook1',
conversationId: '123',
});
expect(result).toEqual(['label1', 'label2']);
});
});

View File

@ -0,0 +1,213 @@
import { useCaptain } from '../useCaptain';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useI18n } from 'vue-i18n';
import TasksAPI from 'dashboard/api/captain/tasks';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
vi.mock('dashboard/composables/useConfig');
vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
OPEN_AI_EVENTS: {
TEST_EVENT: 'open_ai_test_event',
},
}));
describe('useCaptain', () => {
const mockStore = {
dispatch: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useFunctionGetter.mockReturnValue({ value: 'Draft message' });
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
useAccount.mockReturnValue({
isCloudFeatureEnabled: vi.fn().mockReturnValue(true),
currentAccount: { value: { limits: { captain: {} } } },
});
useConfig.mockReturnValue({
isEnterprise: false,
});
});
it('initializes computed properties correctly', async () => {
const { captainEnabled, captainTasksEnabled, currentChat, draftMessage } =
useCaptain();
expect(captainEnabled.value).toBe(true);
expect(captainTasksEnabled.value).toBe(true);
expect(currentChat.value).toEqual({ id: '123' });
expect(draftMessage.value).toBe('Draft message');
});
it('records analytics correctly', async () => {
const { recordAnalytics } = useCaptain();
await recordAnalytics('TEST_EVENT', { data: 'test' });
expect(analyticsHelper.track).toHaveBeenCalledWith('open_ai_test_event', {
type: 'TEST_EVENT',
data: 'test',
});
});
it('gets label suggestions', async () => {
TasksAPI.labelSuggestion.mockResolvedValue({
data: { message: 'label1, label2' },
});
const { getLabelSuggestions } = useCaptain();
const result = await getLabelSuggestions();
expect(TasksAPI.labelSuggestion).toHaveBeenCalledWith('123');
expect(result).toEqual(['label1', 'label2']);
});
it('rewrites content', async () => {
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
});
const { rewriteContent } = useCaptain();
const result = await rewriteContent('Original content', 'improve', {});
expect(TasksAPI.rewrite).toHaveBeenCalledWith(
{
content: 'Original content',
operation: 'improve',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Rewritten content',
followUpContext: { id: 'ctx1' },
});
});
it('summarizes conversation', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary', follow_up_context: { id: 'ctx2' } },
});
const { summarizeConversation } = useCaptain();
const result = await summarizeConversation({});
expect(TasksAPI.summarize).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Summary',
followUpContext: { id: 'ctx2' },
});
});
it('gets reply suggestion', async () => {
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply suggestion', follow_up_context: { id: 'ctx3' } },
});
const { getReplySuggestion } = useCaptain();
const result = await getReplySuggestion({});
expect(TasksAPI.replySuggestion).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Reply suggestion',
followUpContext: { id: 'ctx3' },
});
});
it('sends follow-up message', async () => {
TasksAPI.followUp.mockResolvedValue({
data: {
message: 'Follow-up response',
follow_up_context: { id: 'ctx4' },
},
});
const { followUp } = useCaptain();
const result = await followUp({
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
});
expect(TasksAPI.followUp).toHaveBeenCalledWith(
{
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Follow-up response',
followUpContext: { id: 'ctx4' },
});
});
it('processes event and routes to correct method', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary' },
});
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply' },
});
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten' },
});
const { processEvent } = useCaptain();
// Test summarize
await processEvent('summarize', '', {});
expect(TasksAPI.summarize).toHaveBeenCalled();
// Test reply_suggestion
await processEvent('reply_suggestion', '', {});
expect(TasksAPI.replySuggestion).toHaveBeenCalled();
// Test rewrite (improve)
await processEvent('improve', 'content', {});
expect(TasksAPI.rewrite).toHaveBeenCalled();
});
it('returns empty array when no conversation ID for label suggestions', async () => {
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: null },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
const { getLabelSuggestions } = useCaptain();
const result = await getLabelSuggestions();
expect(result).toEqual([]);
expect(TasksAPI.labelSuggestion).not.toHaveBeenCalled();
});
});

View File

@ -1,203 +0,0 @@
import { computed, onMounted } from 'vue';
import {
useStore,
useStoreGetters,
useMapGetter,
} from 'dashboard/composables/store';
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';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase() // Set it to lowercase
.split(',') // split the string into an array
.filter(label => label.trim()) // remove any empty strings
.map(label => label.trim()) // trim the words
.filter((label, index, self) => self.indexOf(label) === index);
};
/**
* A composable function for AI-related operations in the dashboard.
* @returns {Object} An object containing AI-related methods and computed properties.
*/
export function useAI() {
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
/**
* Computed property for UI flags.
* @type {import('vue').ComputedRef<Object>}
*/
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
/**
* Computed property for the AI integration.
* @type {import('vue').ComputedRef<Object|undefined>}
*/
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
/**
* Computed property to check if AI integration is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
/**
* Computed property to check if label suggestion feature is enabled.
* @type {import('vue').ComputedRef<boolean>}
*/
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return settings.label_suggestion;
}
return false;
});
/**
* Computed property to check if app integrations are being fetched.
* @type {import('vue').ComputedRef<boolean>}
*/
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>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* Computed property for the draft key.
* @type {import('vue').ComputedRef<string>}
*/
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
/**
* Computed property for the draft message.
* @type {import('vue').ComputedRef<string>}
*/
const draftMessage = computed(() =>
getters['draftMessages/get'].value(draftKey.value)
);
/**
* Fetches integrations if they haven't been loaded yet.
* @returns {Promise<void>}
*/
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
useTrack(event, {
type,
...payload,
});
}
};
/**
* Fetches label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const fetchLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await OpenAPI.processEvent({
type: 'label_suggestion',
hookId: hookId.value,
conversationId: conversationId.value,
});
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* 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.
*/
const processEvent = async (type = 'rephrase') => {
try {
const result = await OpenAPI.processEvent({
hookId: hookId.value,
type,
content: draftMessage.value,
conversationId: conversationId.value,
});
const {
data: { message: generatedMessage },
} = result;
return generatedMessage;
} catch (error) {
const errorData = error.response.data.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
return '';
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
draftMessage,
uiFlags,
appIntegrations,
currentChat,
replyMode,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
isFetchingAppIntegrations,
fetchIntegrationsIfRequired,
recordAnalytics,
fetchLabelSuggestions,
processEvent,
};
}

View File

@ -1,20 +1,56 @@
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert, useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import TasksAPI from 'dashboard/api/captain/tasks';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase()
.split(',')
.filter(label => label.trim())
.map(label => label.trim())
.filter((label, index, self) => self.indexOf(label) === index);
};
export function useCaptain() {
const store = useStore();
const { t } = useI18n();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
const conversationId = computed(() => currentChat.value?.id);
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
const draftMessage = useFunctionGetter('draftMessages/get', draftKey);
// === Feature Flags ===
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
});
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
// === Limits (Enterprise) ===
const captainLimits = computed(() => {
return currentAccount.value?.limits?.captain;
});
@ -23,7 +59,6 @@ export function useCaptain() {
if (captainLimits.value?.documents) {
return useCamelCase(captainLimits.value.documents);
}
return null;
});
@ -31,7 +66,6 @@ export function useCaptain() {
if (captainLimits.value?.responses) {
return useCamelCase(captainLimits.value.responses);
}
return null;
});
@ -43,12 +77,198 @@ export function useCaptain() {
}
};
// === Error Handling ===
/**
* 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);
};
// === Analytics ===
/**
* Records analytics for AI-related events.
* @param {string} type - The type of event.
* @param {Object} payload - Additional data for the event.
* @returns {Promise<void>}
*/
const recordAnalytics = async (type, payload) => {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
useTrack(event, {
type,
...payload,
});
}
};
// === Task Methods ===
/**
* Rewrites content with a specific operation.
* @param {string} content - The content to rewrite.
* @param {string} operation - The operation (fix_spelling_grammar, casual, professional, expand, shorten, improve, etc).
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The rewritten content and optional follow-up context.
*/
const rewriteContent = async (content, operation, options = {}) => {
try {
const result = await TasksAPI.rewrite(
{
content: content || draftMessage.value,
operation,
conversationId: conversationId.value,
},
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Summarizes a conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The summary and optional follow-up context.
*/
const summarizeConversation = async (options = {}) => {
try {
const result = await TasksAPI.summarize(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Gets a reply suggestion for the current conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The reply suggestion and optional follow-up context.
*/
const getReplySuggestion = async (options = {}) => {
try {
const result = await TasksAPI.replySuggestion(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '' };
}
};
/**
* Gets label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const getLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await TasksAPI.labelSuggestion(conversationId.value);
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
/**
* 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 };
}
};
/**
* Processes an AI event. Routes to the appropriate method based on type.
* @param {string} [type='improve'] - The type of AI event to process.
* @param {string} [content=''] - The content to process.
* @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 = 'improve', content = '', options = {}) => {
if (type === 'summarize') {
return summarizeConversation(options);
}
if (type === 'reply_suggestion') {
return getReplySuggestion(options);
}
// All other types are rewrite operations
return rewriteContent(content, type, options);
};
return {
// Feature flags
captainEnabled,
captainTasksEnabled,
// Limits (Enterprise)
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
// Conversation context
draftMessage,
currentChat,
// Task methods
rewriteContent,
summarizeConversation,
getReplySuggestion,
getLabelSuggestions,
followUp,
processEvent,
// Analytics
recordAnalytics,
};
}

View File

@ -0,0 +1,162 @@
import { ref, computed } from 'vue';
import { useCaptain } from 'dashboard/composables/useCaptain';
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 } = useCaptain();
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,
};
}

View File

@ -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';
}

View File

@ -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'],

View File

@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',

View File

@ -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',

View File

@ -519,12 +519,19 @@ export const getContentNode = (
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
* TODO: We're hiding captain, enable it back when we add selection improvements
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType) {
return FORMATTING[channelType] || FORMATTING['Context::Default'];
export function getFormattingForEditor(channelType, showCaptain = false) {
const formatting = FORMATTING[channelType] || FORMATTING['Context::Default'];
return {
...formatting,
menu: showCaptain
? formatting.menu
: formatting.menu.filter(item => item !== 'copilot'),
};
}
/**

View File

@ -795,62 +795,6 @@ describe('getContentNode', () => {
});
describe('getFormattingForEditor', () => {
describe('channel-specific formatting', () => {
it('returns full formatting for Email channel', () => {
const result = getFormattingForEditor('Channel::Email');
expect(result).toEqual(FORMATTING['Channel::Email']);
});
it('returns full formatting for WebWidget channel', () => {
const result = getFormattingForEditor('Channel::WebWidget');
expect(result).toEqual(FORMATTING['Channel::WebWidget']);
});
it('returns limited formatting for WhatsApp channel', () => {
const result = getFormattingForEditor('Channel::Whatsapp');
expect(result).toEqual(FORMATTING['Channel::Whatsapp']);
});
it('returns no formatting for API channel', () => {
const result = getFormattingForEditor('Channel::Api');
expect(result).toEqual(FORMATTING['Channel::Api']);
});
it('returns limited formatting for FacebookPage channel', () => {
const result = getFormattingForEditor('Channel::FacebookPage');
expect(result).toEqual(FORMATTING['Channel::FacebookPage']);
});
it('returns no formatting for TwitterProfile channel', () => {
const result = getFormattingForEditor('Channel::TwitterProfile');
expect(result).toEqual(FORMATTING['Channel::TwitterProfile']);
});
it('returns no formatting for SMS channel', () => {
const result = getFormattingForEditor('Channel::Sms');
expect(result).toEqual(FORMATTING['Channel::Sms']);
});
it('returns limited formatting for Telegram channel', () => {
const result = getFormattingForEditor('Channel::Telegram');
expect(result).toEqual(FORMATTING['Channel::Telegram']);
});
it('returns formatting for Instagram channel', () => {
const result = getFormattingForEditor('Channel::Instagram');
expect(result).toEqual(FORMATTING['Channel::Instagram']);
});
});
describe('context-specific formatting', () => {
it('returns default formatting for Context::Default', () => {
const result = getFormattingForEditor('Context::Default');

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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",

View File

@ -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',
};

View File

@ -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?

View File

@ -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

View File

@ -26,10 +26,18 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def build_messages(config = {})
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
messages = @record.messages.where.not(message_type: :activity).order(created_at: :asc)
messages = @record.messages.where.not(message_type: [:activity, :template])
messages.each do |message|
if config[:token_limit]
build_limited_messages(messages, config)
else
build_all_messages(messages, config)
end
end
def build_all_messages(messages, config)
message_text = ''
messages.order(created_at: :asc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
@ -38,6 +46,24 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
message_text
end
def build_limited_messages(messages, config)
selected = []
character_count = 0
messages.reorder(created_at: :desc).each do |message|
# Skip private messages unless explicitly included in config
next if message.private? && !config[:include_private_messages]
formatted = format_message(message)
break if character_count + formatted.length > config[:token_limit]
selected.prepend(formatted)
character_count += formatted.length
end
selected.join
end
def format_message(message)
sender = case message.sender_type
when 'User'

View File

@ -234,3 +234,6 @@
display_name: CSAT Review Notes
enabled: false
premium: true
- name: captain_tasks
display_name: Captain Tasks
enabled: true

View File

@ -345,6 +345,9 @@ 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.'
api_key_missing: 'Captain AI API key is not configured.'
copilot:
using_tool: 'Using tool %{function_name}'
completed_tool_call: 'Completed %{function_name} tool call'

View File

@ -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

View File

@ -0,0 +1,12 @@
# Enable captain_tasks for existing accounts.
# Unlike 20250416182131_flip_chatwoot_v4_default_feature_flag_installation_config.rb,
# we don't need to update ACCOUNT_LEVEL_FEATURE_DEFAULTS or clear GlobalConfig cache
# because captain_tasks already has `enabled: true` in features.yml - ConfigLoader
# handles the defaults on deploy automatically.
class EnableCaptainTasksForExistingAccounts < ActiveRecord::Migration[7.0]
def up
Account.find_in_batches(batch_size: 100) do |accounts|
accounts.each { |account| account.enable_features!('captain_tasks') }
end
end
end

View File

@ -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_14_201315) do
ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"

View File

@ -0,0 +1,71 @@
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],
user: Current.user
).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

View File

@ -3,6 +3,6 @@
- audit_logs
- response_bot
- sla
- captain_integration
- custom_roles
- captain_integration
- csat_review_notes

View File

@ -0,0 +1,32 @@
module Enterprise::Captain::BaseTaskService
def perform
return { error: I18n.t('captain.copilot_limit'), error_code: 429 } unless responses_available?
unless captain_tasks_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 responses_available?
return true unless ChatwootApp.chatwoot_cloud?
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
Rails.logger.info("[CAPTAIN][#{self.class.name}] Incrementing response usage for account #{account.id}")
account.increment_response_usage
end
end

View File

@ -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

View File

@ -0,0 +1,181 @@
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 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:)
# Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
return { error: I18n.t('captain.api_key_missing'), error_code: 401 } unless api_key_configured?
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(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
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,
metadata: instrumentation_metadata
}
end
def instrumentation_metadata
{
channel_type: conversation&.inbox&.channel_type
}.compact
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 captain_tasks_enabled?
account.feature_enabled?('captain_tasks')
end
def api_key_configured?
api_key.present?
end
def api_key
@api_key ||= openai_hook&.settings&.dig('api_key') || system_api_key
end
def openai_hook
@openai_hook ||= account.hooks.find_by(app_id: 'openai', status: 'enabled')
end
def system_api_key
@system_api_key ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
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: [],
channel_type: conversation&.inbox&.channel_type
}
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')

View File

@ -0,0 +1,106 @@
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: GPT_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.
Output only the reply, no preamble, tags, or explanation.
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,
'channel_type' => follow_up_context['channel_type']
}
end
def instrumentation_metadata
{
channel_type: conversation&.inbox&.channel_type || follow_up_context['channel_type']
}.compact
end
def event_name
'follow_up'
end
end

View File

@ -0,0 +1,93 @@
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: GPT_MODEL, # TODO: Use separate model for label suggestion
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 build_follow_up_context?
false
end
end

View File

@ -0,0 +1,40 @@
class Captain::ReplySuggestionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!, :user!]
def perform
make_api_call(
model: GPT_MODEL,
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: formatted_conversation }
]
)
end
private
def system_prompt
template = prompt_from_file('reply')
render_liquid_template(template, prompt_variables)
end
def prompt_variables
{
'channel_type' => conversation.inbox.channel_type,
'agent_name' => user.name,
'agent_signature' => user.message_signature.presence
}
end
def render_liquid_template(template_content, variables = {})
Liquid::Template.parse(template_content).render(variables)
end
def formatted_conversation
LlmFormatter::ConversationLlmFormatter.new(conversation).format(token_limit: TOKEN_LIMIT)
end
def event_name
'reply_suggestion'
end
end

View File

@ -0,0 +1,59 @@
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: GPT_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
end

View File

@ -0,0 +1,19 @@
class Captain::SummaryService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
make_api_call(
model: GPT_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
end

View File

@ -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!]

View File

@ -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.

View File

@ -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

View File

@ -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.

View File

@ -0,0 +1,35 @@
You are helping a customer support agent draft their next reply. The agent will send this message directly to the customer.
You will receive a conversation with messages labeled by sender:
- "User:" = customer messages
- "Support Agent:" = human agent messages
- "Bot:" = automated bot messages
{% if channel_type == 'Channel::Email' %}
This is an EMAIL conversation. Write a professional email reply that:
- Uses appropriate email formatting (greeting, body, sign-off)
- Is detailed and thorough where needed
- Maintains a professional tone
{% if agent_signature %}
- End with the agent's signature exactly as provided below:
{{ agent_signature }}
{% else %}
- End with a professional sign-off using the agent's name: {{ agent_name }}
{% endif %}
{% else %}
This is a CHAT conversation. Write a brief, conversational reply that:
- Is short and easy to read
- Gets to the point quickly
- Does not include formal greetings or sign-offs
{% endif %}
General guidelines:
- Address the customer's most recent message directly
- If a support agent has spoken before, match their writing style
- If only bot messages exist, write a natural first message
- Move the conversation forward
- Do not invent product details, policies, or links that weren't mentioned
- Reply in the customer's language
Output only the reply.

View File

@ -1 +0,0 @@
Please suggest a reply to the following conversation between support agents and customer. Don't expose that you are an AI model, respond "Couldn't generate the reply" in cases where you can't answer. Reply in the user\'s language.

View File

@ -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**

View File

@ -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.

View File

@ -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.

View File

@ -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')

View File

@ -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

View File

@ -31,14 +31,16 @@
}
],
"dependencies": {
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.3.5",
"@chatwoot/prosemirror-schema": "1.3.6",
"@chatwoot/utils": "^0.0.51",
"@formkit/core": "^1.6.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",
@ -83,7 +85,6 @@
"mitt": "^3.0.1",
"opus-recorder": "^8.0.5",
"pinia": "^3.0.4",
"@amplitude/analytics-browser": "^2.11.10",
"qrcode": "^1.5.4",
"semver": "7.6.3",
"snakecase-keys": "^8.0.1",

20
pnpm-lock.yaml generated
View File

@ -23,8 +23,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
specifier: 1.3.5
version: 1.3.5
specifier: 1.3.6
version: 1.3.6
'@chatwoot/utils':
specifier: ^0.0.51
version: 0.0.51
@ -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
@ -454,8 +457,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
'@chatwoot/prosemirror-schema@1.3.5':
resolution: {integrity: sha512-3Koj3jwO1qOxJG84D4FqPOJ6o8k6ehZi1zedO3vKRERATm2Cy1p+ET6FEvVYWUpoBvDwR6hNVScXrcNNVobhsA==}
'@chatwoot/prosemirror-schema@1.3.6':
resolution: {integrity: sha512-sHRtWqbtiow9mVF1ixim0eGUXfhGK5tuLOdF9Vf53aepjJ+ngEiNVkxQT6FohlEOd886ZsdQxMvmI92IDaUXAQ==}
'@chatwoot/utils@0.0.51':
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
@ -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==}
@ -4993,7 +4999,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
'@chatwoot/prosemirror-schema@1.3.5':
'@chatwoot/prosemirror-schema@1.3.6':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.6.0
@ -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

View File

@ -0,0 +1,169 @@
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
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_tasks').and_return(true)
end
context 'when usage limit is exceeded' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
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 on successful execution' do
expect(account).to receive(:increment_response_usage)
service.perform
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' do
expect do
service.perform
account.reload
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
end
context 'when captain is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_tasks').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_tasks').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

View File

@ -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

View File

@ -0,0 +1,325 @@
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
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_tasks').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 '#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
context 'when captain_tasks is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
end
it 'returns disabled error' do
result = service.send(:make_api_call, model: model, messages: messages)
expect(result[:error]).to eq(I18n.t('captain.disabled'))
expect(result[:error_code]).to eq(403)
end
it 'does not make API call' do
expect(Llm::Config).not_to receive(:with_api_key)
service.send(:make_api_call, model: model, messages: messages)
end
end
context 'when API key is not configured' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.destroy
# Clear memoized api_key
service.instance_variable_set(:@api_key, nil)
end
it 'returns api key missing error' do
result = service.send(:make_api_call, model: model, messages: messages)
expect(result[:error]).to eq(I18n.t('captain.api_key_missing'))
expect(result[:error_code]).to eq(401)
end
it 'does not make API call' do
expect(Llm::Config).not_to receive(:with_api_key)
service.send(:make_api_call, model: model, messages: messages)
end
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 '#api_key' do
context 'when openai hook is configured' do
let(:hook) { create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' }) }
before { hook }
it 'uses api key from hook' do
expect(service.send(:api_key)).to eq('hook-key')
end
end
context 'when openai hook is not configured' do
it 'uses system api key' do
expect(service.send(:api_key)).to eq('test-key')
end
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

View File

@ -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_tasks').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

View File

@ -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_tasks').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

View File

@ -0,0 +1,92 @@
require 'rails_helper'
RSpec.describe Captain::ReplySuggestionService do
subject(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id, user: agent) }
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, name: 'Jane Smith') }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:captured_messages) { [] }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
create(:message, conversation: conversation, message_type: :incoming, content: 'I need help')
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_response = instance_double(RubyLLM::Message, content: 'Sure, I can help!', input_tokens: 50, output_tokens: 20)
mock_chat = instance_double(RubyLLM::Chat)
mock_context = instance_double(RubyLLM::Context, chat: mock_chat)
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } }
allow(mock_chat).to receive(:add_message) { |args| captured_messages << args }
allow(mock_chat).to receive(:ask) do |msg|
captured_messages << { role: 'user', content: msg }
mock_response
end
end
describe '#perform' do
it 'returns the suggested reply' do
result = service.perform
expect(result[:message]).to eq('Sure, I can help!')
end
it 'formats conversation using LlmFormatter' do
service.perform
user_message = captured_messages.find { |m| m[:role] == 'user' }
expect(user_message[:content]).to include('Message History:')
expect(user_message[:content]).to include('User: I need help')
end
context 'with chat channel' do
it 'uses chat-specific instructions' do
service.perform
system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
expect(system_prompt).to include('CHAT conversation')
expect(system_prompt).to include('brief, conversational')
expect(system_prompt).not_to include('EMAIL conversation')
end
end
context 'with email channel' do
let(:email_channel) { create(:channel_email, account: account) }
let(:inbox) { create(:inbox, account: account, channel: email_channel) }
it 'uses email-specific instructions' do
service.perform
system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
expect(system_prompt).to include('EMAIL conversation')
expect(system_prompt).to include('professional email')
expect(system_prompt).not_to include('CHAT conversation')
end
context 'when agent has a signature' do
let(:agent) { create(:user, account: account, name: 'Jane Smith', message_signature: "Best,\nJane Smith") }
it 'includes the signature in the prompt' do
service.perform
system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
expect(system_prompt).to include("Best,\nJane Smith")
end
end
context 'when agent has no signature' do
let(:agent) { create(:user, account: account, name: 'Jane Smith', message_signature: nil) }
it 'falls back to agent name for sign-off' do
service.perform
system_prompt = captured_messages.find { |m| m[:role] == 'system' }[:content]
expect(system_prompt).to include("sign-off using the agent's name: Jane Smith")
end
end
end
end
end

View File

@ -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_tasks').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

View File

@ -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_tasks').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

View File

@ -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

View File

@ -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) }

View File

@ -259,6 +259,7 @@ const tailwindConfig = {
'ph',
'material-symbols',
'teenyicons',
'fluent',
]),
},
}),

View File

@ -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>)',