feat: Update UI for Copilot (#11561)

- Updated UI for copilot
This commit is contained in:
Pranav
2025-06-02 22:02:03 -05:00
committed by GitHub
parent a5fda8e118
commit bae958334d
28 changed files with 455 additions and 243 deletions

View File

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

View File

@@ -76,7 +76,7 @@ const handlePageChange = event => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0"> <header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
@@ -116,7 +116,7 @@ const handlePageChange = event => {
</div> </div>
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4"> <div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div

View File

@@ -1,6 +1,5 @@
<script setup> <script setup>
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -9,23 +8,17 @@ 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 CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue'; import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import CopilotEmptyState from './CopilotEmptyState.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue'; import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: { messages: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
isCaptainTyping: {
type: Boolean,
default: false,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -44,18 +37,11 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n(); const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => { const sendMessage = message => {
emit('sendMessage', message); emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE); useTrack(COPILOT_EVENTS.SEND_MESSAGE);
}; };
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const chatContainer = ref(null); const chatContainer = ref(null);
const scrollToBottom = async () => { const scrollToBottom = async () => {
@@ -65,20 +51,40 @@ const scrollToBottom = async () => {
} }
}; };
const promptOptions = [ const groupedMessages = computed(() => {
{ const result = [];
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL', let thinkingGroup = [];
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT', props.messages.forEach(message => {
}, if (message.message_type === 'assistant_thinking') {
{ thinkingGroup.push(message);
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL', } else {
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT', if (thinkingGroup.length > 0) {
}, result.push({
{ id: thinkingGroup[0].id,
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL', message_type: 'thinking_group',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT', messages: thinkingGroup,
}, });
]; thinkingGroup = [];
}
result.push(message);
}
});
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
return (
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
'assistant'
);
});
const { updateUISettings } = useUISettings(); const { updateUISettings } = useUISettings();
@@ -95,8 +101,22 @@ const handleSidebarAction = action => {
} }
}; };
const hasAssistants = computed(() => props.assistants.length > 0);
const hasMessages = computed(() => props.messages.length > 0);
const copilotButtons = computed(() => {
if (hasMessages.value) {
return [
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: t('CAPTAIN.COPILOT.RESET'),
},
];
}
return [];
});
watch( watch(
[() => props.messages, () => props.isCaptainTyping], [() => props.messages],
() => { () => {
scrollToBottom(); scrollToBottom();
}, },
@@ -108,64 +128,57 @@ watch(
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full"> <div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<SidebarActionsHeader <SidebarActionsHeader
:title="$t('CAPTAIN.COPILOT.TITLE')" :title="$t('CAPTAIN.COPILOT.TITLE')"
:buttons="[ :buttons="copilotButtons"
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: $t('CAPTAIN.COPILOT.RESET'),
},
]"
@click="handleSidebarAction" @click="handleSidebarAction"
@close="closeCopilotPanel" @close="closeCopilotPanel"
/> />
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
:support-agent="supportAgent"
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
</div>
<div <div
v-if="!messages.length" ref="chatContainer"
class="h-full w-full flex items-center justify-center" class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
> >
<div class="h-fit px-3 py-3 space-y-1"> <div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full">
<span class="text-xs text-n-slate-10"> <template v-for="(item, index) in groupedMessages" :key="item.id">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }} <CopilotAgentMessage
</span> v-if="item.message_type === 'user'"
<button :message="item.message"
v-for="prompt in promptOptions" />
:key="prompt.label" <CopilotAssistantMessage
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1" v-else-if="item.message_type === 'assistant'"
@click="() => useSuggestion(prompt)" :message="item.message"
> :is-last-message="index === groupedMessages.length - 1"
<span>{{ t(prompt.label) }}</span> :conversation-inbox-type="conversationInboxType"
<Icon icon="i-lucide-chevron-right" /> />
</button> <CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" />
</div> </div>
<CopilotEmptyState
v-else
:has-assistants="hasAssistants"
@use-suggestion="sendMessage"
/>
</div> </div>
<div class="mx-3 mt-px mb-2"> <div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1"> <div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant <ToggleCopilotAssistant
v-if="assistants.length" v-if="assistants.length > 1"
:assistants="assistants" :assistants="assistants"
:active-assistant="activeAssistant" :active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)" @set-assistant="$event => emit('setAssistant', $event)"
/> />
<div v-else /> <div v-else />
</div> </div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" /> <CopilotInput
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -11,6 +11,10 @@ import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: { message: {
type: Object, type: Object,
required: true, required: true,
@@ -20,6 +24,15 @@ const props = defineProps({
required: true, required: true,
}, },
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const showUseButton = computed(() => {
return (
!hasEmptyMessageContent.value &&
props.message.reply_suggestion &&
props.isLastMessage
);
});
const messageContent = computed(() => { const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content); const formatter = new MessageFormatter(props.message.content);
@@ -32,8 +45,6 @@ const insertIntoRichEditor = computed(() => {
); );
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => { const useCopilotResponse = () => {
if (insertIntoRichEditor.value) { if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -57,7 +68,7 @@ const useCopilotResponse = () => {
/> />
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
v-if="!hasEmptyMessageContent" v-if="showUseButton"
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"
faded faded
sm sm

View File

@@ -0,0 +1,108 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
defineProps({
hasAssistants: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/contacts')) return 'contacts';
if (path.includes('/articles')) return 'articles';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div v-if="!hasAssistants" class="w-full space-y-2">
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
</router-link>
</div>
<div v-else class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotLauncher = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
return (
isCaptainEnabled &&
!uiSettings.value.is_copilot_panel_open &&
!isConversationRoute.value
);
});
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
});
};
</script>
<template>
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>

View File

@@ -17,7 +17,7 @@ defineProps({
icon="i-lucide-sparkles" icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9" class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/> />
<div class="text-sm text-n-slate-11"> <div class="text-sm text-n-slate-12">
{{ content }} {{ content }}
</div> </div>
</div> </div>

View File

@@ -51,10 +51,10 @@ watch(
}" }"
> >
<CopilotThinkingBlock <CopilotThinkingBlock
v-for="message in messages" v-for="copilotMessage in messages"
:key="message.id" :key="copilotMessage.id"
:content="message.content" :content="copilotMessage.message.content"
:reasoning="message.reasoning" :reasoning="copilotMessage.message.reasoning"
/> />
</div> </div>
</div> </div>

View File

@@ -1,16 +1,11 @@
<script setup> <script setup>
import { ref, computed, onMounted, watchEffect } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store'; 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 { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const props = defineProps({ defineProps({
conversationId: {
type: [Number, String],
required: true,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -20,12 +15,24 @@ const props = defineProps({
const store = useStore(); const store = useStore();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords'); const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant'); const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings(); const currentChat = useMapGetter('getSelectedChat');
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
selectedCopilotThreadId.value
)
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null); const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const activeAssistant = computed(() => { const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id; const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -55,68 +62,57 @@ const setAssistant = async assistant => {
}); });
}; };
const shouldShowCopilotPanel = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
});
const handleReset = () => { const handleReset = () => {
messages.value = []; selectedCopilotThreadId.value = null;
}; };
const sendMessage = async message => { const sendMessage = async message => {
// Add user message if (selectedCopilotThreadId.value) {
messages.value.push({ await store.dispatch('copilotMessages/create', {
id: messages.value.length + 1, assistant_id: activeAssistant.value.id,
role: 'user', conversation_id: currentChat.value?.id,
content: message, threadId: selectedCopilotThreadId.value,
}); message,
isCaptainTyping.value = true;
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
}); });
} catch (error) { } else {
// eslint-disable-next-line const response = await store.dispatch('copilotThreads/create', {
console.log(error); assistant_id: activeAssistant.value.id,
} finally { conversation_id: currentChat.value?.id,
isCaptainTyping.value = false; message,
});
selectedCopilotThreadId.value = response.id;
} }
}; };
onMounted(() => { onMounted(() => {
store.dispatch('captainAssistants/get'); store.dispatch('captainAssistants/get');
}); });
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script> </script>
<template> <template>
<Copilot <div
:messages="messages" v-if="shouldShowCopilotPanel"
:support-agent="currentUser" class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
:is-captain-typing="isCaptainTyping" >
:conversation-inbox-type="conversationInboxType" <Copilot
:assistants="assistants" :messages="messages"
:active-assistant="activeAssistant" :support-agent="currentUser"
@set-assistant="setAssistant" :conversation-inbox-type="conversationInboxType"
@send-message="sendMessage" :assistants="assistants"
@reset="handleReset" :active-assistant="activeAssistant"
/> @set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
</div>
<template v-else />
</template> </template>

View File

@@ -1,43 +1,23 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue'; import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({ defineProps({
currentChat: { currentChat: {
required: true, required: true,
type: Object, type: Object,
}, },
}); });
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings(); const { uiSettings } = useUISettings();
const activeTab = computed(() => { const activeTab = computed(() => {
const { const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
is_contact_sidebar_open: isContactSidebarOpen,
is_copilot_panel_open: isCopilotPanelOpen,
} = uiSettings.value;
if (isContactSidebarOpen) { if (isContactSidebarOpen) {
return 0; return 0;
} }
if (isCopilotPanelOpen) {
return 1;
}
return null; return null;
}); });
</script> </script>
@@ -52,13 +32,6 @@ const activeTab = computed(() => {
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id" :inbox-id="currentChat.inbox_id"
/> />
<CopilotContainer
v-show="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -328,6 +328,9 @@
"HEADER_KNOW_MORE": "Know more", "HEADER_KNOW_MORE": "Know more",
"COPILOT": { "COPILOT": {
"TITLE": "Copilot", "TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
"PANEL_TITLE": "Get started with Copilot",
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilots here to speed things up.",
"SEND_MESSAGE": "Send message...", "SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.", "EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking", "LOADER": "Captain is thinking",
@@ -348,6 +351,14 @@
"RATE": { "RATE": {
"LABEL": "Rate this conversation", "LABEL": "Rate this conversation",
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness." "CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
},
"HIGH_PRIORITY": {
"LABEL": "High priority conversations",
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
},
"LIST_CONTACTS": {
"LABEL": "List contacts",
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
} }
} }
}, },
@@ -381,6 +392,7 @@
}, },
"ASSISTANTS": { "ASSISTANTS": {
"HEADER": "Assistants", "HEADER": "Assistants",
"NO_ASSISTANTS_AVAILABLE": "There are no assistants available in your account.",
"ADD_NEW": "Create a new assistant", "ADD_NEW": "Create a new assistant",
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the assistant?", "TITLE": "Are you sure to delete the assistant?",

View File

@@ -25,6 +25,8 @@ const Sidebar = defineAsyncComponent(
() => import('../../components/layout/Sidebar.vue') () => import('../../components/layout/Sidebar.vue')
); );
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
export default { export default {
components: { components: {
@@ -37,6 +39,8 @@ export default {
AddLabelModal, AddLabelModal,
NotificationPanel, NotificationPanel,
UpgradePage, UpgradePage,
CopilotLauncher,
CopilotContainer,
}, },
setup() { setup() {
const upgradePageRef = ref(null); const upgradePageRef = ref(null);
@@ -219,6 +223,9 @@ export default {
<template v-if="!showUpgradePage"> <template v-if="!showUpgradePage">
<router-view /> <router-view />
<CommandBar /> <CommandBar />
<CopilotLauncher />
<CopilotContainer />
<NotificationPanel <NotificationPanel
v-if="isNotificationPanel" v-if="isNotificationPanel"
@close="closeNotificationPanel" @close="closeNotificationPanel"

View File

@@ -16,7 +16,7 @@ onMounted(() => {
<template> <template>
<div <div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background" class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6"
> >
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive"> <keep-alive v-if="keepAlive">

View File

@@ -97,11 +97,8 @@ export default {
return false; return false;
} }
const { const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
is_contact_sidebar_open: isContactSidebarOpen, return isContactSidebarOpen;
is_copilot_panel_open: isCopilotPanelOpen,
} = this.uiSettings;
return isContactSidebarOpen || isCopilotPanelOpen;
}, },
showPopOverSearch() { showPopOverSearch() {
return !this.isFeatureEnabledonAccount( return !this.isFeatureEnabledonAccount(

View File

@@ -183,7 +183,6 @@ export default {
</div> </div>
<div v-else class="flex flex-col w-full h-full"> <div v-else class="flex flex-col w-full h-full">
<InboxItemHeader <InboxItemHeader
class="flex-1"
:total-length="totalNotificationCount" :total-length="totalNotificationCount"
:current-index="activeNotificationIndex" :current-index="activeNotificationIndex"
:active-notification="activeNotification" :active-notification="activeNotification"

View File

@@ -109,7 +109,7 @@ export default {
<template> <template>
<div <div
class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak" class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<BackButton <BackButton

View File

@@ -1,7 +1,5 @@
<template> <template>
<div <div class="reports--wrapper overflow-auto bg-n-background w-full px-6">
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
>
<div class="max-w-[60rem] mx-auto pb-12"> <div class="max-w-[60rem] mx-auto pb-12">
<router-view /> <router-view />
</div> </div>

View File

@@ -6,9 +6,9 @@ export default createStore({
API: CopilotMessagesAPI, API: CopilotMessagesAPI,
getters: { getters: {
getMessagesByThreadId: state => copilotThreadId => { getMessagesByThreadId: state => copilotThreadId => {
return state.records.filter( return state.records
record => record.copilot_thread?.id === Number(copilotThreadId) .filter(record => record.copilot_thread?.id === Number(copilotThreadId))
); .sort((a, b) => a.id - b.id);
}, },
}, },
actions: mutationTypes => ({ actions: mutationTypes => ({

View File

@@ -256,6 +256,11 @@ en:
captain: captain:
copilot_error: 'Please connect an assistant to this inbox to use Copilot' 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.' copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
copilot:
using_tool: 'Using tool %{function_name}'
completed_tool_call: 'Completed %{function_name} tool call'
invalid_tool_call: 'Invalid tool call'
tool_not_available: 'Tool not available'
public_portal: public_portal:
search: search:
search_placeholder: Search for article by title or body... search_placeholder: Search for article by title or body...

View File

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

View File

@@ -1,31 +1,5 @@
module Enterprise::Api::V1::Accounts::ConversationsController module Enterprise::Api::V1::Accounts::ConversationsController
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do
before_action :set_assistant, only: [:copilot]
end
def copilot
# 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
response = Captain::Copilot::ChatService.new(
assistant,
previous_history: copilot_params[:previous_history],
conversation_id: @conversation.display_id,
user_id: Current.user.id
).generate_response(copilot_params[:message])
render json: { message: response['response'] }
end
def inbox_assistant def inbox_assistant
assistant = @conversation.inbox.captain_assistant assistant = @conversation.inbox.captain_assistant

View File

@@ -53,9 +53,21 @@ module Captain::ChatHelper
end end
def execute_tool(function_name, arguments, tool_call_id) def execute_tool(function_name, arguments, tool_call_id)
persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking') persist_message(
{
content: I18n.t('captain.copilot.using_tool', function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
result = @tool_registry.send(function_name, arguments) result = @tool_registry.send(function_name, arguments)
persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking') persist_message(
{
content: I18n.t('captain.copilot.completed_tool_call', function_name: function_name),
function_name: function_name
},
'assistant_thinking'
)
append_tool_response(result, tool_call_id) append_tool_response(result, tool_call_id)
end end
@@ -67,8 +79,8 @@ module Captain::ChatHelper
end end
def process_invalid_tool_call(function_name, tool_call_id) def process_invalid_tool_call(function_name, tool_call_id)
persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking') persist_message({ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, 'assistant_thinking')
append_tool_response('Tool not available', tool_call_id) append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
end end
def append_tool_response(content, tool_call_id) def append_tool_response(content, tool_call_id)

View File

@@ -60,7 +60,7 @@ class CopilotMessage < ApplicationRecord
def validate_message_attributes def validate_message_attributes
return if message.blank? return if message.blank?
allowed_keys = %w[content reasoning function_name] allowed_keys = %w[content reasoning function_name reply_suggestion]
invalid_keys = message.keys - allowed_keys invalid_keys = message.keys - allowed_keys
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any? errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?

View File

@@ -74,7 +74,10 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
def system_message def system_message
{ {
role: 'system', role: 'system',
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name']) content: Captain::Llm::SystemPromptsService.copilot_response_generator(
@assistant.config['product_name'],
@tool_registry.tools_summary
)
} }
end end

View File

@@ -56,7 +56,7 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE SYSTEM_PROMPT_MESSAGE
end end
def copilot_response_generator(product_name) def copilot_response_generator(product_name, available_tools)
<<~SYSTEM_PROMPT_MESSAGE <<~SYSTEM_PROMPT_MESSAGE
[Identity] [Identity]
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions. You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
@@ -94,12 +94,20 @@ class Captain::Llm::SystemPromptsService
```json ```json
{ {
"reasoning": "Explain why the response was chosen based on the provided information.", "reasoning": "Explain why the response was chosen based on the provided information.",
"response": "Provide the answer only in Markdown format for readability." "content": "Provide the answer only in Markdown format for readability.",
"reply_suggestion": "A boolean value that is true only if the support agent has explicitly asked to draft a response to the customer, and the response fulfills that request. Otherwise, it should be false."
} }
[Error Handling] [Error Handling]
- If the required information is not found in the provided context, respond with an appropriate message indicating that no relevant data is available. - If the required information is not found in the provided context, respond with an appropriate message indicating that no relevant data is available.
- Avoid speculating or providing unverified information. - Avoid speculating or providing unverified information.
[Available Actions]
You have the following actions available to assist support agents:
- summarize_conversation: Summarize the conversation
- draft_response: Draft a response for the support agent
- rate_conversation: Rate the conversation
#{available_tools}
SYSTEM_PROMPT_MESSAGE SYSTEM_PROMPT_MESSAGE
end end

View File

@@ -27,4 +27,10 @@ class Captain::ToolRegistryService
def respond_to_missing?(method_name, include_private = false) def respond_to_missing?(method_name, include_private = false)
@tools.key?(method_name.to_s) || super @tools.key?(method_name.to_s) || super
end end
def tools_summary
@tools.map do |name, tool|
"- #{name}: #{tool.description}"
end.join("\n")
end
end end

View File

@@ -19,8 +19,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
status = arguments['status'] status = arguments['status']
contact_id = arguments['contact_id'] contact_id = arguments['contact_id']
priority = arguments['priority'] priority = arguments['priority']
labels = arguments['labels']
conversations = get_conversations(status, contact_id, priority) conversations = get_conversations(status, contact_id, priority, labels)
return 'No conversations found' unless conversations.exists? return 'No conversations found' unless conversations.exists?
@@ -41,11 +42,12 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
private private
def get_conversations(status, contact_id, priority) def get_conversations(status, contact_id, priority, labels)
conversations = permissible_conversations conversations = permissible_conversations
conversations = conversations.where(contact_id: contact_id) if contact_id.present? conversations = conversations.where(contact_id: contact_id) if contact_id.present?
conversations = conversations.where(status: status) if status.present? conversations = conversations.where(status: status) if status.present?
conversations = conversations.where(priority: priority) if priority.present? conversations = conversations.where(priority: priority) if priority.present?
conversations = conversations.tagged_with(labels, any: true) if labels.present?
conversations conversations
end end
@@ -59,20 +61,10 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
def properties def properties
{ {
contact_id: { contact_id: { type: 'number', description: 'Filter conversations by contact ID' },
type: 'number', status: { type: 'string', enum: %w[open resolved pending snoozed], description: 'Filter conversations by status' },
description: 'Filter conversations by contact ID' priority: { type: 'string', enum: %w[low medium high urgent], description: 'Filter conversations by priority' },
}, labels: { type: 'array', items: { type: 'string' }, description: 'Filter conversations by labels' }
status: {
type: 'string',
enum: %w[open resolved pending snoozed],
description: 'Filter conversations by status'
},
priority: {
type: 'string',
enum: %w[low medium high urgent],
description: 'Filter conversations by priority'
}
} }
end end
end end

View File

@@ -109,4 +109,46 @@ RSpec.describe Captain::ToolRegistryService do
end end
end end
end end
describe '#tools_summary' do
let(:tool_class) { TestTool }
before do
service.register_tool(tool_class)
end
it 'returns formatted summary of registered tools' do
expect(service.tools_summary).to eq('- test_tool: A test tool for specs')
end
context 'when multiple tools are registered' do
let(:another_tool_class) do
Class.new(Captain::Tools::BaseService) do
def name
'another_tool'
end
def description
'Another test tool'
end
def parameters
{
type: 'object',
properties: {}
}
end
def active?
true
end
end
end
it 'includes all tools in the summary' do
service.register_tool(another_tool_class)
expect(service.tools_summary).to eq("- test_tool: A test tool for specs\n- another_tool: Another test tool")
end
end
end
end end