feat: allow copilot use without connecting an inbox (#10992)

This PR allows Copilot to be used without connecting the Captain assistant to an inbox. Currently, if Captain is enabled on an account, it takes over conversations and responds directly to users. This PR enables the use of Captain as a Copilot without allowing it to respond to users. Additionally, it allows using a different assistant for Copilot instead of the default Captain assistant.

The selection logic for the Copilot assistant follows this order of preference:

- If the user has selected a specific assistant, it takes first preference for Copilot.
- If the above is not available, the assistant connected to the inbox takes preference.
- If neither of the above is available, the first assistant in the account takes preference.
This commit is contained in:
Vishnu Narayanan 2025-03-01 04:50:39 +05:30 committed by GitHub
parent 24f49b9b5a
commit 616bbced9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 265 additions and 14 deletions

View File

@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
requestCopilot(conversationId, body) { requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body); return axios.post(`${this.url}/${conversationId}/copilot`, body);
} }
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
} }
export default new ConversationApi(); export default new ConversationApi();

View File

@ -7,6 +7,7 @@ import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue'; import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue'; import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue'; import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from '../icon/Icon.vue'; import Icon from '../icon/Icon.vue';
const props = defineProps({ const props = defineProps({
@ -26,9 +27,17 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
assistants: {
type: Array,
default: () => [],
},
activeAssistant: {
type: Object,
default: () => ({}),
},
}); });
const emit = defineEmits(['sendMessage', 'reset']); const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const COPILOT_USER_ROLES = ['assistant', 'system']; const COPILOT_USER_ROLES = ['assistant', 'system'];
@ -97,14 +106,18 @@ watch(
<CopilotLoader v-if="isCaptainTyping" /> <CopilotLoader v-if="isCaptainTyping" />
</div> </div>
<div>
<div v-if="!messages.length" class="flex-1 px-3 py-3 space-y-1"> <div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10"> <span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }} {{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span> </span>
<button <button
v-for="prompt in promptOptions" v-for="prompt in promptOptions"
:key="prompt" :key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1" class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)" @click="() => useSuggestion(prompt)"
> >
@ -112,7 +125,17 @@ watch(
<Icon icon="i-lucide-chevron-right" /> <Icon icon="i-lucide-chevron-right" />
</button> </button>
</div> </div>
<div class="mx-3 mt-px mb-2 flex flex-col items-end flex-1"> </div>
<div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant
v-if="assistants.length"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
<button <button
v-if="messages.length" v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline" class="text-xs flex items-center gap-1 hover:underline"
@ -121,8 +144,8 @@ watch(
<i class="i-lucide-refresh-ccw" /> <i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span> <span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button> </button>
<CopilotInput class="mb-1 flex-1 w-full" @send="sendMessage" />
</div> </div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
</div> </div>
</div> </div>
</template> </template>

View File

@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const props = defineProps({
assistants: {
type: Array,
required: true,
},
activeAssistant: {
type: Object,
required: true,
},
});
const emit = defineEmits(['setAssistant']);
const { t } = useI18n();
const activeAssistantLabel = computed(() => {
return props.activeAssistant
? props.activeAssistant.name
: t('CAPTAIN.COPILOT.SELECT_ASSISTANT');
});
</script>
<template>
<div>
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<Button
:label="activeAssistantLabel"
icon="i-woot-captain"
ghost
slate
xs
:class="{ 'bg-n-alpha-2': isOpen }"
@click="toggle"
/>
</template>
<DropdownBody class="bottom-9 min-w-64 z-50" strong>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="assistant in assistants"
:key="assistant.id"
class="!items-start !gap-1 flex-col cursor-pointer"
@click="() => emit('setAssistant', assistant)"
>
<template #label>
<div class="flex gap-1 justify-between w-full">
<div class="items-start flex gap-1 flex-col">
<span class="text-n-slate-12 text-sm">
{{ assistant.name }}
</span>
<span class="line-clamp-2 text-n-slate-11 text-xs">
{{ assistant.description }}
</span>
</div>
<div
v-if="assistant.id === activeAssistant?.id"
class="flex items-center justify-center flex-shrink-0 w-4 h-4 rounded-full bg-n-slate-12 dark:bg-n-slate-11"
>
<i
class="i-lucide-check text-white dark:text-n-slate-1 size-3"
/>
</div>
</div>
</template>
</DropdownItem>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</div>
</template>

View File

@ -19,7 +19,7 @@ const beforeClass = computed(() => {
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation // Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
// https://issues.chromium.org/issues/40835530 // https://issues.chromium.org/issues/40835530
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative"; return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:rounded-xl before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
}); });
</script> </script>

View File

@ -1,8 +1,11 @@
<script setup> <script setup>
import { ref, computed, onMounted, watchEffect } from 'vue';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue'; import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation'; import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { ref } from 'vue'; import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({ const props = defineProps({
conversationId: { conversationId: {
type: [Number, String], type: [Number, String],
@ -13,10 +16,44 @@ const props = defineProps({
required: true, required: true,
}, },
}); });
const currentUser = useMapGetter('getCurrentUser');
const messages = ref([]);
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings();
const messages = ref([]);
const isCaptainTyping = ref(false); const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
// If the user has selected a specific assistant, it takes first preference for Copilot.
if (preferredId) {
const preferredAssistant = assistants.value.find(a => a.id === preferredId);
// Return the preferred assistant if found, otherwise continue to next cases
if (preferredAssistant) return preferredAssistant;
}
// If the above is not available, the assistant connected to the inbox takes preference.
if (inboxAssistant.value) {
const inboxMatchedAssistant = assistants.value.find(
a => a.id === inboxAssistant.value.id
);
if (inboxMatchedAssistant) return inboxMatchedAssistant;
}
// If neither of the above is available, the first assistant in the account takes preference.
return assistants.value[0];
});
const setAssistant = async assistant => {
selectedAssistantId.value = assistant.id;
await updateUISettings({
preferred_captain_assistant_id: assistant.id,
});
};
const handleReset = () => { const handleReset = () => {
messages.value = []; messages.value = [];
@ -42,7 +79,7 @@ const sendMessage = async message => {
})) }))
.slice(0, -1), .slice(0, -1),
message, message,
assistant_id: 16, assistant_id: selectedAssistantId.value,
} }
); );
messages.value.push({ messages.value.push({
@ -57,6 +94,17 @@ const sendMessage = async message => {
isCaptainTyping.value = false; isCaptainTyping.value = false;
} }
}; };
onMounted(() => {
store.dispatch('captainAssistants/get');
});
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script> </script>
<template> <template>
@ -65,6 +113,9 @@ const sendMessage = async message => {
:support-agent="currentUser" :support-agent="currentUser"
:is-captain-typing="isCaptainTyping" :is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType" :conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage" @send-message="sendMessage"
@reset="handleReset" @reset="handleReset"
/> />

View File

@ -313,7 +313,8 @@
"LOADER": "Captain is thinking", "LOADER": "Captain is thinking",
"YOU": "You", "YOU": "You",
"USE": "Use this", "USE": "Use this",
"RESET": "Reset" "RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
}, },
"PAYWALL": { "PAYWALL": {
"TITLE": "Upgrade to use Captain AI", "TITLE": "Upgrade to use Captain AI",

View File

@ -489,6 +489,15 @@ const actions = {
commit(types.SET_CONTEXT_MENU_CHAT_ID, chatId); commit(types.SET_CONTEXT_MENU_CHAT_ID, chatId);
}, },
getInboxCaptainAssistantById: async ({ commit }, conversationId) => {
try {
const response = await ConversationApi.getInboxAssistant(conversationId);
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, response.data);
} catch (error) {
// Handle error
}
},
...messageReadActions, ...messageReadActions,
...messageTranslateActions, ...messageTranslateActions,
}; };

View File

@ -108,6 +108,10 @@ const getters = {
getContextMenuChatId: _state => { getContextMenuChatId: _state => {
return _state.contextMenuChatId; return _state.contextMenuChatId;
}, },
getCopilotAssistant: _state => {
return _state.copilotAssistant;
},
}; };
export default getters; export default getters;

View File

@ -21,6 +21,7 @@ const state = {
conversationLastSeen: null, conversationLastSeen: null,
syncConversationsMessages: {}, syncConversationsMessages: {},
conversationFilters: {}, conversationFilters: {},
copilotAssistant: {},
}; };
// mutations // mutations
@ -309,6 +310,9 @@ export const mutations = {
[types.UPDATE_CHAT_LIST_FILTERS](_state, data) { [types.UPDATE_CHAT_LIST_FILTERS](_state, data) {
_state.conversationFilters = { ..._state.conversationFilters, ...data }; _state.conversationFilters = { ..._state.conversationFilters, ...data };
}, },
[types.SET_INBOX_CAPTAIN_ASSISTANT](_state, data) {
_state.copilotAssistant = data.assistant;
},
}; };
export default { export default {

View File

@ -687,4 +687,23 @@ describe('#addMentions', () => {
]); ]);
}); });
}); });
describe('#getInboxCaptainAssistantById', () => {
it('fetches inbox assistant by id', async () => {
axios.get.mockResolvedValue({
data: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
});
await actions.getInboxCaptainAssistantById({ commit }, 1);
expect(commit.mock.calls).toEqual([
[
types.SET_INBOX_CAPTAIN_ASSISTANT,
{ id: 1, name: 'Assistant', description: 'Assistant description' },
],
]);
});
});
}); });

View File

@ -308,4 +308,21 @@ describe('#getters', () => {
}); });
}); });
}); });
describe('#getCopilotAssistant', () => {
it('get copilot assistant', () => {
const state = {
copilotAssistant: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
};
expect(getters.getCopilotAssistant(state)).toEqual({
id: 1,
name: 'Assistant',
description: 'Assistant description',
});
});
});
}); });

View File

@ -1,3 +1,4 @@
import { describe } from 'vitest';
import types from '../../../mutation-types'; import types from '../../../mutation-types';
import { mutations } from '../../conversations'; import { mutations } from '../../conversations';
@ -553,4 +554,19 @@ describe('#mutations', () => {
}); });
}); });
}); });
describe('#SET_INBOX_CAPTAIN_ASSISTANT', () => {
it('set inbox captain assistant', () => {
const state = { copilotAssistant: {} };
const data = {
assistant: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
};
mutations[types.SET_INBOX_CAPTAIN_ASSISTANT](state, data);
expect(state.copilotAssistant).toEqual(data.assistant);
});
});
}); });

View File

@ -64,6 +64,9 @@ export default {
SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS', SET_CHAT_LIST_FILTERS: 'SET_CHAT_LIST_FILTERS',
UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS', UPDATE_CHAT_LIST_FILTERS: 'UPDATE_CHAT_LIST_FILTERS',
// Conversation inbox connected copilot assistant
SET_INBOX_CAPTAIN_ASSISTANT: 'SET_INBOX_CAPTAIN_ASSISTANT',
// Inboxes // Inboxes
SET_INBOXES_UI_FLAG: 'SET_INBOXES_UI_FLAG', SET_INBOXES_UI_FLAG: 'SET_INBOXES_UI_FLAG',
SET_INBOXES: 'SET_INBOXES', SET_INBOXES: 'SET_INBOXES',

View File

@ -119,6 +119,7 @@ Rails.application.routes.draw do
post :custom_attributes post :custom_attributes
get :attachments get :attachments
post :copilot post :copilot
get :inbox_assistant
end end
end end

View File

@ -5,9 +5,17 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end end
def copilot def copilot
assistant = @conversation.inbox.captain_assistant # First try to get the user's preferred assistant from UI settings or from the request
assistant_id = copilot_params[:assistant_id] || current_user.ui_settings&.dig('preferred_captain_assistant_id')
# Find the assistant either by ID or from inbox
assistant = if assistant_id.present?
Captain::Assistant.find_by(id: assistant_id, account_id: Current.account.id)
else
@conversation.inbox.captain_assistant
end
return render json: { message: I18n.t('captain.copilot_error') } unless assistant return render json: { message: I18n.t('captain.copilot_error') } unless assistant
return render json: { message: I18n.t('captain.copilot_limit') } unless @conversation.inbox.captain_active?
response = Captain::Copilot::ChatService.new( response = Captain::Copilot::ChatService.new(
assistant, assistant,
@ -18,6 +26,16 @@ module Enterprise::Api::V1::Accounts::ConversationsController
render json: { message: response['response'] } render json: { message: response['response'] }
end end
def inbox_assistant
assistant = @conversation.inbox.captain_assistant
if assistant
render json: { assistant: { id: assistant.id, name: assistant.name } }
else
render json: { assistant: nil }
end
end
def permitted_update_params def permitted_update_params
super.merge(params.permit(:sla_policy_id)) super.merge(params.permit(:sla_policy_id))
end end