feat(ee): Add copilot integration (v1) to the conversation sidebar (#10566)
This commit is contained in:
@@ -2,10 +2,22 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
|
|||||||
before_action :hook
|
before_action :hook
|
||||||
|
|
||||||
def proxy
|
def proxy
|
||||||
|
request_url = build_request_url(request_path)
|
||||||
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
|
response = HTTParty.send(request_method, request_url, body: permitted_params[:body].to_json, headers: headers)
|
||||||
render plain: response.body, status: response.code
|
render plain: response.body, status: response.code
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def headers
|
def headers
|
||||||
@@ -17,15 +29,19 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_request_path(route)
|
||||||
|
"api/accounts/#{hook.settings['account_id']}#{route}"
|
||||||
|
end
|
||||||
|
|
||||||
def request_path
|
def request_path
|
||||||
request_route = with_leading_hash_on_route(params[:route])
|
request_route = with_leading_hash_on_route(params[:route])
|
||||||
|
|
||||||
return 'api/sessions/profile' if request_route == '/sessions/profile'
|
return 'api/sessions/profile' if request_route == '/sessions/profile'
|
||||||
|
|
||||||
"api/accounts/#{hook.settings['account_id']}#{request_route}"
|
build_request_path(request_route)
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_url
|
def build_request_url(request_path)
|
||||||
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
|
base_url = InstallationConfig.find_by(name: 'CAPTAIN_API_URL').value
|
||||||
URI.join(base_url, request_path).to_s
|
URI.join(base_url, request_path).to_s
|
||||||
end
|
end
|
||||||
@@ -47,6 +63,15 @@ class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::Ba
|
|||||||
request_route.start_with?('/') ? request_route : "/#{request_route}"
|
request_route.start_with?('/') ? request_route : "/#{request_route}"
|
||||||
end
|
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
|
def permitted_params
|
||||||
params.permit(:method, :route, body: {})
|
params.permit(:method, :route, body: {})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class IntegrationsAPI extends ApiClient {
|
|||||||
requestCaptain(body) {
|
requestCaptain(body) {
|
||||||
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, 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();
|
export default new IntegrationsAPI();
|
||||||
|
|||||||
@@ -72,6 +72,19 @@
|
|||||||
--slate-11: 96 100 108;
|
--slate-11: 96 100 108;
|
||||||
--slate-12: 28 32 36;
|
--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-1: 255 252 253;
|
||||||
--ruby-2: 255 247 248;
|
--ruby-2: 255 247 248;
|
||||||
--ruby-3: 254 234 237;
|
--ruby-3: 254 234 237;
|
||||||
@@ -147,6 +160,19 @@
|
|||||||
--slate-11: 176 180 186;
|
--slate-11: 176 180 186;
|
||||||
--slate-12: 237 238 240;
|
--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-1: 25 17 19;
|
||||||
--ruby-2: 30 21 23;
|
--ruby-2: 30 21 23;
|
||||||
--ruby-3: 58 20 30;
|
--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="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="[
|
:class="[
|
||||||
{ hidden: !showConversationList },
|
{ hidden: !showConversationList },
|
||||||
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
|
isOnExpandedLayout ? 'basis-full' : 'w-[360px]',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
@@ -916,12 +916,3 @@ watch(conversationFilters, (newVal, oldVal) => {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
|
||||||
import ConversationHeader from './ConversationHeader.vue';
|
import ConversationHeader from './ConversationHeader.vue';
|
||||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||||
import EmptyState from './EmptyState/EmptyState.vue';
|
import EmptyState from './EmptyState/EmptyState.vue';
|
||||||
import MessagesView from './MessagesView.vue';
|
import MessagesView from './MessagesView.vue';
|
||||||
|
import ConversationSidebar from './ConversationSidebar.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
ContactPanel,
|
ConversationSidebar,
|
||||||
ConversationHeader,
|
ConversationHeader,
|
||||||
DashboardAppFrame,
|
DashboardAppFrame,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
@@ -138,17 +138,11 @@ export default {
|
|||||||
v-if="!currentChat.id && !isInboxView"
|
v-if="!currentChat.id && !isInboxView"
|
||||||
:is-on-expanded-layout="isOnExpandedLayout"
|
:is-on-expanded-layout="isOnExpandedLayout"
|
||||||
/>
|
/>
|
||||||
<div
|
<ConversationSidebar
|
||||||
v-show="showContactPanel"
|
v-if="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"
|
:current-chat="currentChat"
|
||||||
>
|
@toggle-contact-panel="onToggleContactPanel"
|
||||||
<ContactPanel
|
/>
|
||||||
v-if="showContactPanel"
|
|
||||||
:conversation-id="currentChat.id"
|
|
||||||
:inbox-id="currentChat.inbox_id"
|
|
||||||
:on-toggle="onToggleContactPanel"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<DashboardAppFrame
|
<DashboardAppFrame
|
||||||
v-for="(dashboardApp, index) in dashboardApps"
|
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>
|
</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",
|
"DELETE": "Delete",
|
||||||
"CANCEL": "Cancel"
|
"CANCEL": "Cancel"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"SIDEBAR": {
|
||||||
|
"CONTACT": "Contact",
|
||||||
|
"COPILOT": "Copilot"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EMAIL_TRANSCRIPT": {
|
"EMAIL_TRANSCRIPT": {
|
||||||
|
|||||||
@@ -299,5 +299,13 @@
|
|||||||
"ERROR": "There was an error unlinking the issue, please try again"
|
"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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="w-full">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<ContactInfo
|
<ContactInfo
|
||||||
:contact="contact"
|
:contact="contact"
|
||||||
:channel-type="channelType"
|
:channel-type="channelType"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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-col w-full gap-2 text-left rtl:text-right">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<Thumbnail
|
<Thumbnail
|
||||||
|
|||||||
7
app/models/concerns/llm_formattable.rb
Normal file
7
app/models/concerns/llm_formattable.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
module LlmFormattable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def to_llm_text
|
||||||
|
LlmFormatter::LlmTextFormatterService.new(self).format
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
|
|
||||||
class Conversation < ApplicationRecord
|
class Conversation < ApplicationRecord
|
||||||
include Labelable
|
include Labelable
|
||||||
|
include LlmFormattable
|
||||||
include AssignmentHandler
|
include AssignmentHandler
|
||||||
include AutoAssignmentHandler
|
include AutoAssignmentHandler
|
||||||
include ActivityMessageHandler
|
include ActivityMessageHandler
|
||||||
|
|||||||
32
app/services/llm_formatter/conversation_llm_formatter.rb
Normal file
32
app/services/llm_formatter/conversation_llm_formatter.rb
Normal 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
|
||||||
9
app/services/llm_formatter/default_llm_formatter.rb
Normal file
9
app/services/llm_formatter/default_llm_formatter.rb
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
class LlmFormatter::DefaultLlmFormatter
|
||||||
|
def initialize(record)
|
||||||
|
@record = record
|
||||||
|
end
|
||||||
|
|
||||||
|
def format
|
||||||
|
# override this
|
||||||
|
end
|
||||||
|
end
|
||||||
20
app/services/llm_formatter/llm_text_formatter_service.rb
Normal file
20
app/services/llm_formatter/llm_text_formatter_service.rb
Normal 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
|
||||||
@@ -220,6 +220,7 @@ Rails.application.routes.draw do
|
|||||||
resource :captain, controller: 'captain', only: [] do
|
resource :captain, controller: 'captain', only: [] do
|
||||||
collection do
|
collection do
|
||||||
post :proxy
|
post :proxy
|
||||||
|
post :copilot
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resources :hooks, only: [:show, :create, :update, :destroy] do
|
resources :hooks, only: [:show, :create, :update, :destroy] do
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ require 'rails_helper'
|
|||||||
|
|
||||||
RSpec.describe 'Captain Integrations API', type: :request do
|
RSpec.describe 'Captain Integrations API', type: :request do
|
||||||
let!(:account) { create(:account) }
|
let!(:account) { create(:account) }
|
||||||
|
let(:conversation) { create(:conversation, account: account) }
|
||||||
let!(:agent) { create(:user, account: account, role: :agent) }
|
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||||
let!(:hook) do
|
let!(:hook) do
|
||||||
create(:integrations_hook, account: account, app_id: 'captain', settings: {
|
create(:integrations_hook, account: account, app_id: 'captain', settings: {
|
||||||
access_token: SecureRandom.hex,
|
access_token: SecureRandom.hex,
|
||||||
account_email: Faker::Internet.email,
|
account_email: Faker::Internet.email,
|
||||||
assistant_id: '1',
|
assistant_id: '1',
|
||||||
account_id: '1'
|
account_id: '1',
|
||||||
|
inbox_ids: []
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
let(:captain_api_url) { 'https://captain.example.com/' }
|
let(:captain_api_url) { 'https://captain.example.com/' }
|
||||||
@@ -77,4 +79,45 @@ RSpec.describe 'Captain Integrations API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -300,6 +300,21 @@ export const colors = {
|
|||||||
12: 'rgb(var(--slate-12) / <alpha-value>)',
|
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: {
|
ruby: {
|
||||||
1: 'rgb(var(--ruby-1) / <alpha-value>)',
|
1: 'rgb(var(--ruby-1) / <alpha-value>)',
|
||||||
2: 'rgb(var(--ruby-2) / <alpha-value>)',
|
2: 'rgb(var(--ruby-2) / <alpha-value>)',
|
||||||
|
|||||||
Reference in New Issue
Block a user