Compare commits

..
Author SHA1 Message Date
Shivam Mishra 8bf39a52d1 feat: get rid of turbo links 2025-05-23 12:28:47 +05:30
Shivam Mishra 5e570d08f3 feat: better search UX on the page 2025-05-23 12:26:22 +05:30
Shivam Mishra 8070b0897c feat: use turbo instead of turbo links 2025-05-23 12:25:05 +05:30
Shivam MishraandGitHub 45a1b44b61 Merge branch 'develop' into feature/10945-search-results-page 2025-05-23 11:52:51 +05:30
PranavandGitHub 72c5671e09 feat: Add support for the temperature field (#11554)
<img width="460" alt="Screenshot 2025-05-22 at 3 10 22 PM"
src="https://github.com/user-attachments/assets/e1c6bbd7-cd28-4808-99cb-ebc322531987"
/>


This is a stop-gap solution.
2025-05-22 23:03:10 -07:00
PranavandGitHub 8c0885e1d2 feat: Add support for realtime-events in copilot-threads and copilot-messages (#11557)
- Add API support for creating a thread
- Add API support for creating a message
- Remove uuid from thread (no longer required, we will use existing
websocket connection to send messages)
- Update message_type to a column (user, assistant, assistant_thinking)
2025-05-22 22:25:05 -07:00
Sivin VargheseandGitHub e92f72b318 chore: Prevent online presence during impersonation (#11538) 2025-05-23 09:53:40 +05:30
AlexandGitHub 076059cd3c Merge branch 'develop' into feature/10945-search-results-page 2025-03-19 12:25:14 +01:00
Alejandro Fanjul 67311c2c9a feat: Implement search results functionality with improved UX in PublicArticleSearch component
- Now you can use /hc/{account}/en/search?query=XXX to view search results
2025-03-14 14:08:56 +01:00
78 changed files with 1361 additions and 847 deletions
+2
View File
@@ -7,6 +7,8 @@ gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
# Turbo for SPA-like page acceleration
gem 'turbo-rails'
##-- rails application helper gems --##
gem 'acts-as-taggable-on'
+4
View File
@@ -815,6 +815,9 @@ GEM
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
turbo-rails (2.0.13)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
@@ -1004,6 +1007,7 @@ DEPENDENCIES
telephone_number
test-prof
time_diff
turbo-rails
twilio-ruby (~> 5.66)
twitty (~> 0.1.5)
tzinfo-data
@@ -0,0 +1,19 @@
class Public::Api::V1::Portals::SearchController < Public::Api::V1::Portals::BaseController
before_action :portal
layout 'portal'
def index
@query = params[:query]
@articles = @portal.articles.published
@articles = @articles.search(search_params) if @query.present?
@articles = @articles.page(params[:page]).per(10) # Add pagination
end
private
def search_params
params.permit(:query, :locale, :sort, :status, :page)
end
end
@@ -134,6 +134,10 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
@@ -3,7 +3,7 @@
}
.tabs--container--with-border {
@apply border-b border-b-n-weak;
@apply border-b border-n-weak;
}
.tabs--container--compact.tab--chat-type {
@@ -42,6 +42,7 @@ const initialState = {
conversationFaqs: false,
memories: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
@@ -87,6 +88,7 @@ const updateStateFromAssistant = assistant => {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
@@ -136,6 +138,7 @@ const handleInstructionsUpdate = async () => {
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
@@ -212,7 +215,7 @@ watch(
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
@@ -221,6 +224,25 @@ watch(
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
@@ -1,5 +1,6 @@
<script setup>
import { nextTick, ref, watch, computed } from 'vue';
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -7,10 +8,8 @@ import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import CopilotHeader from './CopilotHeader.vue';
import CopilotEmptyState from './CopilotEmptyState.vue';
import Icon from '../icon/Icon.vue';
const props = defineProps({
supportAgent: {
@@ -27,7 +26,7 @@ const props = defineProps({
},
conversationInboxType: {
type: String,
default: '',
required: true,
},
assistants: {
type: Array,
@@ -39,13 +38,22 @@ const props = defineProps({
},
});
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant', 'close']);
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
};
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const handleReset = () => {
emit('reset');
};
@@ -59,28 +67,20 @@ const scrollToBottom = async () => {
}
};
const groupedMessages = computed(() => {
const result = [];
let thinkingGroup = [];
props.messages.forEach(message => {
if (message.role === 'assistant_thinking') {
thinkingGroup.push(message);
} else {
if (thinkingGroup.length > 0) {
result.push({ type: 'thinking_group', messages: thinkingGroup });
thinkingGroup = [];
}
result.push({ type: 'message', message });
}
});
if (thinkingGroup.length > 0) {
result.push({ type: 'thinking_group', messages: thinkingGroup });
}
return result;
});
const promptOptions = [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
];
watch(
[() => props.messages, () => props.isCaptainTyping],
@@ -93,62 +93,60 @@ watch(
<template>
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<CopilotHeader
:has-messages="messages.length > 0"
@reset="handleReset"
@close="$emit('close')"
/>
<div
ref="chatContainer"
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
>
<div v-if="messages.length" class="space-y-6 flex-1 flex flex-col w-full">
<template
v-for="item in groupedMessages"
:key="item.type === 'message' ? item.message.id : 'thinking-group'"
>
<template v-if="item.type === 'message'">
<CopilotAgentMessage
v-if="item.message.role === 'user'"
:support-agent="supportAgent"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="
item.message.role === 'assistant' ||
item.message.role === 'system'
"
:message="item.message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:has-assistant-message-after="
groupedMessages[groupedMessages.indexOf(item) + 1]?.message
?.role === 'assistant'
"
/>
</template>
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<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"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
<CopilotLoader v-if="isCaptainTyping" />
</div>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
<CopilotEmptyState
v-if="!messages.length"
@use-suggestion="sendMessage"
/>
</div>
<div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant
v-if="assistants.length > 1"
v-if="assistants.length"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@click="handleReset"
>
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
</div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
</div>
@@ -17,7 +17,7 @@ const props = defineProps({
},
conversationInboxType: {
type: String,
default: '',
required: true,
},
});
@@ -1,91 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'High priority conversations',
prompt: 'Get me a summary of all high priority open conversations',
},
{
label: 'List contacts',
prompt: 'Show me the list of contacts',
},
{
label: 'Summarize articles',
prompt: 'List articles that are in draft',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
if (path.includes('/contacts')) return 'contacts';
if (path.includes('/articles')) return 'articles';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -11,7 +11,7 @@ defineEmits(['reset', 'close']);
<template>
<div
class="flex items-center justify-between px-4 py-2 border-b border-n-weak h-12"
class="flex items-center justify-between px-5 py-2 border-b border-n-weak h-12"
>
<div class="flex items-center justify-between gap-2 flex-1">
<span class="font-medium text-sm text-n-slate-12">
@@ -1,47 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useMapGetter } from 'dashboard/composables/store';
const store = useStore();
const route = useRoute();
const getUIState = useMapGetter('uiState/getUIState');
const isSidebarOpen = computed(() => getUIState.value('isCopilotSidebarOpen'));
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const toggleSidebar = () => {
store.dispatch('uiState/toggle', 'isCopilotSidebarOpen');
};
</script>
<template>
<div class="fixed bottom-4 right-4 z-50">
<div
v-if="!isSidebarOpen && !isConversationRoute"
class="rounded-full bg-n-solid-2 p-2 hover:bg-n-solid-3"
>
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-alpha-2 text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
</template>
@@ -519,7 +519,7 @@ const menuItems = computed(() => {
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<ul class="flex flex-col gap-2 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -824,7 +824,7 @@ watch(conversationFilters, (newVal, oldVal) => {
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[21rem] 2xl:w-[26rem]',
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
]"
>
<slot />
@@ -80,14 +80,16 @@ const toggleConversationLayout = () => {
<template>
<div
class="flex items-center justify-between gap-2 px-3 h-12 pt-3 pb-3"
class="flex items-center justify-between gap-2 px-4"
:class="{
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pb-3 border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
'pt-3 pb-2': showV4View,
'mb-2 pb-0': !showV4View,
}"
>
<div class="flex items-center justify-center min-w-0">
<h1
class="text-base font-medium truncate text-n-slate-12"
class="text-lg font-medium truncate text-n-slate-12"
:title="pageTitle"
>
{{ pageTitle }}
@@ -1,24 +1,31 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watchEffect } from 'vue';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
conversationInboxType: {
type: String,
required: true,
},
});
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings();
const currentChat = useMapGetter('getSelectedChat');
const getUIState = useMapGetter('uiState/getUIState');
const isSidebarOpen = computed(() => getUIState.value('isCopilotSidebarOpen'));
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const copilotConnector = ref(null);
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -52,12 +59,7 @@ const handleReset = () => {
messages.value = [];
};
const sendMessage = message => {
// Ensure WebSocket is connected before sending
if (!copilotConnector.value || !isSidebarOpen.value) {
return;
}
const sendMessage = async message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
@@ -66,71 +68,55 @@ const sendMessage = message => {
});
isCaptainTyping.value = true;
copilotConnector.value.sendMessage({
assistantId: activeAssistant.value.id,
conversationId: currentChat.value?.id,
previousHistory: messages.value
.filter(m => m.role !== 'assistant_thinking')
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
});
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
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;
}
};
onMounted(() => {
store.dispatch('captainAssistants/get');
});
// const onCopilotResponse = data => {
// if (data.type === 'final_response') {
// messages.value.push({
// id: new Date().getTime(),
// role: 'assistant',
// content: data.response.response,
// });
// isCaptainTyping.value = false;
// } else if (data.type === 'ui_event') {
// if (data.event === 'ui:linear_ticket_create') {
// emitter.emit('ui:linear_ticket_create');
// setTimeout(() => {
// emitter.emit('ui:linear_ticket_create_data', data.response);
// }, 100);
// }
// } else {
// messages.value.push({
// id: new Date().getTime(),
// role: 'assistant_thinking',
// content: data.response.response,
// reasoning: data.response.reasoning,
// });
// }
// };
const handleClose = () => {
store.dispatch('uiState/set', { isCopilotSidebarOpen: false });
};
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script>
<template>
<div
v-if="isSidebarOpen"
class="border-l border-n-weak w-[20rem] min-w-[20rem]"
>
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
@close="handleClose"
/>
</div>
<div v-else class="hidden" />
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
</template>
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 tab--chat-type"
class="w-full px-4 py-0 tab--chat-type"
@change="onTabChange"
>
<woot-tabs-item
@@ -83,7 +83,7 @@ export default {
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="hasOpenedAtleastOnce" class="flex-1">
<div v-if="hasOpenedAtleastOnce" class="dashboard-app--container">
<div
v-for="(configItem, index) in config"
:key="index"
@@ -115,7 +115,6 @@ export default {
.dashboard-app--list iframe {
border: 0;
}
.dashboard-app_loading-container {
display: flex;
align-items: center;
@@ -4,9 +4,11 @@ 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: {
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
@@ -23,11 +25,16 @@ export default {
type: Boolean,
default: false,
},
isContactPanelOpen: {
type: Boolean,
default: true,
},
isOnExpandedLayout: {
type: Boolean,
default: true,
},
},
emits: ['contactPanelToggle'],
data() {
return { activeIndex: 0 };
},
@@ -50,6 +57,9 @@ export default {
})),
];
},
showContactPanel() {
return this.isContactPanelOpen && this.currentChat.id;
},
},
watch: {
'currentChat.inbox_id': {
@@ -76,6 +86,9 @@ export default {
}
this.$store.dispatch('conversationLabels/get', this.currentChat.id);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
onDashboardAppTabChange(index) {
this.activeIndex = index;
},
@@ -85,7 +98,7 @@ export default {
<template>
<div
class="conversation-details-wrap bg-n-background relative"
class="conversation-details-wrap bg-n-background"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
@@ -94,12 +107,14 @@ export default {
v-if="currentChat.id"
:chat="currentChat"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
:show-back-button="isOnExpandedLayout && !isInboxView"
@contact-panel-toggle="onToggleContactPanel"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
:index="activeIndex"
class="-mt-px border-t border-t-n-background bg-n-background dashboard-app--tabs"
class="-mt-px bg-white dashboard-app--tabs dark:bg-slate-900"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -110,17 +125,23 @@ export default {
:show-badge="false"
/>
</woot-tabs>
<div v-show="!activeIndex" class="flex-1 h-full min-h-0 m-0">
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
<MessagesView
v-if="currentChat.id"
:inbox-id="inboxId"
:is-inbox-view="isInboxView"
:is-contact-panel-open="isContactPanelOpen"
@contact-panel-toggle="onToggleContactPanel"
/>
<EmptyState
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<slot />
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -243,7 +243,7 @@ export default {
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-4 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
@@ -278,7 +278,7 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
size="40px"
/>
</div>
<div
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue';
@@ -12,6 +13,8 @@ import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BackButton,
@@ -20,6 +23,7 @@ export default {
Thumbnail,
SLACardLabel,
Linear,
NextButton,
},
mixins: [inboxMixin],
props: {
@@ -27,6 +31,10 @@ export default {
type: Object,
default: () => {},
},
isContactPanelOpen: {
type: Boolean,
default: false,
},
showBackButton: {
type: Boolean,
default: false,
@@ -36,6 +44,15 @@ export default {
default: false,
},
},
emits: ['contactPanelToggle'],
setup(props, { emit }) {
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents);
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
@@ -82,7 +99,13 @@ export default {
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
return this.$store.getters['inboxes/getInbox'](inboxId);
@@ -110,7 +133,7 @@ export default {
<template>
<div
class="flex flex-col items-center justify-between px-3 border-b bg-n-background border-n-weak md:flex-row h-12"
class="flex flex-col items-center justify-between px-4 py-2 border-b bg-n-background border-n-weak md:flex-row"
>
<div
class="flex flex-col items-center justify-center flex-1 w-full min-w-0"
@@ -127,18 +150,20 @@ export default {
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
>
<div class="flex items-center max-w-full gap-1 p-0 m-0 w-fit">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
<div
class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"
>
<NextButton link slate @click.prevent="$emit('contactPanelToggle')">
<span
class="text-base font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
</span>
</NextButton>
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
@@ -155,11 +180,19 @@ export default {
<span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }}
</span>
<NextButton
link
xs
blue
:label="contactPanelToggleText"
@click="$emit('contactPanelToggle')"
/>
</div>
</div>
</div>
<div
class="flex flex-row items-center justify-end flex-grow gap-2 mt-3 header-actions-wrap lg:mt-0"
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLACardLabel v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<Linear
@@ -1,8 +1,11 @@
<script setup>
import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
const props = defineProps({
currentChat: {
@@ -11,48 +14,69 @@ const props = defineProps({
},
});
const getUIState = useMapGetter('uiState/getUIState');
const isCopilotSidebarOpen = computed(() =>
getUIState.value('isCopilotSidebarOpen')
);
const isConversationSidebarOpen = computed(() =>
getUIState.value('isConversationSidebarOpen')
);
const emit = defineEmits(['toggleContactPanel']);
const showConversationSidebar = computed(
() =>
props.currentChat.id &&
!isCopilotSidebarOpen.value &&
isConversationSidebarOpen.value
);
const { t } = useI18n();
const store = useStore();
const closeConversationPanel = () => {
store.dispatch('uiState/set', { isConversationSidebarOpen: false });
const channelType = computed(() => props.currentChat?.meta?.channel || '');
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 currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
</script>
<template>
<div
v-if="showConversationSidebar"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[20rem] min-w-[20rem] flex flex-col"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
>
<div class="flex flex-1 flex-col overflow-auto">
<div
class="flex items-center justify-between gap-2 px-4 py-2 border-b border-n-weak h-12"
>
<span class="font-medium text-sm text-n-slate-12">
{{ $t('CONVERSATION.SIDEBAR.ACTIONS') }}
</span>
<div class="flex items-center">
<Button icon="i-lucide-x" ghost sm @click="closeConversationPanel" />
</div>
</div>
<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="flex flex-1 overflow-auto">
<ContactPanel
:conversation-id="props.currentChat.id"
:inbox-id="props.currentChat.inbox_id"
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-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div>
</div>
<div v-else class="hidden" />
</template>
@@ -38,6 +38,8 @@ import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Message,
@@ -45,8 +47,20 @@ export default {
ReplyBox,
Banner,
ConversationLabelSuggestion,
NextButton,
},
mixins: [inboxMixin],
props: {
isContactPanelOpen: {
type: Boolean,
default: false,
},
isInboxView: {
type: Boolean,
default: false,
},
},
emits: ['contactPanelToggle'],
setup() {
const isPopOutReplyBox = ref(false);
const { isEnterprise } = useConfig();
@@ -185,6 +199,12 @@ export default {
isATweet() {
return this.conversationType === 'tweet';
},
isRightOrLeftIcon() {
if (this.isContactPanelOpen) {
return 'arrow-chevron-right';
}
return 'arrow-chevron-left';
},
getLastSeenAt() {
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
@@ -420,6 +440,9 @@ export default {
relevantMessages
);
},
onToggleContactPanel() {
this.$emit('contactPanelToggle');
},
setScrollParams() {
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
@@ -503,7 +526,19 @@ export default {
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div class="flex justify-end">
<NextButton
faded
xs
slate
class="!rounded-r-none rtl:rotate-180 !rounded-2xl !fixed z-10"
:icon="
isContactPanelOpen ? 'i-ph-caret-right-fill' : 'i-ph-caret-left-fill'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
@click="onToggleContactPanel"
/>
</div>
<NextMessageList
v-if="showNextBubbles"
class="conversation-panel"
@@ -10,7 +10,6 @@ import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import Button from 'dashboard/components-next/button/Button.vue';
import { useEmitter } from 'dashboard/composables/emitter';
const props = defineProps({
conversationId: {
@@ -205,12 +204,6 @@ const createIssue = async () => {
}
};
useEmitter('ui:linear_ticket_create_data', data => {
formState.title = data.title;
formState.description = data.description;
formState.priority = data.priority;
});
onMounted(getTeams);
</script>
@@ -10,7 +10,6 @@ import { useTrack } from 'dashboard/composables';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import Button from 'dashboard/components-next/button/Button.vue';
import { useEmitter } from 'dashboard/composables/emitter';
const props = defineProps({
conversationId: {
@@ -95,10 +94,6 @@ watch(
}
);
useEmitter('ui:linear_ticket_create', () => {
shouldShowPopup.value = true;
});
onMounted(() => {
loadLinkedIssue();
});
@@ -3,6 +3,9 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
const { isImpersonating } = useImpersonation();
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -52,6 +55,7 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onPresenceUpdate = data => {
if (isImpersonating.value) return;
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
this.app.$store.dispatch('agents/updatePresence', data.users);
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
@@ -234,7 +234,8 @@
}
},
"SIDEBAR": {
"ACTIONS": "Actions"
"CONTACT": "Contact",
"COPILOT": "Copilot"
}
},
"EMAIL_TRANSCRIPT": {
@@ -379,6 +380,9 @@
"TWO": "{user} and {secondUser} are typing",
"MULTIPLE": "{user} and {count} others are typing"
},
"COPILOT": {
"TRY_THESE_PROMPTS": "Try these prompts"
},
"GALLERY_VIEW": {
"ERROR_DOWNLOADING": "Unable to download attachment. Please try again"
}
@@ -328,17 +328,14 @@
"HEADER_KNOW_MORE": "Know more",
"COPILOT": {
"TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
"PANEL_TITLE": "Get started with Copilot",
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilots here to speed things up.",
"SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this",
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant",
"SHOW_STEPS": "Show steps",
"SELECT_ASSISTANT": "Select Assistant",
"PROMPTS": {
"SUMMARIZE": {
"LABEL": "Summarize this conversation",
@@ -370,7 +367,6 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
@@ -413,6 +409,10 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
@@ -1,15 +1,6 @@
<script setup>
import {
defineAsyncComponent,
ref,
computed,
onMounted,
onUnmounted,
nextTick,
watch,
} from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
<script>
import { defineAsyncComponent, ref } from 'vue';
import { mapGetters } from 'vuex';
import NextSidebar from 'next/sidebar/Sidebar.vue';
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
@@ -18,8 +9,6 @@ import AccountSelector from 'dashboard/components/layout/sidebarComponents/Accou
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
import UpgradePage from 'dashboard/routes/dashboard/upgrade/UpgradePage.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -27,160 +16,178 @@ import { useAccount } from 'dashboard/composables/useAccount';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { emitter } from 'shared/helpers/mitt';
const CommandBar = defineAsyncComponent(
() => import('./commands/commandbar.vue')
);
const Sidebar = defineAsyncComponent(
() => import('../../components/layout/Sidebar.vue')
);
import { emitter } from 'shared/helpers/mitt';
const route = useRoute();
export default {
components: {
NextSidebar,
Sidebar,
CommandBar,
WootKeyShortcutModal,
AddAccountModal,
AccountSelector,
AddLabelModal,
NotificationPanel,
UpgradePage,
},
setup() {
const upgradePageRef = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
const upgradePageRef = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const { currentAccount } = useAccount();
return {
uiSettings,
updateUISettings,
accountId,
upgradePageRef,
};
},
data() {
return {
showAccountModal: false,
showCreateAccountModal: false,
showAddLabelModal: false,
showShortcutModal: false,
isNotificationPanel: false,
displayLayoutType: '',
hasBanner: '',
};
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentRoute() {
return ' ';
},
showUpgradePage() {
return this.upgradePageRef?.shouldShowUpgradePage;
},
bypassUpgradePage() {
return [
'billing_settings_index',
'settings_inbox_list',
'agent_list',
].includes(this.$route.name);
},
isSidebarOpen() {
const { show_secondary_sidebar: showSecondarySidebar } = this.uiSettings;
return showSecondarySidebar;
},
previouslyUsedDisplayType() {
const {
previously_used_conversation_display_type: conversationDisplayType,
} = this.uiSettings;
return conversationDisplayType;
},
previouslyUsedSidebarView() {
const { previously_used_sidebar_view: showSecondarySidebar } =
this.uiSettings;
return showSecondarySidebar;
},
showNextSidebar() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CHATWOOT_V4
);
},
},
watch: {
displayLayoutType() {
const { LAYOUT_TYPES } = wootConstants;
this.updateUISettings({
conversation_display_type:
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
? LAYOUT_TYPES.EXPANDED
: this.previouslyUsedDisplayType,
show_secondary_sidebar:
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
? false
: this.previouslyUsedSidebarView,
});
},
},
mounted() {
this.handleResize();
this.$nextTick(this.checkBanner);
window.addEventListener('resize', this.handleResize);
window.addEventListener('resize', this.checkBanner);
emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
unmounted() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('resize', this.checkBanner);
emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
const showAccountModal = ref(false);
const showCreateAccountModal = ref(false);
const showAddLabelModal = ref(false);
const showShortcutModal = ref(false);
const isNotificationPanel = ref(false);
const displayLayoutType = ref('');
const hasBanner = ref('');
methods: {
checkBanner() {
this.hasBanner =
document.getElementsByClassName('woot-banner').length > 0;
},
handleResize() {
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
let throttled = false;
const delay = 150;
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
if (throttled) {
return;
}
throttled = true;
const currentRoute = computed(() => ' ');
const showUpgradePage = computed(
() => upgradePageRef.value?.shouldShowUpgradePage
);
const bypassUpgradePage = computed(() =>
['billing_settings_index', 'settings_inbox_list', 'agent_list'].includes(
route.name
)
);
const isSidebarOpen = computed(() => {
const { show_secondary_sidebar: showSecondarySidebar } = uiSettings;
return showSecondarySidebar;
});
const previouslyUsedDisplayType = computed(() => {
const { previously_used_conversation_display_type: conversationDisplayType } =
uiSettings;
return conversationDisplayType;
});
const previouslyUsedSidebarView = computed(() => {
const { previously_used_sidebar_view: showSecondarySidebar } = uiSettings;
return showSecondarySidebar;
});
const showNextSidebar = computed(() =>
isFeatureEnabledonAccount.value(
currentAccount.value?.id,
FEATURE_FLAGS.CHATWOOT_V4
)
);
const checkBanner = () => {
hasBanner.value = document.getElementsByClassName('woot-banner').length > 0;
setTimeout(() => {
throttled = false;
if (window.innerWidth <= SMALL_SCREEN_BREAKPOINT) {
this.displayLayoutType = LAYOUT_TYPES.EXPANDED;
} else {
this.displayLayoutType = LAYOUT_TYPES.CONDENSED;
}
}, delay);
},
toggleSidebar() {
this.updateUISettings({
show_secondary_sidebar: !this.isSidebarOpen,
previously_used_sidebar_view: !this.isSidebarOpen,
});
},
openCreateAccountModal() {
this.showAccountModal = false;
this.showCreateAccountModal = true;
},
closeCreateAccountModal() {
this.showCreateAccountModal = false;
},
toggleAccountModal() {
this.showAccountModal = !this.showAccountModal;
},
toggleKeyShortcutModal() {
this.showShortcutModal = true;
},
closeKeyShortcutModal() {
this.showShortcutModal = false;
},
showAddLabelPopup() {
this.showAddLabelModal = true;
},
hideAddLabelPopup() {
this.showAddLabelModal = false;
},
openNotificationPanel() {
this.isNotificationPanel = true;
},
closeNotificationPanel() {
this.isNotificationPanel = false;
},
},
};
const handleResize = () => {
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
let throttled = false;
const delay = 150;
if (throttled) return;
throttled = true;
setTimeout(() => {
throttled = false;
displayLayoutType.value =
window.innerWidth <= SMALL_SCREEN_BREAKPOINT
? LAYOUT_TYPES.EXPANDED
: LAYOUT_TYPES.CONDENSED;
}, delay);
};
const toggleSidebar = () => {
updateUISettings({
show_secondary_sidebar: !isSidebarOpen.value,
previously_used_sidebar_view: !isSidebarOpen.value,
});
};
const openCreateAccountModal = () => {
showAccountModal.value = false;
showCreateAccountModal.value = true;
};
const closeCreateAccountModal = () => {
showCreateAccountModal.value = false;
};
const toggleAccountModal = () => {
showAccountModal.value = !showAccountModal.value;
};
const toggleKeyShortcutModal = () => {
showShortcutModal.value = true;
};
const closeKeyShortcutModal = () => {
showShortcutModal.value = false;
};
const showAddLabelPopup = () => {
showAddLabelModal.value = true;
};
const hideAddLabelPopup = () => {
showAddLabelModal.value = false;
};
const openNotificationPanel = () => {
isNotificationPanel.value = true;
};
const closeNotificationPanel = () => {
isNotificationPanel.value = false;
};
watch(displayLayoutType, () => {
const { LAYOUT_TYPES } = wootConstants;
updateUISettings({
conversation_display_type:
displayLayoutType.value === LAYOUT_TYPES.EXPANDED
? LAYOUT_TYPES.EXPANDED
: previouslyUsedDisplayType.value,
show_secondary_sidebar:
displayLayoutType.value === LAYOUT_TYPES.EXPANDED
? false
: previouslyUsedSidebarView.value,
});
});
onMounted(() => {
handleResize();
nextTick(checkBanner);
window.addEventListener('resize', handleResize);
window.addEventListener('resize', checkBanner);
emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, toggleSidebar);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('resize', checkBanner);
emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, toggleSidebar);
});
</script>
<template>
@@ -211,8 +218,6 @@ onUnmounted(() => {
/>
<template v-if="!showUpgradePage">
<router-view />
<CopilotLauncher />
<CopilotContainer />
<CommandBar />
<NotificationPanel
v-if="isNotificationPanel"
@@ -29,6 +29,10 @@ const props = defineProps({
type: Number,
default: undefined,
},
onToggle: {
type: Function,
default: () => {},
},
});
const {
@@ -85,6 +89,8 @@ watch(conversationId, (newConversationId, prevConversationId) => {
watch(contactId, getContactDetails);
const onPanelToggle = props.onToggle;
const onDragEnd = () => {
dragging.value = false;
updateUISettings({
@@ -101,7 +107,11 @@ onMounted(() => {
<template>
<div class="w-full">
<ContactInfo :contact="contact" :channel-type="channelType" />
<ContactInfo
:contact="contact"
:channel-type="channelType"
@toggle-panel="onPanelToggle"
/>
<div class="list-group pb-8">
<Draggable
:list="conversationSidebarItems"
@@ -10,17 +10,13 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import { emitter } from 'shared/helpers/mitt';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
import PanelButtons from './PanelButtons.vue';
export default {
components: {
ChatList,
ConversationBox,
ConversationSidebar,
PopOverSearch,
CmdBarConversationSnooze,
PanelButtons,
},
beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
@@ -91,6 +87,14 @@ export default {
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
showPopOverSearch() {
return !this.isFeatureEnabledonAccount(
this.accountId,
@@ -185,7 +189,11 @@ export default {
this.$store.dispatch('clearSelectedState');
}
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
onSearch() {
this.showSearchModal = true;
},
@@ -217,11 +225,10 @@ export default {
<ConversationBox
v-if="showMessageView"
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="isOnExpandedLayout"
>
<PanelButtons v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar :current-chat="currentChat" />
@contact-panel-toggle="onToggleContactPanel"
/>
<CmdBarConversationSnooze />
</section>
</template>
@@ -1,42 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import { useStore } from 'vuex';
const store = useStore();
const handleConversationSidebarToggle = () => {
store.dispatch('uiState/set', {
isConversationSidebarOpen: true,
isCopilotSidebarOpen: false,
});
};
const handleCopilotSidebarToggle = () => {
store.dispatch('uiState/set', {
isCopilotSidebarOpen: true,
isConversationSidebarOpen: false,
});
};
</script>
<template>
<div
class="flex flex-col justify-center items-center absolute top-24 right-1 bg-n-alpha-2 border border-n-weak rounded-lg bg-n-background gap-2 py-0.5 px-0.5"
>
<Button
ghost
slate
sm
class="!text-sm"
icon="i-ph-user-bold"
@click="handleConversationSidebarToggle"
/>
<Button
ghost
class="!text-sm !text-n-iris-11"
sm
icon="i-woot-captain"
@click="handleCopilotSidebarToggle"
/>
</div>
</template>
-2
View File
@@ -51,7 +51,6 @@ import captainDocuments from './captain/document';
import captainResponses from './captain/response';
import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import uiState from './modules/uiState';
const plugins = [];
export default createStore({
@@ -107,7 +106,6 @@ export default createStore({
captainResponses,
captainInboxes,
captainBulkActions,
uiState,
},
plugins,
});
@@ -1,37 +0,0 @@
const initialState = {
isCopilotSidebarOpen: false,
isConversationSidebarOpen: true,
};
const types = {
SET_UI_STATE: 'SET_UI_STATE',
};
const mutations = {
[types.SET_UI_STATE](state, { key, value }) {
state[key] = value;
},
};
const getters = {
getUIState: state => key => state[key],
};
const actions = {
toggle({ commit, state }, key) {
commit(types.SET_UI_STATE, { key, value: !state[key] });
},
set({ commit }, payload) {
Object.entries(payload).forEach(([key, value]) => {
commit(types.SET_UI_STATE, { key, value });
});
},
};
export default {
namespaced: true,
state: initialState,
getters,
mutations,
actions,
};
+3 -3
View File
@@ -1,9 +1,9 @@
import Rails from '@rails/ujs';
import Turbolinks from 'turbolinks';
import { Turbo } from '@hotwired/turbo-rails';
import '../portal/application.scss';
import { InitializationHelpers } from '../portal/portalHelpers';
Rails.start();
Turbolinks.start();
Turbo.start();
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
document.addEventListener('turbo:load', InitializationHelpers.onLoad);
@@ -90,18 +90,29 @@ export default {
this.isLoading = false;
}
},
handleSubmit() {
if (this.searchTerm.trim()) {
const url = `/hc/${this.portalSlug}/${this.localeCode}/search?query=${encodeURIComponent(this.searchTerm)}`;
window.location.href = url;
}
},
},
};
</script>
<template>
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
<PublicSearchInput
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<form @submit.prevent="handleSubmit">
<PublicSearchInput
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<button type="submit" class="sr-only">
{{ searchTranslations.submit || 'Search' }}
</button>
</form>
<div
v-if="shouldShowSearchBox"
class="absolute w-full top-14"
@@ -110,7 +110,7 @@ export default {
<p class="py-1 px-3" :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbolinks="false"
data-turbo="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
:class="elementTextStyles(element)"
>
+2 -2
View File
@@ -15,7 +15,7 @@ export const getHeadingsfromTheArticle = () => {
const slug = slugifyWithCounter(element.innerText);
element.id = slug;
element.className = 'scroll-mt-24 heading';
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbolinks="false">#</a>`;
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbo="false">#</a>`;
rows.push({
slug,
title: element.innerText,
@@ -144,7 +144,7 @@ export const InitializationHelpers = {
const a = document.createElement('a');
a.href = window.location.hash;
a['data-turbolinks'] = false;
a['data-turbo'] = false;
a.click();
}
},
+2
View File
@@ -204,3 +204,5 @@ class ActionCableListener < BaseListener
::ActionCableBroadcastJob.perform_later(tokens.uniq, event_name, payload)
end
end
ActionCableListener.prepend_mod_with('ActionCableListener')
+2 -2
View File
@@ -18,7 +18,7 @@ By default, it renders:
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name= "turbolinks-cache-control" content= "no-cache">
<meta name= "turbo-cache-control" content= "no-cache">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<%= vite_client_tag %>
@@ -39,7 +39,7 @@ By default, it renders:
<% else %>
<title><%= @portal.page_title%></title>
<% end %>
<% if @portal.logo.present? %>
<link rel="icon" href="<%= url_for(@portal.logo) %>">
<% end %>
@@ -0,0 +1,184 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
<% end %>
<% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<div class="flex flex-row items-center gap-px mb-6">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer hover:underline leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<!-- Search form for the results page -->
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
<input type="hidden" name="locale" value="<%= params[:locale] %>">
<input type="text"
name="query"
value="<%= @query %>"
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
data-search-input
autofocus
class="w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<button type="submit" class="sr-only">
<%= I18n.t('public_portal.search.submit') %>
</button>
</form>
</div>
</div>
</div>
<% else %>
<div class="max-w-5xl mx-auto space-y-4 w-full px-4 md:px-8 py-4">
<div class="flex items-center flex-row">
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:underline hover:cursor-pointer leading-8 font-semibold"
href="<%= generate_home_link(@portal.slug, params[:locale], @theme_from_params, @is_plain_layout_enabled) %>">
<%= I18n.t('public_portal.common.home') %>
</a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300">
<%= render partial: 'icons/chevron-right' %>
</span>
<span class="text-sm font-semibold text-slate-800 dark:text-slate-100">
<%= I18n.t('public_portal.search.results') %>
</span>
</div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= I18n.t('public_portal.search.results_for', query: @query) %>
</h1>
<!-- Search form for the results page -->
<form class="w-full my-4" action="<%= request.path %>" method="GET" data-search-form>
<input type="hidden" name="locale" value="<%= params[:locale] %>">
<input type="text"
name="query"
value="<%= @query %>"
placeholder="<%= I18n.t('public_portal.search.search_placeholder') %>"
data-search-input
autofocus
class="w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<button type="submit" class="sr-only">
<%= I18n.t('public_portal.search.submit') %>
</button>
</form>
</div>
<% end %>
<script>
document.addEventListener('turbo:load', function() {
const searchInputs = document.querySelectorAll('[data-search-input]');
searchInputs.forEach(function(input) {
let debounceTimer;
// Move cursor to end of input text after autofocus
if (input.value.length > 0) {
setTimeout(function() {
input.setSelectionRange(input.value.length, input.value.length);
}, 0);
}
input.addEventListener('input', function() {
const form = input.closest('[data-search-form]');
const query = input.value.trim();
// Clear existing timer
clearTimeout(debounceTimer);
// Only search if there's a query and it's different from current
if (query.length > 0) {
debounceTimer = setTimeout(function() {
// Check if the query has actually changed
const currentQuery = new URLSearchParams(window.location.search).get('query') || '';
if (query !== currentQuery) {
// Use Turbo.visit for SPA-like navigation
const formData = new FormData(form);
const searchParams = new URLSearchParams(formData);
const url = form.action + '?' + searchParams.toString();
Turbo.visit(url);
}
}, 500); // 500ms debounce
}
});
// Handle manual form submission
input.closest('[data-search-form]').addEventListener('submit', function(e) {
clearTimeout(debounceTimer);
});
});
});
</script>
<div class="max-w-5xl w-full mx-auto px-4 md:px-8 py-6 flex flex-col items-center justify-center flex-grow">
<div class="w-full flex flex-col gap-6 flex-grow">
<% if @articles.empty? %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl py-6">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.search.no_results', query: @query) %></p>
</div>
<% else %>
<p class="text-sm text-slate-600 dark:text-slate-400">
<%= I18n.t('public_portal.search.found_results', count: @articles.size) %>
</p>
<% @articles.each do |article| %>
<div class="border border-solid border-slate-100 dark:border-slate-800 rounded-lg">
<a class="p-4 text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div class="flex flex-col gap-5">
<div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 tracking-[0.28px] dark:text-slate-50 font-semibold">
<%= article.title %>
</h3>
<p class="text-base font-normal text-slate-600 dark:text-slate-200 line-clamp-2 break-all">
<%= render_category_content(article.content) %>
</p>
</div>
<div class="flex flex-row items-center gap-2">
<% if article.category.present? %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= article.category.name %>
</span>
<span class="text-slate-600 dark:text-slate-400"></span>
<% end %>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium">
<%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %>
</span>
</div>
</div>
</a>
</div>
<% end %>
<!-- Pagination -->
<% if @articles.respond_to?(:total_pages) && @articles.total_pages > 1 %>
<div class="flex justify-center mt-6">
<nav class="inline-flex">
<% if @articles.prev_page %>
<a href="<%= url_for(page: @articles.prev_page) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-l-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.previous') %>
</a>
<% end %>
<% if @articles.next_page %>
<a href="<%= url_for(page: @articles.next_page) %>" class="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-r-md text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800">
<%= I18n.t('public_portal.common.next') %>
</a>
<% end %>
</nav>
</div>
<% end %>
<% end %>
</div>
</div>
+7
View File
@@ -254,6 +254,13 @@ en:
empty_placeholder: No results found.
loading_placeholder: Searching...
results_title: Search results
results: Search Results
results_for: "Search Results for '%{query}'"
no_results: "No results found for '%{query}'"
found_results:
one: Found 1 result
other: "Found %{count} results"
submit: Search
toc_header: 'On this page'
hero:
sub_title: Search for the articles here or browse the categories below.
+7
View File
@@ -240,6 +240,13 @@ es:
empty_placeholder: No se encontraron resultados.
loading_placeholder: Buscando...
results_title: Buscar resultados
results: Resultados de búsqueda
results_for: "Resultados de búsqueda para '%{query}'"
no_results: "No se encontraron resultados para '%{query}'"
found_results:
one: Se encontró 1 resultado
other: "Se encontraron %{count} resultados"
submit: Buscar
toc_header: 'En esta página'
hero:
sub_title: Busque aquí los artículos o busque las categorías de abajo.
+4 -2
View File
@@ -60,8 +60,8 @@ Rails.application.routes.draw do
end
resources :assistant_responses
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index] do
resources :copilot_messages, only: [:index]
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :documents, only: [:index, :show, :create, :destroy]
end
@@ -127,6 +127,7 @@ Rails.application.routes.draw do
post :unread
post :custom_attributes
get :attachments
post :copilot
get :inbox_assistant
end
end
@@ -441,6 +442,7 @@ Rails.application.routes.draw do
get 'hc/:slug', to: 'public/api/v1/portals#show'
get 'hc/:slug/sitemap.xml', to: 'public/api/v1/portals#sitemap'
get 'hc/:slug/:locale', to: 'public/api/v1/portals#show'
get 'hc/:slug/:locale/search', to: 'public/api/v1/portals/search#index', as: :portal_search
get 'hc/:slug/:locale/articles', to: 'public/api/v1/portals/articles#index'
get 'hc/:slug/:locale/categories', to: 'public/api/v1/portals/categories#index'
get 'hc/:slug/:locale/categories/:category_slug', to: 'public/api/v1/portals/categories#show'
@@ -0,0 +1,8 @@
class RemoveUuidFromCopilotThreads < ActiveRecord::Migration[7.1]
def change
remove_column :copilot_threads, :uuid, :string
add_column :copilot_threads, :assistant_id, :integer
add_index :copilot_threads, :assistant_id
end
end
@@ -0,0 +1,5 @@
class RemoveUserIdFromCopilotMessages < ActiveRecord::Migration[7.1]
def change
remove_reference :copilot_messages, :user, index: true
end
end
@@ -0,0 +1,11 @@
class ChangeMessageTypeToIntegerInCopilotMessages < ActiveRecord::Migration[7.1]
def up
remove_column :copilot_messages, :message_type
add_column :copilot_messages, :message_type, :integer, default: 0
end
def down
remove_column :copilot_messages, :message_type
add_column :copilot_messages, :message_type, :string, default: 'user'
end
end
+4 -6
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
ActiveRecord::Schema[7.1].define(version: 2025_05_23_031839) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -577,27 +577,25 @@ ActiveRecord::Schema[7.0].define(version: 2025_05_14_045638) do
create_table "copilot_messages", force: :cascade do |t|
t.bigint "copilot_thread_id", null: false
t.bigint "user_id", null: false
t.bigint "account_id", null: false
t.string "message_type", null: false
t.jsonb "message", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "message_type", default: 0
t.index ["account_id"], name: "index_copilot_messages_on_account_id"
t.index ["copilot_thread_id"], name: "index_copilot_messages_on_copilot_thread_id"
t.index ["user_id"], name: "index_copilot_messages_on_user_id"
end
create_table "copilot_threads", force: :cascade do |t|
t.string "title", null: false
t.bigint "user_id", null: false
t.bigint "account_id", null: false
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "assistant_id"
t.index ["account_id"], name: "index_copilot_threads_on_account_id"
t.index ["assistant_id"], name: "index_copilot_threads_on_assistant_id"
t.index ["user_id"], name: "index_copilot_threads_on_user_id"
t.index ["uuid"], name: "index_copilot_threads_on_uuid", unique: true
end
create_table "csat_survey_responses", force: :cascade do |t|
@@ -47,7 +47,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
config: [
:product_name, :feature_faq, :feature_memory,
:welcome_message, :handoff_message, :resolution_message,
:instructions
:instructions, :temperature
])
end
@@ -1,21 +1,28 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_copilot_thread
def index
@copilot_messages = @copilot_thread
.copilot_messages
.includes(:copilot_thread)
.order(created_at: :asc)
.page(permitted_params[:page] || 1)
.per(1000)
end
def create
@copilot_message = @copilot_thread.copilot_messages.create!(
message: params[:message],
message_type: :user
)
end
private
def set_copilot_thread
@copilot_thread = Current.account.copilot_threads.find_by!(
uuid: params[:copilot_thread_id], user_id: Current.user.id
id: params[:copilot_thread_id],
user: Current.user
)
end
@@ -1,18 +1,41 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :ensure_message, only: :create
def index
@copilot_threads = Current.account.copilot_threads
.where(user_id: Current.user.id)
.includes(:user)
.includes(:user, :assistant)
.order(created_at: :desc)
.page(permitted_params[:page] || 1)
.per(5)
end
def create
ActiveRecord::Base.transaction do
@copilot_thread = Current.account.copilot_threads.create!(
title: copilot_thread_params[:message],
user: Current.user,
assistant: assistant
)
@copilot_thread.copilot_messages.create!(message_type: :user, message: copilot_thread_params[:message])
end
end
private
def ensure_message
return render_could_not_create_error('Message is required') if copilot_thread_params[:message].blank?
end
def assistant
Current.account.captain_assistants.find(copilot_thread_params[:assistant_id])
end
def copilot_thread_params
params.permit(:message, :assistant_id)
end
def permitted_params
params.permit(:page)
end
@@ -4,6 +4,29 @@ module Enterprise::Api::V1::Accounts::ConversationsController
before_action :set_assistant, only: [:copilot]
end
def copilot
# First try to get the user's preferred assistant from UI settings or from the request
assistant_id = copilot_params[:assistant_id] || current_user.ui_settings&.dig('preferred_captain_assistant_id')
# Find the assistant either by ID or from inbox
assistant = if assistant_id.present?
Captain::Assistant.find_by(id: assistant_id, account_id: Current.account.id)
else
@conversation.inbox.captain_assistant
end
return render json: { message: I18n.t('captain.copilot_error') } unless assistant
response = Captain::Copilot::ChatService.new(
assistant,
previous_messages: copilot_params[:previous_messages],
conversation_history: @conversation.to_llm_text,
language: @conversation.account.locale_english_name
).generate_response(copilot_params[:message])
render json: { message: response['response'] }
end
def inbox_assistant
assistant = @conversation.inbox.captain_assistant
+8 -41
View File
@@ -7,18 +7,16 @@ module Captain::ChatHelper
model: @model,
messages: @messages,
tools: @tool_registry&.registered_tools || [],
response_format: { type: 'json_object' }
response_format: { type: 'json_object' },
temperature: @assistant&.config&.[]('temperature').to_f || 1
}
)
handle_response(response)
rescue StandardError => e
Rails.logger.error { "[CAPTAIN][ChatCompletion] #{e}" }
raise e
end
def handle_response(response)
Rails.logger.info { "[CAPTAIN][ChatCompletion] #{response}" }
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
@@ -39,13 +37,11 @@ module Captain::ChatHelper
arguments = JSON.parse(tool_call['function']['arguments'])
function_name = tool_call['function']['name']
tool_call_id = tool_call['id']
function_name = tool_call['function']['name']
arguments = JSON.parse(tool_call['function']['arguments'])
if @tool_registry.respond_to?(function_name)
execute_tool(function_name, arguments, tool_call_id)
else
process_invalid_tool_call(tool_call_id, function_name)
process_invalid_tool_call(tool_call_id)
end
end
@@ -61,6 +57,10 @@ module Captain::ChatHelper
}
end
def process_invalid_tool_call(tool_call_id)
append_tool_response('Tool not available', tool_call_id)
end
def append_tool_response(content, tool_call_id)
@messages << {
role: 'tool',
@@ -68,37 +68,4 @@ module Captain::ChatHelper
content: content
}
end
def publish_to_stream(response)
@stream_writer&.call(response)
end
def execute_tool_call(tool_call_id, function_name, arguments)
publish_to_stream(
{
response: { response: "Processing tool call #{function_name}" },
type: 'tool_calls_start'
}
)
result = @tool_registry.send(function_name, arguments.merge('user_id' => @user_id))
append_tool_response(result, tool_call_id)
publish_to_stream(
{
response: { response: "Received tool response #{function_name}" },
type: 'tool_response',
tool: function_name
}
)
end
def process_invalid_tool_call(tool_call_id, function_name)
append_tool_response('Tool not implemented', tool_call_id)
publish_to_stream(
{
response: { response: 'Tool not implemented' },
type: 'tool_error',
tool: function_name
}
)
end
end
@@ -1,62 +0,0 @@
class Captain::ProcessCopilotMessageJob < ApplicationJob
queue_as :default
def perform(assistant_id:, message:, options: {})
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Starting job for assistant_id=#{assistant_id}")
ensure_assistant(assistant_id)
ensure_user(options[:user_id])
process_message(message, options)
end
private
def ensure_assistant(assistant_id)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Finding assistant with id=#{assistant_id}")
@assistant = Captain::Assistant.find(assistant_id)
@account = @assistant.account
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Found assistant for account_id=#{@account.id}")
end
def ensure_user(user_id)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Finding user with id=#{user_id}")
@user = @account.users.find(user_id)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Found user #{@user.name}")
end
def process_message(message, options)
return unless @assistant
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Processing message with conversation_id=#{options[:conversation_id]}")
conversation = find_conversation(options[:conversation_id])
generate_chat_response(message, conversation, options[:previous_history])
end
def find_conversation(conversation_id)
return unless conversation_id
@account.conversations.find_by(display_id: conversation_id)
end
def generate_chat_response(message, conversation, previous_history)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Generating chat response for conversation_id=#{conversation&.display_id}, user_id=#{@user.id}")
Captain::Copilot::ChatService.new(
@assistant,
previous_messages: previous_history || [],
stream_writer: ->(data) { broadcast_response(data) },
additional_info: {
language: @account.locale_english_name,
conversation_id: conversation&.display_id,
contact_id: conversation&.contact_id,
user_id: @user.id
}
).generate_response(message)
end
def broadcast_response(data)
Rails.logger.info("[Captain::ProcessCopilotMessageJob] Broadcasting response to account_id=#{@account.id} user_id=#{@user.id}")
ActionCable.server.broadcast(
"copilot_#{@account.id}_#{@user.id}",
{ event: 'copilot.response', data: data }
)
end
end
@@ -1,7 +1,10 @@
class CaptainListener < BaseListener
include ::Events::Types
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
assistant = conversation.inbox.captain_assistant
return unless conversation.inbox.captain_active?
Captain::Llm::ContactNotesService.new(assistant, conversation).generate_and_update_notes if assistant.config['feature_memory'].present?
@@ -0,0 +1,11 @@
module Enterprise::ActionCableListener
include Events::Types
def copilot_message_created(event)
copilot_message = event.data[:copilot_message]
copilot_thread = copilot_message.copilot_thread
account = copilot_thread.account
user = copilot_thread.user
broadcast(account, [user.pubsub_token], COPILOT_MESSAGE_CREATED, copilot_message.push_event_data)
end
end
@@ -29,6 +29,7 @@ class Captain::Assistant < ApplicationRecord
has_many :inboxes,
through: :captain_inboxes
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
validates :name, presence: true
validates :description, presence: true
+28 -5
View File
@@ -4,24 +4,47 @@
#
# id :bigint not null, primary key
# message :jsonb not null
# message_type :string not null
# message_type :integer default("user")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# copilot_thread_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_copilot_messages_on_account_id (account_id)
# index_copilot_messages_on_copilot_thread_id (copilot_thread_id)
# index_copilot_messages_on_user_id (user_id)
#
class CopilotMessage < ApplicationRecord
belongs_to :copilot_thread
belongs_to :user
belongs_to :account
validates :message_type, presence: true, inclusion: { in: %w[user assistant assistant_thinking] }
before_validation :ensure_account
enum message_type: { user: 0, assistant: 1, assistant_thinking: 2 }
validates :message_type, presence: true, inclusion: { in: message_types.keys }
validates :message, presence: true
after_create_commit :broadcast_message
def push_event_data
{
id: id,
message: message,
message_type: message_type,
created_at: created_at.to_i,
copilot_thread: copilot_thread.push_event_data
}
end
private
def ensure_account
self.account = copilot_thread.account
end
def broadcast_message
Rails.configuration.dispatcher.dispatch(COPILOT_MESSAGE_CREATED, Time.zone.now, copilot_message: self)
end
end
+34 -12
View File
@@ -2,25 +2,47 @@
#
# Table name: copilot_threads
#
# id :bigint not null, primary key
# title :string not null
# uuid :uuid not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# user_id :bigint not null
# id :bigint not null, primary key
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :integer
# user_id :bigint not null
#
# Indexes
#
# index_copilot_threads_on_account_id (account_id)
# index_copilot_threads_on_user_id (user_id)
# index_copilot_threads_on_uuid (uuid) UNIQUE
# index_copilot_threads_on_account_id (account_id)
# index_copilot_threads_on_assistant_id (assistant_id)
# index_copilot_threads_on_user_id (user_id)
#
class CopilotThread < ApplicationRecord
belongs_to :user
belongs_to :account
has_many :copilot_messages, dependent: :destroy
belongs_to :assistant, class_name: 'Captain::Assistant'
has_many :copilot_messages, dependent: :destroy_async
validates :title, presence: true
validates :uuid, presence: true, uniqueness: true
def push_event_data
{
id: id,
title: title,
created_at: created_at.to_i,
user: user.push_event_data,
account_id: account_id
}
end
def previous_history
copilot_messages
.where(message_type: %w[user assistant])
.order(created_at: :asc)
.map do |copilot_message|
{
content: copilot_message.message,
role: copilot_message.message_type
}
end
end
end
@@ -1,50 +1,27 @@
require 'openai'
class Captain::Copilot::ChatService < Llm::BaseOpenAiService
include Captain::ChatHelper
attr_reader :assistant, :language
def initialize(assistant, config = {})
def initialize(assistant, config)
super()
@assistant = assistant
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@stream_writer = config[:stream_writer]
setup_additional_info(config)
@conversation_history = config[:conversation_history]
@previous_messages = config[:previous_messages] || []
@language = config[:language] || 'english'
register_tools
@messages = build_initial_messages(config)
end
def setup_additional_info(config)
additional_info = config[:additional_info] || {}
@language = additional_info[:language] || 'english'
@conversation_id = additional_info[:conversation_id]
@contact_id = additional_info[:contact_id]
@user_id = additional_info[:user_id]
Rails.logger.info("[Captain::Copilot::ChatService::User] user_id: #{@user_id}")
end
def build_initial_messages(config)
Rails.logger.info("[CAPTAIN][CopilotChatService] Building initial messages for conversation_id=#{@conversation_id}")
messages = [system_message]
messages += (config[:previous_messages] || [])
Rails.logger.info("[CAPTAIN][CopilotChatService] Added #{config[:previous_messages]&.length || 0} previous messages")
messages << current_viewing_history if @conversation_id
Rails.logger.info("[CAPTAIN][CopilotChatService] Total messages built: #{messages.length}")
messages
@messages = [system_message, conversation_history_context] + @previous_messages
@response = ''
end
def generate_response(input)
@messages << { role: 'user', content: input } if input.present?
Rails.logger.info("[CAPTAIN][CopilotChatService] Initial Prompt: #{@messages}")
response = request_chat_completion
Rails.logger.info("[CAPTAIN][CopilotChatService] Incrementing response usage for #{@assistant.account.id}")
@assistant.account.increment_response_usage
publish_to_stream(
{
response: response,
type: 'final_response'
}
)
response
end
@@ -53,35 +30,22 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
def register_tools
@tool_registry = Captain::ToolRegistryService.new(@assistant)
@tool_registry.register_tool(Captain::Tools::SearchDocumentationService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetArticleService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetContactService)
@tool_registry.register_tool(Captain::Tools::Copilot::GetConversationService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchArticlesService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchContactsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchConversationsService)
@tool_registry.register_tool(Captain::Tools::Copilot::SearchLinearIssuesService)
end
def system_message
Rails.logger.info("[CAPTAIN][CopilotChatService] Generating system message for product=#{@assistant.config['product_name']} language=#{@language}")
{
role: 'system',
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language, @assistant.account_id)
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'], @language)
}
end
def current_viewing_history
Rails.logger.info("[CAPTAIN][CopilotChatService] Fetching viewing history for conversation_id=#{@conversation_id}")
return unless @conversation_id
def conversation_history_context
{
role: 'system',
content: <<~HISTORY.strip
You are currently viewing the conversation with the user with the following details:
Conversation ID: #{@conversation_id}
Contact ID: #{@contact_id}
Account ID: #{@assistant.account.id}
HISTORY
content: "
Message History with the user is below:
#{@conversation_history}
"
}
end
end
@@ -56,15 +56,14 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
def copilot_response_generator(product_name, language, account_id)
def copilot_response_generator(product_name, language)
<<~SYSTEM_PROMPT_MESSAGE
[Identity]
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
You should only provide information related to #{product_name} and must not address queries about other products or external events.
You are an assistant for the account with ID: #{account_id}
[Context]
Identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
You will be provided with the message history between the support agent and the customer. Use this context to understand the conversation flow, identify unresolved queries, and ensure responses are relevant and consistent with previous interactions. Always maintain a coherent and professional tone throughout the conversation.
[Response Guidelines]
- Use natural, polite, and conversational language that is clear and easy to follow. Keep sentences short and use simple words.
@@ -91,9 +90,6 @@ class Captain::Llm::SystemPromptsService
7. Write the response in multiple paragraphs and in markdown format.
8. DO NOT use headings in Markdown
9. Cite the sources if you used a tool to find the response.
10. Do not use your own training data or assumptions to answer queries. Base responses strictly on the provided information.
11. Always provide a reasoning for the response.
12. Always double the check the information with tools unless you are very sure of the information.
```json
{
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: @copilot_message
@@ -1,8 +1,5 @@
json.payload do
json.array! @copilot_messages do |message|
json.id message.id
json.message message.message
json.message_type message.message_type
json.created_at message.created_at.to_i
json.partial! 'api/v1/models/captain/copilot_message', formats: [:json], resource: message
end
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/copilot_thread', formats: [:json], resource: @copilot_thread
@@ -1,12 +1,5 @@
json.payload do
json.array! @copilot_threads do |thread|
json.id thread.id
json.title thread.title
json.uuid thread.uuid
json.created_at thread.created_at.to_i
json.user do
json.id thread.user.id
json.name thread.user.name
end
json.partial! 'api/v1/models/captain/copilot_thread', resource: thread
end
end
@@ -0,0 +1,6 @@
json.id resource.id
json.message resource.message
json.message_type resource.message_type
json.created_at resource.created_at.to_i
json.copilot_thread resource.copilot_thread.push_event_data
json.account_id resource.account_id
@@ -0,0 +1,6 @@
json.id resource.id
json.title resource.title
json.created_at resource.created_at.to_i
json.user resource.user.push_event_data
json.assistant resource.assistant.push_event_data
json.account_id resource.account_id
+3
View File
@@ -54,4 +54,7 @@ module Events::Types
# agent events
AGENT_ADDED = 'agent.added'
AGENT_REMOVED = 'agent.removed'
# copilot events
COPILOT_MESSAGE_CREATED = 'copilot.message.created'
end
+1 -1
View File
@@ -39,6 +39,7 @@
"@formkit/vue": "^1.6.7",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@highlightjs/vue-plugin": "^2.1.0",
"@hotwired/turbo-rails": "^8.0.13",
"@iconify-json/material-symbols": "^1.2.10",
"@june-so/analytics-next": "^2.0.0",
"@lk77/vue3-color": "^3.0.6",
@@ -84,7 +85,6 @@
"snakecase-keys": "^8.0.1",
"timezone-phone-codes": "^0.0.2",
"tinykeys": "^3.0.0",
"turbolinks": "^5.2.0",
"urlpattern-polyfill": "^10.0.0",
"video.js": "7.18.1",
"videojs-record": "4.5.0",
+22 -8
View File
@@ -37,6 +37,9 @@ importers:
'@highlightjs/vue-plugin':
specifier: ^2.1.0
version: 2.1.0(highlight.js@11.10.0)(vue@3.5.12(typescript@5.6.2))
'@hotwired/turbo-rails':
specifier: ^8.0.13
version: 8.0.13
'@iconify-json/material-symbols':
specifier: ^1.2.10
version: 1.2.10
@@ -172,9 +175,6 @@ importers:
tinykeys:
specifier: ^3.0.0
version: 3.0.0
turbolinks:
specifier: ^5.2.0
version: 5.2.0
urlpattern-polyfill:
specifier: ^10.0.0
version: 10.0.0
@@ -865,6 +865,13 @@ packages:
'@histoire/vendors@0.17.17':
resolution: {integrity: sha512-QZvmffdoJlLuYftPIkOU5Q2FPAdG2JjMuQ5jF7NmEl0n1XnmbMqtRkdYTZ4eF6CO1KLZ0Zyf6gBQvoT1uWNcjA==}
'@hotwired/turbo-rails@8.0.13':
resolution: {integrity: sha512-6SCnnOSzhtaJ0pNkAjncZxjtKsK3sP/vPEkCnTXBXSHkr+vF7DTZkOlwjhms1DbbQNTsjCsBoKvzSMbh/omSCQ==}
'@hotwired/turbo@8.0.13':
resolution: {integrity: sha512-M7qXUqcGab6G5PKOiwhgbByTtrPgKPFCTMNQ52QhzUEXEqmp0/ApEguUesh/FPiUjrmFec+3lq98KsWnYY2C7g==}
engines: {node: '>= 14'}
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@@ -1036,6 +1043,9 @@ packages:
'@rails/actioncable@6.1.3':
resolution: {integrity: sha512-m02524MR9cTnUNfGz39Lkx9jVvuL0tle4O7YgvouJ7H83FILxzG1nQ5jw8pAjLAr9XQGu+P1sY4SKE3zyhCNjw==}
'@rails/actioncable@7.2.201':
resolution: {integrity: sha512-wsTdWoZ5EfG5k3t7ORdyQF0ZmDEgN4aVPCanHAiNEwCROqibSZMXXmCbH7IDJUVri4FOeAVwwbPINI7HVHPKBw==}
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
@@ -4710,9 +4720,6 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
turbolinks@5.2.0:
resolution: {integrity: sha512-pMiez3tyBo6uRHFNNZoYMmrES/IaGgMhQQM+VFF36keryjb5ms0XkVpmKHkfW/4Vy96qiGW3K9bz0tF5sK9bBw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -5723,6 +5730,13 @@ snapshots:
'@histoire/vendors@0.17.17': {}
'@hotwired/turbo-rails@8.0.13':
dependencies:
'@hotwired/turbo': 8.0.13
'@rails/actioncable': 7.2.201
'@hotwired/turbo@8.0.13': {}
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -5942,6 +5956,8 @@ snapshots:
'@rails/actioncable@6.1.3': {}
'@rails/actioncable@7.2.201': {}
'@rails/ujs@7.1.400': {}
'@rollup/rollup-android-arm-eabi@4.40.2':
@@ -10200,8 +10216,6 @@ snapshots:
tslib@2.8.1: {}
turbolinks@5.2.0: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -4,12 +4,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
let(:account) { create(:account) }
let(:user) { create(:user, account: account, role: :administrator) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'returns all messages' do
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages",
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
headers: user.create_new_auth_token,
as: :json
@@ -20,9 +20,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end
end
context 'when thread uuid is invalid' do
context 'when thread id is invalid' do
it 'returns not found error' do
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages",
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
headers: user.create_new_auth_token,
as: :json
@@ -30,4 +30,49 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'creates a new message' do
message_content = { 'content' => 'This is a test message' }
expect do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
params: { message: message_content },
headers: user.create_new_auth_token,
as: :json
end.to change(CopilotMessage, :count).by(1)
expect(response).to have_http_status(:success)
expect(CopilotMessage.last.message).to eq(message_content)
expect(CopilotMessage.last.message_type).to eq('user')
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
end
end
context 'when thread does not exist' do
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
params: { message: { text: 'Test message' } },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'when thread belongs to another user' do
let(:another_user) { create(:user, account: account) }
let(:another_thread) { create(:captain_copilot_thread, account: account, user: another_user) }
it 'returns not found error' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{another_thread.id}/copilot_messages",
params: { message: { text: 'Test message' } },
headers: user.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
end
end
@@ -18,7 +18,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
end
end
context 'when it is an agent' do
context 'when it is an authenticated user' do
it 'fetches copilot threads for the current user' do
# Create threads for the current agent
create_list(:captain_copilot_thread, 3, account: account, user: agent)
@@ -47,4 +47,65 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
end
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } }
context 'when it is an un-authenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
context 'with invalid params' do
it 'returns error when message is blank' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: '', assistant_id: assistant.id },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(json_response[:error]).to eq('Message is required')
end
it 'returns error when assistant_id is invalid' do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: { message: 'Hello', assistant_id: 0 },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:not_found)
end
end
context 'with valid params' do
it 'creates a new copilot thread with initial message' do
expect do
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
end.to change(CopilotThread, :count).by(1)
.and change(CopilotMessage, :count).by(1)
expect(response).to have_http_status(:success)
thread = CopilotThread.last
expect(thread.title).to eq(valid_params[:message])
expect(thread.user_id).to eq(agent.id)
expect(thread.assistant_id).to eq(assistant.id)
message = thread.copilot_messages.last
expect(message.message_type).to eq('user')
expect(message.message).to eq(valid_params[:message])
end
end
end
end
end
@@ -0,0 +1,24 @@
require 'rails_helper'
describe ActionCableListener do
describe '#copilot_message_created' do
let(:event_name) { :copilot_message_created }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
let(:listener) { described_class.instance }
it 'broadcasts message to the user' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[user.pubsub_token],
'copilot.message.created',
copilot_message.push_event_data.merge(account_id: account.id)
)
listener.copilot_message_created(event)
end
end
end
@@ -0,0 +1,57 @@
require 'rails_helper'
describe CaptainListener do
let(:listener) { described_class.instance }
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
describe '#conversation_resolved' do
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let(:event_name) { :conversation_resolved }
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
before do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
context 'when feature_memory is enabled' do
before do
assistant.config['feature_memory'] = true
assistant.config['feature_faq'] = false
assistant.save!
end
it 'generates and updates notes' do
expect(Captain::Llm::ContactNotesService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
listener.conversation_resolved(event)
end
end
context 'when feature_faq is enabled' do
before do
assistant.config['feature_faq'] = true
assistant.config['feature_memory'] = false
assistant.save!
end
it 'generates and deduplicates FAQs' do
expect(Captain::Llm::ConversationFaqService)
.to receive(:new)
.with(assistant, conversation)
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
listener.conversation_resolved(event)
end
end
end
end
@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe CopilotMessage, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:copilot_thread) }
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:message_type) }
it { is_expected.to validate_presence_of(:message) }
it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) }
end
describe 'callbacks' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
describe '#ensure_account' do
it 'sets the account from the copilot thread before validation' do
message = build(:captain_copilot_message, copilot_thread: copilot_thread, account: nil)
message.valid?
expect(message.account).to eq(copilot_thread.account)
end
end
describe '#broadcast_message' do
it 'dispatches COPILOT_MESSAGE_CREATED event after create' do
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
expect(Rails.configuration.dispatcher).to receive(:dispatch)
.with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message)
message.save!
end
end
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:message_content) { { 'content' => 'Test message' } }
let(:copilot_message) do
create(:captain_copilot_message,
copilot_thread: copilot_thread,
message_type: 'user',
message: message_content)
end
it 'returns the correct event data' do
event_data = copilot_message.push_event_data
expect(event_data[:id]).to eq(copilot_message.id)
expect(event_data[:message]).to eq(message_content)
expect(event_data[:message_type]).to eq('user')
expect(event_data[:created_at]).to eq(copilot_message.created_at.to_i)
expect(event_data[:copilot_thread]).to eq(copilot_thread.push_event_data)
end
end
end
@@ -0,0 +1,62 @@
require 'rails_helper'
RSpec.describe CopilotThread, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
it { is_expected.to have_many(:copilot_messages).dependent(:destroy_async) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
end
describe '#push_event_data' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant, title: 'Test Thread') }
it 'returns the correct event data' do
event_data = copilot_thread.push_event_data
expect(event_data[:id]).to eq(copilot_thread.id)
expect(event_data[:title]).to eq('Test Thread')
expect(event_data[:created_at]).to eq(copilot_thread.created_at.to_i)
expect(event_data[:user]).to eq(user.push_event_data)
expect(event_data[:account_id]).to eq(account.id)
end
end
describe '#previous_history' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
context 'when there are messages in the thread' do
before do
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'user', message: { 'content' => 'User message' })
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant_thinking', message: { 'content' => 'Thinking...' })
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant', message: { 'content' => 'Assistant message' })
end
it 'returns only user and assistant messages in chronological order' do
history = copilot_thread.previous_history
expect(history.length).to eq(2)
expect(history[0][:role]).to eq('user')
expect(history[0][:content]).to eq({ 'content' => 'User message' })
expect(history[1][:role]).to eq('assistant')
expect(history[1][:content]).to eq({ 'content' => 'Assistant message' })
end
end
context 'when there are no messages in the thread' do
it 'returns an empty array' do
expect(copilot_thread.previous_history).to eq([])
end
end
end
end
+1 -2
View File
@@ -1,9 +1,8 @@
FactoryBot.define do
factory :captain_copilot_message, class: 'CopilotMessage' do
account
user
copilot_thread { association :captain_copilot_thread }
message { { content: 'This is a test message' } }
message_type { 'user' }
message_type { 0 }
end
end
+1 -1
View File
@@ -3,6 +3,6 @@ FactoryBot.define do
account
user
title { Faker::Lorem.sentence }
uuid { SecureRandom.uuid }
assistant { create(:captain_assistant, account: account) }
end
end
@@ -203,4 +203,24 @@ describe ActionCableListener do
listener.conversation_updated(event)
end
end
describe '#copilot_message_created' do
let(:event_name) { :copilot_message_created }
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
let(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
it 'broadcasts message to the user' do
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
[user.pubsub_token],
'copilot.message.created',
copilot_message.push_event_data
)
listener.copilot_message_created(event)
end
end
end