feat(ee): Add copilot integration (v1) to the conversation sidebar (#10566)
This commit is contained in:
@@ -36,6 +36,10 @@ class IntegrationsAPI extends ApiClient {
|
||||
requestCaptain(body) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
|
||||
}
|
||||
|
||||
requestCaptainCopilot(body) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/captain/copilot`, body);
|
||||
}
|
||||
}
|
||||
|
||||
export default new IntegrationsAPI();
|
||||
|
||||
@@ -72,6 +72,19 @@
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
--iris-1: 253 253 255;
|
||||
--iris-2: 248 248 255;
|
||||
--iris-3: 240 241 254;
|
||||
--iris-4: 230 231 255;
|
||||
--iris-5: 218 220 255;
|
||||
--iris-6: 203 205 255;
|
||||
--iris-7: 184 186 248;
|
||||
--iris-8: 155 158 240;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 81 81 205;
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
@@ -147,6 +160,19 @@
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
--iris-1: 19 19 30;
|
||||
--iris-2: 23 22 37;
|
||||
--iris-3: 32 34 72;
|
||||
--iris-4: 38 42 101;
|
||||
--iris-5: 48 51 116;
|
||||
--iris-6: 61 62 130;
|
||||
--iris-7: 74 74 149;
|
||||
--iris-8: 89 88 177;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 84 114 228;
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Copilot from './Copilot.vue';
|
||||
|
||||
const supportAgent = {
|
||||
available_name: 'Pranav Raj',
|
||||
avatar_url:
|
||||
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
|
||||
};
|
||||
|
||||
const messages = ref([
|
||||
{
|
||||
id: 1,
|
||||
role: 'user',
|
||||
content: 'Hi there! How can I help you today?',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
role: 'assistant',
|
||||
content:
|
||||
"Hello! I'm the AI assistant. I'll be helping the support team today.",
|
||||
},
|
||||
]);
|
||||
|
||||
const isCaptainTyping = ref(false);
|
||||
|
||||
const sendMessage = message => {
|
||||
// Add user message
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
|
||||
// Simulate AI response
|
||||
isCaptainTyping.value = true;
|
||||
setTimeout(() => {
|
||||
isCaptainTyping.value = false;
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
role: 'assistant',
|
||||
content: 'This is a simulated AI response.',
|
||||
});
|
||||
}, 2000);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Copilot"
|
||||
:layout="{ type: 'grid', width: '400px', height: '800px' }"
|
||||
>
|
||||
<Copilot
|
||||
:support-agent="supportAgent"
|
||||
:messages="messages"
|
||||
:is-captain-typing="isCaptainTyping"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
</Story>
|
||||
</template>
|
||||
68
app/javascript/dashboard/components-next/copilot/Copilot.vue
Normal file
68
app/javascript/dashboard/components-next/copilot/Copilot.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import CopilotInput from './CopilotInput.vue';
|
||||
import CopilotLoader from './CopilotLoader.vue';
|
||||
import CopilotAgentMessage from './CopilotAgentMessage.vue';
|
||||
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
supportAgent: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isCaptainTyping: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
|
||||
const COPILOT_USER_ROLES = ['assistant', 'system'];
|
||||
|
||||
const sendMessage = message => {
|
||||
emit('sendMessage', message);
|
||||
};
|
||||
const chatContainer = ref(null);
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
if (chatContainer.value) {
|
||||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
[() => props.messages, () => props.isCaptainTyping],
|
||||
() => {
|
||||
scrollToBottom();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col ]mx-auto h-full text-sm leading-6 tracking-tight">
|
||||
<div ref="chatContainer" class="flex-1 overflow-y-auto py-4 space-y-6 px-4">
|
||||
<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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<CopilotLoader v-if="isCaptainTyping" />
|
||||
</div>
|
||||
|
||||
<CopilotInput class="mx-3 mb-4 mt-px" @send="sendMessage" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import Avatar from '../avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
supportAgent: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Avatar
|
||||
:name="supportAgent.available_name"
|
||||
:src="supportAgent.avatar_url"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="space-y-1 text-n-slate-12">
|
||||
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
|
||||
<div class="break-words">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import Avatar from '../avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Avatar
|
||||
name="Captain Copilot"
|
||||
icon-name="i-woot-captain"
|
||||
:size="24"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-1 text-n-slate-12">
|
||||
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
|
||||
<div class="break-words">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const emit = defineEmits(['send']);
|
||||
const message = ref('');
|
||||
|
||||
const sendMessage = () => {
|
||||
if (message.value.trim()) {
|
||||
emit('send', message.value);
|
||||
message.value = '';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
|
||||
@submit.prevent="sendMessage"
|
||||
>
|
||||
<input
|
||||
v-model="message"
|
||||
type="text"
|
||||
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
|
||||
class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<button
|
||||
class="h-auto w-12 flex items-center justify-center text-n-slate-11"
|
||||
type="submit"
|
||||
>
|
||||
<i class="i-ph-arrow-up" />
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
import CopilotLoader from './CopilotLoader.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/CopilotLoader"
|
||||
:layout="{ type: 'grid', width: '400px', height: '800px' }"
|
||||
>
|
||||
<CopilotLoader />
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script>
|
||||
// Copilot Loader Component
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-start">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-n-iris-11 font-medium">
|
||||
{{ $t('CAPTAIN.COPILOT.LOADER') }}
|
||||
</span>
|
||||
<div class="flex space-x-1">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce [animation-delay:-0.3s]"
|
||||
/>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce [animation-delay:-0.15s]"
|
||||
/>
|
||||
<div class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -785,7 +785,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
class="flex flex-col flex-shrink-0 border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
|
||||
:class="[
|
||||
{ hidden: !showConversationList },
|
||||
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
|
||||
isOnExpandedLayout ? 'basis-full' : 'w-[360px]',
|
||||
]"
|
||||
>
|
||||
<slot />
|
||||
@@ -916,12 +916,3 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@tailwind components;
|
||||
@layer components {
|
||||
.flex-basis-clamp {
|
||||
flex-basis: clamp(20rem, 4vw + 21.25rem, 27.5rem);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||
import IntegrationsAPI from 'dashboard/api/integrations';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { ref } from 'vue';
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const messages = ref([]);
|
||||
|
||||
const isCaptainTyping = ref(false);
|
||||
|
||||
const sendMessage = async message => {
|
||||
// Add user message
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
isCaptainTyping.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await IntegrationsAPI.requestCaptainCopilot({
|
||||
previous_history: messages.value
|
||||
.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
.slice(0, -1),
|
||||
message,
|
||||
conversation_id: props.conversationId,
|
||||
});
|
||||
messages.value.push({
|
||||
id: new Date().getTime(),
|
||||
role: 'assistant',
|
||||
content: data.message,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line
|
||||
console.log(error);
|
||||
} finally {
|
||||
isCaptainTyping.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Copilot
|
||||
:messages="messages"
|
||||
:support-agent="currentUser"
|
||||
:is-captain-typing="isCaptainTyping"
|
||||
@send-message="sendMessage"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,14 +1,14 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||
import ConversationHeader from './ConversationHeader.vue';
|
||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||
import EmptyState from './EmptyState/EmptyState.vue';
|
||||
import MessagesView from './MessagesView.vue';
|
||||
import ConversationSidebar from './ConversationSidebar.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactPanel,
|
||||
ConversationSidebar,
|
||||
ConversationHeader,
|
||||
DashboardAppFrame,
|
||||
EmptyState,
|
||||
@@ -138,17 +138,11 @@ export default {
|
||||
v-if="!currentChat.id && !isInboxView"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
/>
|
||||
<div
|
||||
v-show="showContactPanel"
|
||||
class="conversation-sidebar-wrap basis-full sm:basis-[17.5rem] md:basis-[18.75rem] lg:basis-[19.375rem] xl:basis-[20.625rem] 2xl:basis-[25rem] rtl:border-r border-slate-50 dark:border-slate-700 h-auto overflow-auto z-10 flex-shrink-0 flex-grow-0"
|
||||
>
|
||||
<ContactPanel
|
||||
v-if="showContactPanel"
|
||||
:conversation-id="currentChat.id"
|
||||
:inbox-id="currentChat.inbox_id"
|
||||
:on-toggle="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
<ConversationSidebar
|
||||
v-if="showContactPanel"
|
||||
:current-chat="currentChat"
|
||||
@toggle-contact-panel="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
<DashboardAppFrame
|
||||
v-for="(dashboardApp, index) in dashboardApps"
|
||||
@@ -180,10 +174,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-sidebar-wrap {
|
||||
&::v-deep .contact--panel {
|
||||
@apply w-full h-full max-w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { computed, ref } from 'vue';
|
||||
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
currentChat: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggleContactPanel']);
|
||||
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const captainIntegration = computed(() =>
|
||||
getters['integrations/getIntegration'].value('captain', null)
|
||||
);
|
||||
const { t } = useI18n();
|
||||
|
||||
const CONTACT_TABS_OPTIONS = [
|
||||
{ key: 'CONTACT', value: 'contact' },
|
||||
{ key: 'COPILOT', value: 'copilot' },
|
||||
];
|
||||
|
||||
const tabs = computed(() => {
|
||||
return CONTACT_TABS_OPTIONS.map(tab => ({
|
||||
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
|
||||
value: tab.value,
|
||||
}));
|
||||
});
|
||||
const activeTab = ref(0);
|
||||
const toggleContactPanel = () => {
|
||||
emit('toggleContactPanel');
|
||||
};
|
||||
|
||||
const handleTabChange = selectedTab => {
|
||||
activeTab.value = tabs.value.findIndex(
|
||||
tabItem => tabItem.value === selectedTab.value
|
||||
);
|
||||
};
|
||||
|
||||
const showCopilotTab = computed(() => {
|
||||
return captainIntegration.value && captainIntegration.value.enabled;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 min-w-[300px] w-[300px] flex flex-col bg-n-solid-2"
|
||||
>
|
||||
<div v-if="showCopilotTab" class="p-2">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTab"
|
||||
class="w-full [&>button]:w-full"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="overflow-auto flex flex-1">
|
||||
<ContactPanel
|
||||
v-if="!activeTab"
|
||||
:conversation-id="currentChat.id"
|
||||
:inbox-id="currentChat.inbox_id"
|
||||
:on-toggle="toggleContactPanel"
|
||||
/>
|
||||
<CopilotContainer
|
||||
v-else-if="activeTab === 1 && showCopilotTab"
|
||||
:key="currentChat.id"
|
||||
:conversation-id="currentChat.id"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -219,6 +219,10 @@
|
||||
"DELETE": "Delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@@ -299,5 +299,13 @@
|
||||
"ERROR": "There was an error unlinking the issue, please try again"
|
||||
}
|
||||
}
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"NAME": "Captain",
|
||||
"COPILOT": {
|
||||
"SEND_MESSAGE": "Send message...",
|
||||
"LOADER": "Captain is thinking",
|
||||
"YOU": "You"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,9 +92,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="overflow-y-auto bg-white border-l dark:bg-slate-900 text-slate-900 dark:text-slate-300 border-slate-50 dark:border-slate-800/50 rtl:border-l-0 rtl:border-r contact--panel"
|
||||
>
|
||||
<div class="w-full">
|
||||
<ContactInfo
|
||||
:contact="contact"
|
||||
:channel-type="channelType"
|
||||
|
||||
@@ -174,7 +174,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative items-center w-full p-4 bg-white dark:bg-slate-900">
|
||||
<div class="relative items-center w-full p-4">
|
||||
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
|
||||
<div class="flex flex-row justify-between">
|
||||
<Thumbnail
|
||||
|
||||
Reference in New Issue
Block a user