feat(ee): Add copilot integration (v1) to the conversation sidebar (#10566)

This commit is contained in:
Pranav
2024-12-10 15:36:48 -08:00
committed by GitHub
parent 9a405d65ba
commit 10a0333980
27 changed files with 650 additions and 36 deletions

View File

@@ -2,10 +2,22 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
before_action :hook
def proxy
request_url = build_request_url(request_path)
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
render plain: response.body, status: response.code
end
def copilot
request_url = build_request_url(build_request_path("/assistants/#{hook.settings['assistant_id']}/copilot"))
params = {
previous_messages: copilot_params[:previous_messages],
conversation_history: conversation_history,
message: copilot_params[:message]
}
response = HTTParty.send(:post, request_url, body: params.to_json, headers: headers)
render plain: response.body, status: response.code
end
private
def headers
@@ -17,15 +29,19 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
}
end
def build_request_path(route)
"api/accounts/#{hook.settings['account_id']}#{route}"
end
def request_path
request_route = with_leading_hash_on_route(params[:route])
return 'api/sessions/profile' if request_route == '/sessions/profile'
"api/accounts/#{hook.settings['account_id']}#{request_route}"
build_request_path(request_route)
end
def request_url
def build_request_url(request_path)
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
URI.join(base_url, request_path).to_s
end
@@ -47,6 +63,15 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
request_route.start_with?('/') ? request_route : "/#{request_route}"
end
def conversation_history
conversation = Current.account.conversations.find_by!(display_id: copilot_params[:conversation_id])
conversation.to_llm_text
end
def copilot_params
params.permit(:previous_messages, :conversation_id, :message)
end
def permitted_params
params.permit(:method, :route, body: {})
end

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -219,6 +219,10 @@
"DELETE": "Delete",
"CANCEL": "Cancel"
}
},
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
}
},
"EMAIL_TRANSCRIPT": {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
module LlmFormattable
extend ActiveSupport::Concern
def to_llm_text
LlmFormatter::LlmTextFormatterService.new(self).format
end
end

View File

@@ -51,6 +51,7 @@
class Conversation < ApplicationRecord
include Labelable
include LlmFormattable
include AssignmentHandler
include AutoAssignmentHandler
include ActivityMessageHandler

View File

@@ -0,0 +1,32 @@
class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
def format
sections = []
sections << "Conversation ID: ##{@record.display_id}"
sections << "Channel: #{@record.inbox.channel.name}"
sections << 'Message History:'
sections << if @record.messages.any?
build_messages
else
'No messages in this conversation'
end
sections.join("\n")
end
private
def build_messages
return "No messages in this conversation\n" if @record.messages.empty?
message_text = ''
@record.messages.chat.order(created_at: :asc).each do |message|
message_text << format_message(message)
end
message_text
end
def format_message(message)
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
"#{sender}: #{message.content}\n"
end
end

View File

@@ -0,0 +1,9 @@
class LlmFormatter::DefaultLlmFormatter
def initialize(record)
@record = record
end
def format
# override this
end
end

View File

@@ -0,0 +1,20 @@
class LlmFormatter::LlmTextFormatterService
def initialize(record)
@record = record
end
def format
formatter_class = find_formatter
formatter_class.new(@record).format
end
private
def find_formatter
formatter_name = "LlmFormatter::#{@record.class.name}LlmFormatter"
formatter_class = formatter_name.safe_constantize
raise FormatterNotFoundError, "No formatter found for #{@record.class.name}" unless formatter_class
formatter_class
end
end

View File

@@ -220,6 +220,7 @@ Rails.application.routes.draw do
resource :captain, controller: 'captain', only: [] do
collection do
post :proxy
post :copilot
end
end
resources :hooks, only: [:show, :create, :update, :destroy] do

View File

@@ -2,13 +2,15 @@ require 'rails_helper'
RSpec.describe 'Captain Integrations API', type: :request do
let!(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:hook) do
create(:integrations_hook, account: account, app_id: 'captain', settings: {
access_token: SecureRandom.hex,
account_email: Faker::Internet.email,
assistant_id: '1',
account_id: '1'
account_id: '1',
inbox_ids: []
})
end
let(:captain_api_url) { 'https://captain.example.com/' }
@@ -77,4 +79,45 @@ RSpec.describe 'Captain Integrations API', type: :request do
end
end
end
describe 'POST /api/v1/accounts/{account.id}/integrations/captain/copilot' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post copilot_api_v1_account_integrations_captain_url(account_id: account.id),
params: { method: 'get', route: 'some_route' },
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'when valid request method and route' do
let(:route) { 'assistants/1/copilot' }
let(:method) { 'get' }
it 'proxies the request to Copilot API' do
stub_request(:post, "#{captain_api_url}api/accounts/#{hook.settings['account_id']}/#{route}")
.with(headers: {
'X-User-Email' => hook.settings['account_email'],
'X-User-Token' => hook.settings['access_token'],
'Content-Type' => 'application/json'
})
.to_return(status: 200, body: 'Success', headers: {})
post copilot_api_v1_account_integrations_captain_url(account_id: account.id),
params: {
message: 'hello',
previous_messages: [],
conversation_id: conversation.display_id
},
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to eq('Success')
end
end
end
end
end

View File

@@ -0,0 +1,51 @@
require 'rails_helper'
RSpec.describe LlmFormatter::ConversationLlmFormatter do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:formatter) { described_class.new(conversation) }
describe '#format' do
context 'when conversation has no messages' do
it 'returns basic conversation info with no messages' do
expected_output = [
"Conversation ID: ##{conversation.display_id}",
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
'No messages in this conversation'
].join("\n")
expect(formatter.format).to eq(expected_output)
end
end
context 'when conversation has messages' do
it 'formats messages in chronological order with sender labels' do
create(
:message,
conversation: conversation,
message_type: 'incoming',
content: 'Hello, I need help'
)
create(
:message,
conversation: conversation,
message_type: 'outgoing',
content: 'How can I assist you today?'
)
expected_output = [
"Conversation ID: ##{conversation.display_id}",
"Channel: #{conversation.inbox.channel.name}",
'Message History:',
'User: Hello, I need help',
'Support agent: How can I assist you today?',
''
].join("\n")
expect(formatter.format).to eq(expected_output)
end
end
end
end

View File

@@ -300,6 +300,21 @@ export const colors = {
12: 'rgb(var(--slate-12) / <alpha-value>)',
},
iris: {
1: 'rgb(var(--iris-1) / <alpha-value>)',
2: 'rgb(var(--iris-2) / <alpha-value>)',
3: 'rgb(var(--iris-3) / <alpha-value>)',
4: 'rgb(var(--iris-4) / <alpha-value>)',
5: 'rgb(var(--iris-5) / <alpha-value>)',
6: 'rgb(var(--iris-6) / <alpha-value>)',
7: 'rgb(var(--iris-7) / <alpha-value>)',
8: 'rgb(var(--iris-8) / <alpha-value>)',
9: 'rgb(var(--iris-9) / <alpha-value>)',
10: 'rgb(var(--iris-10) / <alpha-value>)',
11: 'rgb(var(--iris-11) / <alpha-value>)',
12: 'rgb(var(--iris-12) / <alpha-value>)',
},
ruby: {
1: 'rgb(var(--ruby-1) / <alpha-value>)',
2: 'rgb(var(--ruby-2) / <alpha-value>)',