mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +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 |   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
	 Pranav
					Pranav