Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816294e609 | ||
|
|
9813400237 | ||
|
|
cece07d649 | ||
|
|
02c4863d95 | ||
|
|
bae958334d |
@@ -0,0 +1,3 @@
|
||||
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
|
||||
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||
vite: BUILD_MODE=dev bin/vite build --watch
|
||||
@@ -134,10 +134,6 @@ 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`);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ const handlePageChange = event => {
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<div class="w-full max-w-[60rem] mx-auto">
|
||||
<div
|
||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||
@@ -116,7 +116,7 @@ const handlePageChange = event => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
|
||||
<slot v-if="!showPaywall" name="controls" />
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { nextTick, ref, watch, computed } from 'vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -9,23 +8,17 @@ 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 Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CopilotEmptyState from './CopilotEmptyState.vue';
|
||||
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
supportAgent: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
messages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isCaptainTyping: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -44,18 +37,11 @@ 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 chatContainer = ref(null);
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
@@ -65,20 +51,40 @@ const scrollToBottom = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
];
|
||||
const groupedMessages = computed(() => {
|
||||
const result = [];
|
||||
let thinkingGroup = [];
|
||||
props.messages.forEach(message => {
|
||||
if (message.message_type === 'assistant_thinking') {
|
||||
thinkingGroup.push(message);
|
||||
} else {
|
||||
if (thinkingGroup.length > 0) {
|
||||
result.push({
|
||||
id: thinkingGroup[0].id,
|
||||
message_type: 'thinking_group',
|
||||
messages: thinkingGroup,
|
||||
});
|
||||
thinkingGroup = [];
|
||||
}
|
||||
result.push(message);
|
||||
}
|
||||
});
|
||||
if (thinkingGroup.length > 0) {
|
||||
result.push({
|
||||
id: thinkingGroup[0].id,
|
||||
message_type: 'thinking_group',
|
||||
messages: thinkingGroup,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const isLastMessageFromAssistant = computed(() => {
|
||||
return (
|
||||
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
|
||||
'assistant'
|
||||
);
|
||||
});
|
||||
|
||||
const { updateUISettings } = useUISettings();
|
||||
|
||||
@@ -95,8 +101,22 @@ const handleSidebarAction = action => {
|
||||
}
|
||||
};
|
||||
|
||||
const hasAssistants = computed(() => props.assistants.length > 0);
|
||||
const hasMessages = computed(() => props.messages.length > 0);
|
||||
const copilotButtons = computed(() => {
|
||||
if (hasMessages.value) {
|
||||
return [
|
||||
{
|
||||
key: 'reset',
|
||||
icon: 'i-lucide-refresh-ccw',
|
||||
tooltip: t('CAPTAIN.COPILOT.RESET'),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
watch(
|
||||
[() => props.messages, () => props.isCaptainTyping],
|
||||
[() => props.messages],
|
||||
() => {
|
||||
scrollToBottom();
|
||||
},
|
||||
@@ -108,64 +128,57 @@ watch(
|
||||
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
|
||||
<SidebarActionsHeader
|
||||
:title="$t('CAPTAIN.COPILOT.TITLE')"
|
||||
:buttons="[
|
||||
{
|
||||
key: 'reset',
|
||||
icon: 'i-lucide-refresh-ccw',
|
||||
tooltip: $t('CAPTAIN.COPILOT.RESET'),
|
||||
},
|
||||
]"
|
||||
:buttons="copilotButtons"
|
||||
@click="handleSidebarAction"
|
||||
@close="closeCopilotPanel"
|
||||
/>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!messages.length"
|
||||
class="h-full w-full flex items-center justify-center"
|
||||
ref="chatContainer"
|
||||
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
|
||||
>
|
||||
<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 v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full">
|
||||
<template v-for="(item, index) in groupedMessages" :key="item.id">
|
||||
<CopilotAgentMessage
|
||||
v-if="item.message_type === 'user'"
|
||||
:message="item.message"
|
||||
/>
|
||||
<CopilotAssistantMessage
|
||||
v-else-if="item.message_type === 'assistant'"
|
||||
:message="item.message"
|
||||
:is-last-message="index === groupedMessages.length - 1"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
/>
|
||||
<CopilotThinkingGroup
|
||||
v-else
|
||||
:messages="item.messages"
|
||||
:default-collapsed="isLastMessageFromAssistant"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<CopilotLoader v-if="!isLastMessageFromAssistant" />
|
||||
</div>
|
||||
<CopilotEmptyState
|
||||
v-else
|
||||
:has-assistants="hasAssistants"
|
||||
@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"
|
||||
v-if="assistants.length > 1"
|
||||
:assistants="assistants"
|
||||
:active-assistant="activeAssistant"
|
||||
@set-assistant="$event => emit('setAssistant', $event)"
|
||||
/>
|
||||
<div v-else />
|
||||
</div>
|
||||
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
||||
<CopilotInput
|
||||
v-if="hasAssistants"
|
||||
class="mb-1 w-full"
|
||||
@send="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,10 @@ import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
isLastMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -20,6 +24,15 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
||||
|
||||
const showUseButton = computed(() => {
|
||||
return (
|
||||
!hasEmptyMessageContent.value &&
|
||||
props.message.reply_suggestion &&
|
||||
props.isLastMessage
|
||||
);
|
||||
});
|
||||
|
||||
const messageContent = computed(() => {
|
||||
const formatter = new MessageFormatter(props.message.content);
|
||||
@@ -32,8 +45,6 @@ const insertIntoRichEditor = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasEmptyMessageContent = computed(() => !props.message?.content);
|
||||
|
||||
const useCopilotResponse = () => {
|
||||
if (insertIntoRichEditor.value) {
|
||||
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
|
||||
@@ -57,7 +68,7 @@ const useCopilotResponse = () => {
|
||||
/>
|
||||
<div class="flex flex-row mt-1">
|
||||
<Button
|
||||
v-if="!hasEmptyMessageContent"
|
||||
v-if="showUseButton"
|
||||
:label="$t('CAPTAIN.COPILOT.USE')"
|
||||
faded
|
||||
sm
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Icon from '../icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
hasAssistants: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
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: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
|
||||
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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 v-if="!hasAssistants" class="w-full space-y-2">
|
||||
<p class="text-sm text-n-slate-11 leading-6">
|
||||
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
|
||||
</p>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'captain_assistants_index',
|
||||
params: { accountId: route.params.accountId },
|
||||
}"
|
||||
class="text-n-slate-11 underline hover:text-n-slate-12"
|
||||
>
|
||||
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else 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>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
const route = useRoute();
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
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 currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showCopilotLauncher = computed(() => {
|
||||
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
);
|
||||
return (
|
||||
isCaptainEnabled &&
|
||||
!uiSettings.value.is_copilot_panel_open &&
|
||||
!isConversationRoute.value
|
||||
);
|
||||
});
|
||||
const toggleSidebar = () => {
|
||||
updateUISettings({
|
||||
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
|
||||
<div class="rounded-full bg-n-alpha-2 p-1">
|
||||
<Button
|
||||
icon="i-woot-captain"
|
||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
||||
lg
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -17,7 +17,7 @@ defineProps({
|
||||
icon="i-lucide-sparkles"
|
||||
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
|
||||
/>
|
||||
<div class="text-sm text-n-slate-11">
|
||||
<div class="text-sm text-n-slate-12">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,10 +51,10 @@ watch(
|
||||
}"
|
||||
>
|
||||
<CopilotThinkingBlock
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
:content="message.content"
|
||||
:reasoning="message.reasoning"
|
||||
v-for="copilotMessage in messages"
|
||||
:key="copilotMessage.id"
|
||||
:content="copilotMessage.message.content"
|
||||
:reasoning="copilotMessage.message.reasoning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watchEffect } from 'vue';
|
||||
import { ref, computed, onMounted } 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,
|
||||
},
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
defineProps({
|
||||
conversationInboxType: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -20,12 +15,24 @@ const props = defineProps({
|
||||
const store = useStore();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
const selectedCopilotThreadId = ref(null);
|
||||
const messages = computed(() =>
|
||||
store.getters['copilotMessages/getMessagesByThreadId'](
|
||||
selectedCopilotThreadId.value
|
||||
)
|
||||
);
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const messages = ref([]);
|
||||
const isCaptainTyping = ref(false);
|
||||
const selectedAssistantId = ref(null);
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const activeAssistant = computed(() => {
|
||||
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||
@@ -55,68 +62,57 @@ const setAssistant = async assistant => {
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowCopilotPanel = computed(() => {
|
||||
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
);
|
||||
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
|
||||
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
messages.value = [];
|
||||
selectedCopilotThreadId.value = null;
|
||||
};
|
||||
|
||||
const sendMessage = async message => {
|
||||
// Add user message
|
||||
messages.value.push({
|
||||
id: messages.value.length + 1,
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
isCaptainTyping.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await 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,
|
||||
if (selectedCopilotThreadId.value) {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
threadId: selectedCopilotThreadId.value,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line
|
||||
console.log(error);
|
||||
} finally {
|
||||
isCaptainTyping.value = false;
|
||||
} else {
|
||||
const response = await store.dispatch('copilotThreads/create', {
|
||||
assistant_id: activeAssistant.value.id,
|
||||
conversation_id: currentChat.value?.id,
|
||||
message,
|
||||
});
|
||||
selectedCopilotThreadId.value = response.id;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.conversationId) {
|
||||
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
|
||||
selectedAssistantId.value = activeAssistant.value?.id;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldShowCopilotPanel"
|
||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
|
||||
>
|
||||
<Copilot
|
||||
:messages="messages"
|
||||
:support-agent="currentUser"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
:assistants="assistants"
|
||||
:active-assistant="activeAssistant"
|
||||
@set-assistant="setAssistant"
|
||||
@send-message="sendMessage"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
|
||||
@@ -1,43 +1,23 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import CopilotContainer from '../../copilot/CopilotContainer.vue';
|
||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
currentChat: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
});
|
||||
|
||||
const channelType = computed(() => props.currentChat?.meta?.channel || '');
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showCopilotTab = computed(() =>
|
||||
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
|
||||
);
|
||||
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const activeTab = computed(() => {
|
||||
const {
|
||||
is_contact_sidebar_open: isContactSidebarOpen,
|
||||
is_copilot_panel_open: isCopilotPanelOpen,
|
||||
} = uiSettings.value;
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
|
||||
|
||||
if (isContactSidebarOpen) {
|
||||
return 0;
|
||||
}
|
||||
if (isCopilotPanelOpen) {
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
@@ -52,13 +32,6 @@ const activeTab = computed(() => {
|
||||
:conversation-id="currentChat.id"
|
||||
:inbox-id="currentChat.inbox_id"
|
||||
/>
|
||||
<CopilotContainer
|
||||
v-show="activeTab === 1 && showCopilotTab"
|
||||
:key="currentChat.id"
|
||||
:conversation-inbox-type="channelType"
|
||||
:conversation-id="currentChat.id"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -328,6 +328,9 @@
|
||||
"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? Copilot’s 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",
|
||||
@@ -348,6 +351,14 @@
|
||||
"RATE": {
|
||||
"LABEL": "Rate this conversation",
|
||||
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
|
||||
},
|
||||
"HIGH_PRIORITY": {
|
||||
"LABEL": "High priority conversations",
|
||||
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
|
||||
},
|
||||
"LIST_CONTACTS": {
|
||||
"LABEL": "List contacts",
|
||||
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -381,6 +392,7 @@
|
||||
},
|
||||
"ASSISTANTS": {
|
||||
"HEADER": "Assistants",
|
||||
"NO_ASSISTANTS_AVAILABLE": "There are no assistants available in your account.",
|
||||
"ADD_NEW": "Create a new assistant",
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure to delete the assistant?",
|
||||
|
||||
@@ -25,6 +25,8 @@ const Sidebar = defineAsyncComponent(
|
||||
() => import('../../components/layout/Sidebar.vue')
|
||||
);
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
|
||||
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -37,6 +39,8 @@ export default {
|
||||
AddLabelModal,
|
||||
NotificationPanel,
|
||||
UpgradePage,
|
||||
CopilotLauncher,
|
||||
CopilotContainer,
|
||||
},
|
||||
setup() {
|
||||
const upgradePageRef = ref(null);
|
||||
@@ -219,6 +223,9 @@ export default {
|
||||
<template v-if="!showUpgradePage">
|
||||
<router-view />
|
||||
<CommandBar />
|
||||
<CopilotLauncher />
|
||||
<CopilotContainer />
|
||||
|
||||
<NotificationPanel
|
||||
v-if="isNotificationPanel"
|
||||
@close="closeNotificationPanel"
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6"
|
||||
>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive v-if="keepAlive">
|
||||
|
||||
@@ -97,11 +97,8 @@ export default {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
is_contact_sidebar_open: isContactSidebarOpen,
|
||||
is_copilot_panel_open: isCopilotPanelOpen,
|
||||
} = this.uiSettings;
|
||||
return isContactSidebarOpen || isCopilotPanelOpen;
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
|
||||
return isContactSidebarOpen;
|
||||
},
|
||||
showPopOverSearch() {
|
||||
return !this.isFeatureEnabledonAccount(
|
||||
|
||||
@@ -183,7 +183,6 @@ export default {
|
||||
</div>
|
||||
<div v-else class="flex flex-col w-full h-full">
|
||||
<InboxItemHeader
|
||||
class="flex-1"
|
||||
:total-length="totalNotificationCount"
|
||||
:current-index="activeNotificationIndex"
|
||||
:active-notification="activeNotification"
|
||||
|
||||
@@ -109,7 +109,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
|
||||
class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton
|
||||
|
||||
+1
-3
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
|
||||
>
|
||||
<div class="reports--wrapper overflow-auto bg-n-background w-full px-6">
|
||||
<div class="max-w-[60rem] mx-auto pb-12">
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,9 @@ export default createStore({
|
||||
API: CopilotMessagesAPI,
|
||||
getters: {
|
||||
getMessagesByThreadId: state => copilotThreadId => {
|
||||
return state.records.filter(
|
||||
record => record.copilot_thread?.id === Number(copilotThreadId)
|
||||
);
|
||||
return state.records
|
||||
.filter(record => record.copilot_thread?.id === Number(copilotThreadId))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
},
|
||||
},
|
||||
actions: mutationTypes => ({
|
||||
|
||||
+13
-7
@@ -76,18 +76,24 @@ class Article < ApplicationRecord
|
||||
scope :order_by_views, -> { reorder(views: :desc) }
|
||||
|
||||
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
|
||||
# - the A, B and C are for weightage. See: https://github.com/Casecommons/pg_search#weighting
|
||||
# - the normalization is for ensuring the long articles that mention the search term too many times are not ranked higher.
|
||||
# it divides rank by log(document_length) to prevent longer articles from ranking higher just due to sizeSee: https://github.com/Casecommons/pg_search#normalization
|
||||
# - the ranking is to ensure that articles with higher weightage are ranked higher
|
||||
pg_search_scope(
|
||||
:text_search,
|
||||
against: %i[
|
||||
title
|
||||
description
|
||||
content
|
||||
],
|
||||
against: {
|
||||
title: 'A',
|
||||
description: 'B',
|
||||
content: 'C'
|
||||
},
|
||||
using: {
|
||||
tsearch: {
|
||||
prefix: true
|
||||
prefix: true,
|
||||
normalization: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
ranked_by: ':tsearch'
|
||||
)
|
||||
|
||||
def self.search(params)
|
||||
|
||||
@@ -96,7 +96,6 @@ class SearchService
|
||||
def filter_articles
|
||||
@articles = current_account.articles
|
||||
.text_search(search_query)
|
||||
.reorder('updated_at DESC')
|
||||
.page(params[:page])
|
||||
.per(15)
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
if Rails.env.development?
|
||||
if Rails.env.development? && ENV['DISABLE_MINI_PROFILER'] != 'true'
|
||||
require 'rack-mini-profiler'
|
||||
|
||||
# initialization is skipped so trigger it
|
||||
|
||||
@@ -256,6 +256,11 @@ en:
|
||||
captain:
|
||||
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
||||
copilot_limit: 'You are out of Copilot credits. You can buy more credits from the billing section.'
|
||||
copilot:
|
||||
using_tool: 'Using tool %{function_name}'
|
||||
completed_tool_call: 'Completed %{function_name} tool call'
|
||||
invalid_tool_call: 'Invalid tool call'
|
||||
tool_not_available: 'Tool not available'
|
||||
public_portal:
|
||||
search:
|
||||
search_placeholder: Search for article by title or body...
|
||||
|
||||
@@ -128,7 +128,6 @@ Rails.application.routes.draw do
|
||||
post :unread
|
||||
post :custom_attributes
|
||||
get :attachments
|
||||
post :copilot
|
||||
get :inbox_assistant
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
module Enterprise::Api::V1::Accounts::ConversationsController
|
||||
extend ActiveSupport::Concern
|
||||
included do
|
||||
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_history: copilot_params[:previous_history],
|
||||
conversation_id: @conversation.display_id,
|
||||
user_id: Current.user.id
|
||||
).generate_response(copilot_params[:message])
|
||||
|
||||
render json: { message: response['response'] }
|
||||
end
|
||||
|
||||
def inbox_assistant
|
||||
assistant = @conversation.inbox.captain_assistant
|
||||
|
||||
@@ -53,9 +53,21 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def execute_tool(function_name, arguments, tool_call_id)
|
||||
persist_message({ content: "Using tool #{function_name}", function_name: function_name }, 'assistant_thinking')
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t('captain.copilot.using_tool', function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
result = @tool_registry.send(function_name, arguments)
|
||||
persist_message({ content: "Completed #{function_name} tool call", function_name: function_name }, 'assistant_thinking')
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t('captain.copilot.completed_tool_call', function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
append_tool_response(result, tool_call_id)
|
||||
end
|
||||
|
||||
@@ -67,8 +79,8 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def process_invalid_tool_call(function_name, tool_call_id)
|
||||
persist_message({ content: 'Invalid tool call', function_name: function_name }, 'assistant_thinking')
|
||||
append_tool_response('Tool not available', tool_call_id)
|
||||
persist_message({ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, 'assistant_thinking')
|
||||
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_response(content, tool_call_id)
|
||||
|
||||
@@ -60,7 +60,7 @@ class CopilotMessage < ApplicationRecord
|
||||
def validate_message_attributes
|
||||
return if message.blank?
|
||||
|
||||
allowed_keys = %w[content reasoning function_name]
|
||||
allowed_keys = %w[content reasoning function_name reply_suggestion]
|
||||
invalid_keys = message.keys - allowed_keys
|
||||
|
||||
errors.add(:message, "contains invalid attributes: #{invalid_keys.join(', ')}") if invalid_keys.any?
|
||||
|
||||
@@ -74,7 +74,10 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
def system_message
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.copilot_response_generator(@assistant.config['product_name'])
|
||||
content: Captain::Llm::SystemPromptsService.copilot_response_generator(
|
||||
@assistant.config['product_name'],
|
||||
@tool_registry.tools_summary
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class Captain::Llm::SystemPromptsService
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def copilot_response_generator(product_name)
|
||||
def copilot_response_generator(product_name, available_tools)
|
||||
<<~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.
|
||||
@@ -94,12 +94,20 @@ class Captain::Llm::SystemPromptsService
|
||||
```json
|
||||
{
|
||||
"reasoning": "Explain why the response was chosen based on the provided information.",
|
||||
"response": "Provide the answer only in Markdown format for readability."
|
||||
"content": "Provide the answer only in Markdown format for readability.",
|
||||
"reply_suggestion": "A boolean value that is true only if the support agent has explicitly asked to draft a response to the customer, and the response fulfills that request. Otherwise, it should be false."
|
||||
}
|
||||
|
||||
[Error Handling]
|
||||
- If the required information is not found in the provided context, respond with an appropriate message indicating that no relevant data is available.
|
||||
- Avoid speculating or providing unverified information.
|
||||
|
||||
[Available Actions]
|
||||
You have the following actions available to assist support agents:
|
||||
- summarize_conversation: Summarize the conversation
|
||||
- draft_response: Draft a response for the support agent
|
||||
- rate_conversation: Rate the conversation
|
||||
#{available_tools}
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
|
||||
@@ -27,4 +27,10 @@ class Captain::ToolRegistryService
|
||||
def respond_to_missing?(method_name, include_private = false)
|
||||
@tools.key?(method_name.to_s) || super
|
||||
end
|
||||
|
||||
def tools_summary
|
||||
@tools.map do |name, tool|
|
||||
"- #{name}: #{tool.description}"
|
||||
end.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,8 +19,9 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
status = arguments['status']
|
||||
contact_id = arguments['contact_id']
|
||||
priority = arguments['priority']
|
||||
labels = arguments['labels']
|
||||
|
||||
conversations = get_conversations(status, contact_id, priority)
|
||||
conversations = get_conversations(status, contact_id, priority, labels)
|
||||
|
||||
return 'No conversations found' unless conversations.exists?
|
||||
|
||||
@@ -41,11 +42,12 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
|
||||
private
|
||||
|
||||
def get_conversations(status, contact_id, priority)
|
||||
def get_conversations(status, contact_id, priority, labels)
|
||||
conversations = permissible_conversations
|
||||
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
|
||||
conversations = conversations.where(status: status) if status.present?
|
||||
conversations = conversations.where(priority: priority) if priority.present?
|
||||
conversations = conversations.tagged_with(labels, any: true) if labels.present?
|
||||
conversations
|
||||
end
|
||||
|
||||
@@ -59,20 +61,10 @@ class Captain::Tools::Copilot::SearchConversationsService < Captain::Tools::Base
|
||||
|
||||
def properties
|
||||
{
|
||||
contact_id: {
|
||||
type: 'number',
|
||||
description: 'Filter conversations by contact ID'
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: %w[open resolved pending snoozed],
|
||||
description: 'Filter conversations by status'
|
||||
},
|
||||
priority: {
|
||||
type: 'string',
|
||||
enum: %w[low medium high urgent],
|
||||
description: 'Filter conversations by priority'
|
||||
}
|
||||
contact_id: { type: 'number', description: 'Filter conversations by contact ID' },
|
||||
status: { type: 'string', enum: %w[open resolved pending snoozed], description: 'Filter conversations by status' },
|
||||
priority: { type: 'string', enum: %w[low medium high urgent], description: 'Filter conversations by priority' },
|
||||
labels: { type: 'array', items: { type: 'string' }, description: 'Filter conversations by labels' }
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"start:dev": "foreman start -f ./Procfile.dev",
|
||||
"start:test": "RAILS_ENV=test foreman start -f ./Procfile.test",
|
||||
"dev": "overmind start -f ./Procfile.dev",
|
||||
"dev:tunnel": "overmind start -f ./Procfile.tunnel",
|
||||
"ruby:prettier": "bundle exec rubocop -a",
|
||||
"build:sdk": "BUILD_MODE=library vite build",
|
||||
"prepare": "husky install",
|
||||
|
||||
@@ -109,4 +109,46 @@ RSpec.describe Captain::ToolRegistryService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#tools_summary' do
|
||||
let(:tool_class) { TestTool }
|
||||
|
||||
before do
|
||||
service.register_tool(tool_class)
|
||||
end
|
||||
|
||||
it 'returns formatted summary of registered tools' do
|
||||
expect(service.tools_summary).to eq('- test_tool: A test tool for specs')
|
||||
end
|
||||
|
||||
context 'when multiple tools are registered' do
|
||||
let(:another_tool_class) do
|
||||
Class.new(Captain::Tools::BaseService) do
|
||||
def name
|
||||
'another_tool'
|
||||
end
|
||||
|
||||
def description
|
||||
'Another test tool'
|
||||
end
|
||||
|
||||
def parameters
|
||||
{
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
end
|
||||
|
||||
def active?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'includes all tools in the summary' do
|
||||
service.register_tool(another_tool_class)
|
||||
expect(service.tools_summary).to eq("- test_tool: A test tool for specs\n- another_tool: Another test tool")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -156,33 +156,18 @@ describe SearchService do
|
||||
end
|
||||
|
||||
context 'when article search' do
|
||||
it 'orders results by updated_at desc' do
|
||||
# Create articles with explicit timestamps
|
||||
older_time = 2.days.ago
|
||||
newer_time = 1.hour.ago
|
||||
|
||||
it 'returns matching articles' do
|
||||
article2 = create(:article, title: 'Spellcasting Guide',
|
||||
account: account, portal: portal, author: user, status: 'published')
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
article2.update_column(:updated_at, older_time)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
article3 = create(:article, title: 'Spellcasting Manual',
|
||||
account: account, portal: portal, author: user, status: 'published')
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
article3.update_column(:updated_at, newer_time)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
params = { q: 'Spellcasting' }
|
||||
search = described_class.new(current_user: user, current_account: account, params: params, search_type: 'Article')
|
||||
results = search.perform[:articles]
|
||||
|
||||
# Check the timestamps to understand ordering
|
||||
results.map { |a| [a.id, a.updated_at] }
|
||||
|
||||
# Should be ordered by updated_at desc (newer first)
|
||||
expect(results.length).to eq(2)
|
||||
expect(results.first.updated_at).to be > results.second.updated_at
|
||||
expect(results.map(&:id)).to contain_exactly(article2.id, article3.id)
|
||||
end
|
||||
|
||||
it 'returns paginated results' do
|
||||
|
||||
+11
-3
@@ -1,7 +1,7 @@
|
||||
/// <reference types="vitest" />
|
||||
|
||||
/**
|
||||
What's going on with library mode?
|
||||
What's going on with build modes?
|
||||
|
||||
Glad you asked, here's a quick rundown:
|
||||
|
||||
@@ -13,8 +13,10 @@ This puts us in a deadlock, now there are two ways around this, either add anoth
|
||||
the app using vanilla rollup or rspack or something. The second option is to remove sdk building from the main pipeline
|
||||
and build it separately using Vite itself, toggled by an ENV variable.
|
||||
|
||||
`BUILD_MODE=library bin/vite build` should build only the SDK and save it to `public/packs/js/sdk.js`
|
||||
`bin/vite build` will build the rest of the app as usual. But exclude the SDK.
|
||||
Build modes:
|
||||
- `BUILD_MODE=library bin/vite build` should build only the SDK and save it to `public/packs/js/sdk.js`
|
||||
- `BUILD_MODE=dev bin/vite build --watch` builds with fast dev settings (no minify, inline sourcemaps) for tunnels/codespace
|
||||
- `bin/vite build` will build the rest of the app as usual. But exclude the SDK.
|
||||
|
||||
We need to edit the `asset:precompile` rake task to include the SDK in the precompile list.
|
||||
*/
|
||||
@@ -24,6 +26,7 @@ import path from 'path';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const isLibraryMode = process.env.BUILD_MODE === 'library';
|
||||
const isDevMode = process.env.BUILD_MODE === 'dev';
|
||||
const isTestMode = process.env.TEST === 'true';
|
||||
|
||||
const vueOptions = {
|
||||
@@ -45,6 +48,10 @@ if (isLibraryMode) {
|
||||
export default defineConfig({
|
||||
plugins: plugins,
|
||||
build: {
|
||||
minify: isDevMode ? false : 'esbuild',
|
||||
cssMinify: isDevMode ? false : true,
|
||||
sourcemap: isDevMode ? false : false,
|
||||
target: isDevMode ? 'es2019' : undefined,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// [NOTE] when not in library mode, no new keys will be addedd or overwritten
|
||||
@@ -60,6 +67,7 @@ export default defineConfig({
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(isDevMode ? { manualChunks: undefined } : {}), // single bundle for dev mode
|
||||
inlineDynamicImports: isLibraryMode, // Disable code-splitting for SDK
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user