Merge branch 'develop' into feat/chat-list-header

This commit is contained in:
Sivin Varghese
2024-12-11 11:01:27 +05:30
committed by GitHub
34 changed files with 769 additions and 101 deletions
+58 -58
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
actioncable (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailbox (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.5)
actionpack (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionmailer (7.0.8.7)
actionpack (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activesupport (= 7.0.8.7)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.5)
actionview (= 7.0.8.5)
activesupport (= 7.0.8.5)
actionpack (7.0.8.7)
actionview (= 7.0.8.7)
activesupport (= 7.0.8.7)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.5)
actionpack (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
actiontext (7.0.8.7)
actionpack (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.5)
activesupport (= 7.0.8.5)
actionview (7.0.8.7)
activesupport (= 7.0.8.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.5)
activesupport (= 7.0.8.5)
activejob (7.0.8.7)
activesupport (= 7.0.8.7)
globalid (>= 0.3.6)
activemodel (7.0.8.5)
activesupport (= 7.0.8.5)
activerecord (7.0.8.5)
activemodel (= 7.0.8.5)
activesupport (= 7.0.8.5)
activemodel (7.0.8.7)
activesupport (= 7.0.8.7)
activerecord (7.0.8.7)
activemodel (= 7.0.8.7)
activesupport (= 7.0.8.7)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.5)
actionpack (= 7.0.8.5)
activejob (= 7.0.8.5)
activerecord (= 7.0.8.5)
activesupport (= 7.0.8.5)
activestorage (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activesupport (= 7.0.8.7)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.5)
activesupport (7.0.8.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -472,7 +472,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.1)
minitest (5.25.4)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -502,14 +502,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.8)
nokogiri (1.17.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.8-arm64-darwin)
nokogiri (1.17.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.8-x86_64-darwin)
nokogiri (1.17.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.8-x86_64-linux)
nokogiri (1.17.1-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -581,20 +581,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.5)
actioncable (= 7.0.8.5)
actionmailbox (= 7.0.8.5)
actionmailer (= 7.0.8.5)
actionpack (= 7.0.8.5)
actiontext (= 7.0.8.5)
actionview (= 7.0.8.5)
activejob (= 7.0.8.5)
activemodel (= 7.0.8.5)
activerecord (= 7.0.8.5)
activestorage (= 7.0.8.5)
activesupport (= 7.0.8.5)
rails (7.0.8.7)
actioncable (= 7.0.8.7)
actionmailbox (= 7.0.8.7)
actionmailer (= 7.0.8.7)
actionpack (= 7.0.8.7)
actiontext (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activemodel (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
bundler (>= 1.15.0)
railties (= 7.0.8.5)
railties (= 7.0.8.7)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -602,9 +602,9 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.0.8.5)
actionpack (= 7.0.8.5)
activesupport (= 7.0.8.5)
railties (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -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>
@@ -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
@@ -6,6 +6,7 @@ class Internal::ProcessStaleRedisKeysJob < ApplicationJob
queue_as :low
def perform(account)
Internal::RemoveStaleRedisKeysService.new(account_id: account.id).perform
removed_count = Internal::RemoveStaleRedisKeysService.new(account_id: account.id).perform
Rails.logger.info "Successfully cleaned up Redis keys for account #{account.id} (removed #{removed_count} keys)"
end
end
@@ -6,9 +6,11 @@ class Internal::RemoveStaleRedisKeysJob < ApplicationJob
queue_as :scheduled_jobs
def perform
Account.find_each do |account|
Rails.logger.info "Enqueuing ProcessStaleRedisKeysJob for account #{account.id}"
Internal::ProcessStaleRedisKeysJob.perform_later(account)
Account.find_in_batches(batch_size: 100) do |accounts|
accounts.each do |account|
Rails.logger.info "Enqueuing ProcessStaleRedisKeysJob for account #{account.id}"
Internal::ProcessStaleRedisKeysJob.perform_later(account)
end
end
end
end
+7
View File
@@ -0,0 +1,7 @@
module LlmFormattable
extend ActiveSupport::Concern
def to_llm_text
LlmFormatter::LlmTextFormatterService.new(self).format
end
end
+1
View File
@@ -51,6 +51,7 @@
class Conversation < ApplicationRecord
include Labelable
include LlmFormattable
include AssignmentHandler
include AutoAssignmentHandler
include ActivityMessageHandler
@@ -1,10 +1,15 @@
class Internal::RemoveStaleRedisKeysService
pattr_initialize [:account_id!]
def perform(account_id)
range_start = (Time.zone.now - PRESENCE_DURATION).to_i
def perform
Rails.logger.info "Removing redis stale keys for account #{@account_id}"
range_start = (Time.zone.now - OnlineStatusTracker::PRESENCE_DURATION).to_i
# exclusive minimum score is specified by prefixing (
# we are clearing old records because this could clogg up the sorted set
::Redis::Alfred.zremrangebyscore(presence_key(account_id, 'Contact'), '-inf', "(#{range_start}")
::Redis::Alfred.zremrangebyscore(
OnlineStatusTracker.presence_key(@account_id, 'Contact'),
'-inf',
"(#{range_start}"
)
end
end
@@ -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
@@ -0,0 +1,9 @@
class LlmFormatter::DefaultLlmFormatter
def initialize(record)
@record = record
end
def format
# override this
end
end
@@ -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
+1
View File
@@ -220,6 +220,7 @@ Rails.application.routes.draw do
resource :captain, controller: 'captain', only: [] do
collection do
post :proxy
post :copilot
end
end
resources :hooks, only: [:show, :create, :update, :destroy] do
@@ -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,15 @@
require 'rails_helper'
RSpec.describe Internal::ProcessStaleRedisKeysJob do
let(:account) { create(:account) }
describe '#perform' do
it 'calls the RemoveStaleRedisKeysService with the correct account ID' do
expect(Internal::RemoveStaleRedisKeysService).to receive(:new)
.with(account_id: account.id)
.and_call_original
described_class.perform_now(account)
end
end
end
@@ -0,0 +1,13 @@
require 'rails_helper'
RSpec.describe Internal::RemoveStaleRedisKeysJob do
let(:account) { create(:account) }
describe '#perform' do
it 'enqueues ProcessStaleRedisKeysJob for the account' do
expect(Internal::ProcessStaleRedisKeysJob).to receive(:perform_later).with(account)
described_class.perform_now
end
end
end
@@ -0,0 +1,18 @@
require 'rails_helper'
RSpec.describe Internal::RemoveStaleRedisKeysService, type: :service do
let(:account_id) { 1 }
let(:service) { described_class.new(account_id: account_id) }
describe '#perform' do
it 'removes stale Redis keys for the specified account' do
presence_key = OnlineStatusTracker.presence_key(account_id, 'Contact')
# Mock Redis calls
expect(Redis::Alfred).to receive(:zremrangebyscore)
.with(presence_key, '-inf', anything)
service.perform
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
+15
View File
@@ -300,6 +300,21 @@ export const colors = {
12: 'rgb(var(--slate-12) / <alpha-value>)',
},
iris: {
1: 'rgb(var(--iris-1) / <alpha-value>)',
2: 'rgb(var(--iris-2) / <alpha-value>)',
3: 'rgb(var(--iris-3) / <alpha-value>)',
4: 'rgb(var(--iris-4) / <alpha-value>)',
5: 'rgb(var(--iris-5) / <alpha-value>)',
6: 'rgb(var(--iris-6) / <alpha-value>)',
7: 'rgb(var(--iris-7) / <alpha-value>)',
8: 'rgb(var(--iris-8) / <alpha-value>)',
9: 'rgb(var(--iris-9) / <alpha-value>)',
10: 'rgb(var(--iris-10) / <alpha-value>)',
11: 'rgb(var(--iris-11) / <alpha-value>)',
12: 'rgb(var(--iris-12) / <alpha-value>)',
},
ruby: {
1: 'rgb(var(--ruby-1) / <alpha-value>)',
2: 'rgb(var(--ruby-2) / <alpha-value>)',