mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 02:32:29 +00:00
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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
include Labelable
|
||||
include LlmFormattable
|
||||
include AssignmentHandler
|
||||
include AutoAssignmentHandler
|
||||
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
|
||||
collection do
|
||||
post :proxy
|
||||
post :copilot
|
||||
end
|
||||
end
|
||||
resources :hooks, only: [:show, :create, :update, :destroy] do
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>)',
|
||||
},
|
||||
|
||||
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>)',
|
||||
|
||||
Reference in New Issue
Block a user