Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be68927a52 | ||
|
|
1fd77fdec2 | ||
|
|
992cf6fd4d | ||
|
|
b3ac1dcd03 | ||
|
|
80caf25962 | ||
|
|
bf035114fe | ||
|
|
990b58fe62 | ||
|
|
52271dc984 | ||
|
|
52d6f2a369 | ||
|
|
a35c3e4c06 | ||
|
|
344e8d5016 | ||
|
|
3e27e28848 | ||
|
|
38af08534c |
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -59,6 +60,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
{{ answer }}
|
||||
</span>
|
||||
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
|
||||
<div class="inline-flex items-center">
|
||||
<span
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||
>
|
||||
<Policy v-if="showActions" :permissions="['administrator']">
|
||||
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||
<Button
|
||||
v-if="status === 'pending'"
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
sm
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'approve', value: 'approve' })
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
|
||||
icon="i-lucide-pencil-line"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
|
||||
icon="i-lucide-trash"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'delete', value: 'delete' })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
:class="{ 'justify-between w-full': !showActions }"
|
||||
>
|
||||
<div class="inline-flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="inline-flex items-center gap-1 truncate over"
|
||||
v-if="status === 'approved'"
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-files-light text-base" />
|
||||
<span class="max-w-96 truncate" :title="documentable.name">
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
|
||||
>
|
||||
<Icon
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
icon="i-ph-files-light"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'User'"
|
||||
icon="i-ph-user-circle-plus"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="truncate"
|
||||
:title="documentable.name"
|
||||
>
|
||||
{{ documentable.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="documentable.type === 'User'"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-user-circle-plus text-base" />
|
||||
<span
|
||||
class="max-w-96 truncate"
|
||||
v-else-if="documentable.type === 'User'"
|
||||
class="truncate"
|
||||
:title="documentable.available_name"
|
||||
>
|
||||
{{ documentable.available_name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="inline-flex items-center gap-1 group cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
<i class="i-ph-chat-circle-dots text-base" />
|
||||
<span class="group-hover:underline">
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="hover:underline truncate cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
{{
|
||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||
id: documentable.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status !== 'approved'"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
>
|
||||
<i
|
||||
class="i-ph-stack text-base"
|
||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
||||
/>
|
||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
>
|
||||
<i class="i-ph-calendar-dot" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-text w-full" />
|
||||
<Button
|
||||
label="Visit our website"
|
||||
slate
|
||||
class="!text-n-blue-text w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import CallToAction from '../../bubbles/Template/CallToAction.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/CallToAction"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Call To Action">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<CallToAction :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Card from '../../bubbles/Template/Card.vue';
|
||||
|
||||
const message = {
|
||||
title: 'Two in one cake (1 pound)',
|
||||
content: 'Customize your order for special occasions',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Card"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Card">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Card :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import ListPicker from '../../bubbles/Template/ListPicker.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you? `,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/ListPicker"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="ListPicker">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<ListPicker :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Media from '../../bubbles/Template/Media.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Media"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Image Media">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Media :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import QuickReply from '../../bubbles/Template/QuickReply.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you?`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/QuickReply"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Quick Replies">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<QuickReply :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import Text from '../../bubbles/Template/Text.vue';
|
||||
|
||||
const message = {
|
||||
content: 'Hello John, how may we assist you?',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Text"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Text :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -861,6 +861,7 @@
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -900,6 +901,10 @@
|
||||
"APPROVED": "Approved",
|
||||
"ALL": "All"
|
||||
},
|
||||
"PENDING_BANNER": {
|
||||
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||
"ACTION": "Click here to review"
|
||||
},
|
||||
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
||||
"CREATE": {
|
||||
"TITLE": "Add an FAQ",
|
||||
@@ -931,9 +936,9 @@
|
||||
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
|
||||
},
|
||||
"OPTIONS": {
|
||||
"APPROVE": "Mark as approved",
|
||||
"EDIT_RESPONSE": "Edit FAQ",
|
||||
"DELETE_RESPONSE": "Delete FAQ"
|
||||
"APPROVE": "Approve",
|
||||
"EDIT_RESPONSE": "Edit",
|
||||
"DELETE_RESPONSE": "Delete"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
|
||||
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
export const routes = [
|
||||
@@ -125,6 +126,19 @@ export const routes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/responses/pending'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_responses_pending',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/tools'),
|
||||
component: CustomToolsIndex,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
@@ -37,7 +35,6 @@ const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedStatus = ref('all');
|
||||
const selectedAssistant = ref('all');
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
@@ -45,54 +42,17 @@ const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const isStatusFilterOpen = ref(false);
|
||||
const shouldShowDropdown = computed(() => {
|
||||
if (assistants.value.length === 0) return false;
|
||||
|
||||
return !isFetching.value;
|
||||
});
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
['all', 'pending', 'approved'].map(key => ({
|
||||
label: t(`CAPTAIN.RESPONSES.STATUS.${key.toUpperCase()}`),
|
||||
value: key,
|
||||
action: 'filter',
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredResponses = computed(() => {
|
||||
return selectedStatus.value === 'pending'
|
||||
? responses.value.filter(r => r.status === 'pending')
|
||||
: responses.value;
|
||||
});
|
||||
|
||||
const selectedStatusLabel = computed(() => {
|
||||
const status = statusOptions.value.find(
|
||||
option => option.value === selectedStatus.value
|
||||
);
|
||||
return t('CAPTAIN.RESPONSES.FILTER.STATUS', {
|
||||
selected: status ? status.label : '',
|
||||
});
|
||||
});
|
||||
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogType.value = 'create';
|
||||
@@ -105,9 +65,7 @@ const handleEdit = () => {
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
selectedResponse.value = responses.value.find(response => id === response.id);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
@@ -115,9 +73,6 @@ const handleAction = ({ action, id }) => {
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -136,10 +91,8 @@ const handleCreateClose = () => {
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page };
|
||||
if (selectedStatus.value !== 'all') {
|
||||
filterParams.status = selectedStatus.value;
|
||||
}
|
||||
const filterParams = { page, status: 'approved' };
|
||||
|
||||
if (selectedAssistant.value !== 'all') {
|
||||
filterParams.assistantId = selectedAssistant.value;
|
||||
}
|
||||
@@ -155,7 +108,7 @@ const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
const totalCount = responses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
@@ -168,13 +121,13 @@ const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
? new Set(responses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
const count = responses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
@@ -191,7 +144,7 @@ const handleCardSelect = id => {
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const hasNoResponsesLeft = responses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
@@ -208,22 +161,6 @@ const fetchResponseAfterBulkAction = () => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
// Store current selection state before fetching new page
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
@@ -238,7 +175,7 @@ const onPageChange = page => {
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
@@ -247,12 +184,6 @@ const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleStatusFilterChange = ({ value }) => {
|
||||
selectedStatus.value = value;
|
||||
isStatusFilterOpen.value = false;
|
||||
fetchResponses();
|
||||
};
|
||||
|
||||
const handleAssistantFilterChange = assistant => {
|
||||
selectedAssistant.value = assistant;
|
||||
fetchResponses();
|
||||
@@ -262,9 +193,14 @@ const debouncedSearch = debounce(async () => {
|
||||
fetchResponses();
|
||||
}, 500);
|
||||
|
||||
const navigateToPendingFAQs = () => {
|
||||
router.push({ name: 'captain_responses_pending' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
fetchResponses();
|
||||
store.dispatch('captainResponses/fetchPendingCount');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -276,8 +212,8 @@ onMounted(() => {
|
||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:is-empty="!responses.length"
|
||||
:show-pagination-footer="!isFetching && !!responses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
@@ -315,25 +251,7 @@ onMounted(() => {
|
||||
v-if="!bulkSelectionState.hasSelected"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<div class="flex items-center gap-3">
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
@@ -344,6 +262,7 @@ onMounted(() => {
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
@@ -380,15 +299,6 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
@@ -406,10 +316,19 @@ onMounted(() => {
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
<Banner
|
||||
v-if="pendingCount > 0"
|
||||
color="blue"
|
||||
class="mb-4"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
|
||||
@action="navigateToPendingFAQs"
|
||||
>
|
||||
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
|
||||
</Banner>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in filteredResponses"
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
@@ -422,6 +341,7 @@ onMounted(() => {
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="!bulkSelectedIds.has(response.id)"
|
||||
:show-actions="false"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
|
||||
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedAssistant = ref('all');
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const shouldShowDropdown = computed(() => {
|
||||
if (assistants.value.length === 0) return false;
|
||||
return !isFetching.value;
|
||||
});
|
||||
|
||||
const backUrl = computed(() =>
|
||||
frontendURL(`accounts/${route.params.accountId}/captain/responses`)
|
||||
);
|
||||
|
||||
// Filter out approved responses in pending view
|
||||
const filteredResponses = computed(() =>
|
||||
responses.value.filter(response => response.status !== 'approved')
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogType.value = 'create';
|
||||
nextTick(() => createDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
dialogType.value = 'edit';
|
||||
nextTick(() => createDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
}
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigationAction = ({ id, type }) => {
|
||||
if (type === 'Conversation') {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: id },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedResponse.value = null;
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistant.value !== 'all') {
|
||||
filterParams.assistantId = selectedAssistant.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
}
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
|
||||
allSelected: totalCount > 0 && selectedCount === totalCount,
|
||||
};
|
||||
});
|
||||
|
||||
const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleCardSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
|
||||
fetchResponses(pageToFetch);
|
||||
} else {
|
||||
fetchResponses(currentPage);
|
||||
}
|
||||
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
const hadPartialSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
if (wasAllPageSelected || hadPartialSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleAssistantFilterChange = assistant => {
|
||||
selectedAssistant.value = assistant;
|
||||
fetchResponses();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
fetchResponses();
|
||||
}, 500);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
fetchResponses();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:button-policy="['administrator']"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<div
|
||||
v-if="shouldShowDropdown"
|
||||
class="mb-4 -mt-3 flex justify-between items-center py-1"
|
||||
:class="{
|
||||
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
|
||||
bulkSelectionState.hasSelected,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="!bulkSelectionState.hasSelected"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="slide-fade"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
|
||||
enter-to-class="opacity-100 transform translate-x-0"
|
||||
leave-active-class="hidden opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="bulkSelectionState.hasSelected"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckbox"
|
||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
|
||||
{{ buildSelectedCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
count: bulkSelectedIds.size,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="bulkDeleteDialog.dialogRef.open()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:documentable="response.documentable"
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="false"
|
||||
:show-actions="!bulkSelectedIds.has(response.id)"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedResponse"
|
||||
ref="deleteDialog"
|
||||
:entity="selectedResponse"
|
||||
type="Responses"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialog"
|
||||
:type="dialogType"
|
||||
:selected-response="selectedResponse"
|
||||
@close="handleCreateClose"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -1,9 +1,22 @@
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainResponse',
|
||||
API: CaptainResponseAPI,
|
||||
getters: {
|
||||
getPendingCount: state => state.meta.pendingCount || 0,
|
||||
},
|
||||
mutations: {
|
||||
[SET_PENDING_COUNT](state, count) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
pendingCount: Number(count),
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: mutations => ({
|
||||
removeBulkResponses: ({ commit, state }, ids) => {
|
||||
const updatedRecords = state.records.filter(
|
||||
@@ -28,5 +41,18 @@ export default createStore({
|
||||
|
||||
commit(mutations.SET, updatedRecords);
|
||||
},
|
||||
fetchPendingCount: async ({ commit }, assistantId) => {
|
||||
try {
|
||||
const response = await CaptainResponseAPI.get({
|
||||
status: 'pending',
|
||||
page: 1,
|
||||
assistantId,
|
||||
});
|
||||
const count = response.data?.meta?.total_count || 0;
|
||||
commit(SET_PENDING_COUNT, count);
|
||||
} catch (error) {
|
||||
commit(SET_PENDING_COUNT, 0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ export const createMutations = mutationTypes => ({
|
||||
},
|
||||
[mutationTypes.SET_META](state, meta) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
totalCount: Number(meta.total_count),
|
||||
page: Number(meta.page),
|
||||
};
|
||||
@@ -69,7 +70,7 @@ export const createCrudActions = (API, mutationTypes) => ({
|
||||
});
|
||||
|
||||
export const createStore = options => {
|
||||
const { name, API, actions, getters } = options;
|
||||
const { name, API, actions, getters, mutations } = options;
|
||||
const mutationTypes = generateMutationTypes(name);
|
||||
|
||||
const customActions = actions ? actions(mutationTypes) : {};
|
||||
@@ -81,7 +82,10 @@ export const createStore = options => {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
mutations: createMutations(mutationTypes),
|
||||
mutations: {
|
||||
...createMutations(mutationTypes),
|
||||
...(mutations || {}),
|
||||
},
|
||||
actions: {
|
||||
...createCrudActions(API, mutationTypes),
|
||||
...customActions,
|
||||
|
||||
@@ -78,6 +78,11 @@ export const getters = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out authentication templates
|
||||
if (template.category === 'AUTHENTICATION') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||
const hasUnsupportedComponents = template.components.some(
|
||||
component =>
|
||||
|
||||
@@ -265,6 +265,39 @@ describe('#getters', () => {
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('filters out authentication templates', () => {
|
||||
const authenticationTemplates = [
|
||||
{
|
||||
name: 'auth_template',
|
||||
status: 'approved',
|
||||
category: 'AUTHENTICATION',
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Your verification code is {{1}}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'regular_template',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
components: [{ type: 'BODY', text: 'Regular message' }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: authenticationTemplates,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('returns valid templates from fixture data', () => {
|
||||
const state = {
|
||||
records: [
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
require 'agents'
|
||||
|
||||
class Captain::Assistant::BaseAssistantService
|
||||
def initialize(text:)
|
||||
@text = text
|
||||
end
|
||||
|
||||
def execute
|
||||
agent = build_agent
|
||||
runner = Agents::Runner.with_agents(agent)
|
||||
|
||||
result = runner.run(@text, context: {})
|
||||
|
||||
return error_response(result.error) if result.respond_to?(:error) && result.error
|
||||
|
||||
process_result(result)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[Captain V2] #{self.class.name} error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
error_response(e.message)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def build_agent
|
||||
Agents::Agent.new(
|
||||
name: agent_name,
|
||||
instructions: build_instructions,
|
||||
model: agent_model,
|
||||
response_schema: response_schema
|
||||
)
|
||||
end
|
||||
|
||||
def agent_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL
|
||||
end
|
||||
|
||||
def agent_name
|
||||
raise NotImplementedError, "#{self.class} must implement agent_name"
|
||||
end
|
||||
|
||||
def build_instructions
|
||||
raise NotImplementedError, "#{self.class} must implement build_instructions"
|
||||
end
|
||||
|
||||
def response_schema
|
||||
raise NotImplementedError, "#{self.class} must implement response_schema"
|
||||
end
|
||||
|
||||
def process_result(result)
|
||||
output = result.output
|
||||
|
||||
return error_response(output[:error] || output['error']) if output.is_a?(Hash) && (output[:error] || output['error'])
|
||||
|
||||
build_success_response(output)
|
||||
end
|
||||
|
||||
def build_success_response(output)
|
||||
{
|
||||
success: true,
|
||||
result: extract_primary_field(output),
|
||||
original_text: @text
|
||||
}
|
||||
end
|
||||
|
||||
def error_response(error_message)
|
||||
{
|
||||
success: false,
|
||||
error: error_message,
|
||||
original_text: @text
|
||||
}
|
||||
end
|
||||
|
||||
def extract_field(output, *field_names)
|
||||
return output.to_s unless output.is_a?(Hash)
|
||||
|
||||
field_names.each do |field|
|
||||
value = output[field.to_sym] || output[field.to_s]
|
||||
return value if value
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def extract_primary_field(output)
|
||||
output
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,56 @@
|
||||
class Captain::Assistant::ChangeToneService < Captain::Assistant::BaseAssistantService
|
||||
SUPPORTED_TONES = %w[professional casual straightforward confident friendly].freeze
|
||||
|
||||
def initialize(text:, tone:)
|
||||
super(text: text)
|
||||
@tone = validate_tone(tone)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def agent_name
|
||||
'ToneChanger'
|
||||
end
|
||||
|
||||
def build_instructions
|
||||
context = { tone: @tone }
|
||||
Captain::PromptRenderer.render('rewrite/tone', context.with_indifferent_access)
|
||||
end
|
||||
|
||||
def response_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
rewritten_text: {
|
||||
type: 'string',
|
||||
description: 'The rewritten text with the requested tone applied'
|
||||
},
|
||||
tone_applied: {
|
||||
type: 'string',
|
||||
description: 'The tone that was applied to the text'
|
||||
}
|
||||
},
|
||||
required: %w[rewritten_text tone_applied],
|
||||
additionalProperties: false
|
||||
}
|
||||
end
|
||||
|
||||
def build_success_response(output)
|
||||
{
|
||||
success: true,
|
||||
rewritten_text: extract_field(output, 'rewritten_text'),
|
||||
tone: @tone,
|
||||
original_text: @text
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_tone(tone)
|
||||
tone_str = tone.to_s.downcase
|
||||
|
||||
raise ArgumentError, "Unsupported tone: #{tone}. Supported tones: #{SUPPORTED_TONES.join(', ')}" unless SUPPORTED_TONES.include?(tone_str)
|
||||
|
||||
tone_str
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,124 @@
|
||||
class Captain::Assistant::ConversationSummaryService < Captain::Assistant::BaseAssistantService
|
||||
TOKEN_LIMIT = 16_000
|
||||
|
||||
def initialize(conversation:)
|
||||
@conversation = conversation
|
||||
super(text: format_conversation_messages)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def agent_name
|
||||
'ConversationSummarizer'
|
||||
end
|
||||
|
||||
def build_instructions
|
||||
Captain::PromptRenderer.render('summary', {})
|
||||
end
|
||||
|
||||
def response_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
customer_intent: {
|
||||
type: 'string',
|
||||
description: 'Brief description of what the customer wants (around 50 words)'
|
||||
},
|
||||
conversation_summary: {
|
||||
type: 'string',
|
||||
description: 'Summary of the conversation in approximately 200 words'
|
||||
},
|
||||
action_items: {
|
||||
type: 'array',
|
||||
description: 'List of action items committed to by the agent or left incomplete',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
follow_up_items: {
|
||||
type: 'array',
|
||||
description: 'List of unresolved issues or outstanding questions',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
},
|
||||
required: %w[customer_intent conversation_summary],
|
||||
additionalProperties: false
|
||||
}
|
||||
end
|
||||
|
||||
def build_success_response(output)
|
||||
{
|
||||
success: true,
|
||||
summary: build_summary_markdown(output),
|
||||
structured_data: {
|
||||
customer_intent: extract_field(output, 'customer_intent'),
|
||||
conversation_summary: extract_field(output, 'conversation_summary'),
|
||||
action_items: extract_array_field(output, 'action_items'),
|
||||
follow_up_items: extract_array_field(output, 'follow_up_items')
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_conversation_messages
|
||||
messages = []
|
||||
character_count = 0
|
||||
|
||||
@conversation.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
break if character_count + message.content.length > TOKEN_LIMIT
|
||||
next if message.content.blank?
|
||||
|
||||
messages.prepend(format_message(message))
|
||||
character_count += message.content.length
|
||||
end
|
||||
|
||||
messages.join("\n")
|
||||
end
|
||||
|
||||
def format_message(message)
|
||||
sender_type = message.incoming? ? 'Customer' : 'Agent'
|
||||
sender_name = message.sender&.name || 'Unknown'
|
||||
"#{sender_type} #{sender_name}: #{message.content}"
|
||||
end
|
||||
|
||||
def extract_array_field(output, field_name)
|
||||
return [] unless output.is_a?(Hash)
|
||||
|
||||
output[field_name.to_sym] || output[field_name.to_s] || []
|
||||
end
|
||||
|
||||
def build_summary_markdown(output)
|
||||
return '' unless output.is_a?(Hash)
|
||||
|
||||
sections = []
|
||||
|
||||
if (intent = extract_field(output, 'customer_intent')).present?
|
||||
sections << "**Customer Intent**\n\n#{intent}"
|
||||
end
|
||||
|
||||
if (summary = extract_field(output, 'conversation_summary')).present?
|
||||
sections << "**Conversation Summary**\n\n#{summary}"
|
||||
end
|
||||
|
||||
action_items = extract_array_field(output, 'action_items')
|
||||
if action_items&.any?
|
||||
items_list = action_items.map { |item| "- #{item}" }.join("\n")
|
||||
sections << "**Action Items**\n\n#{items_list}"
|
||||
end
|
||||
|
||||
follow_up_items = extract_array_field(output, 'follow_up_items')
|
||||
if follow_up_items&.any?
|
||||
items_list = follow_up_items.map { |item| "- #{item}" }.join("\n")
|
||||
sections << "**Follow-up Items**\n\n#{items_list}"
|
||||
end
|
||||
|
||||
sections.join("\n\n")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
class Captain::Assistant::FixGrammarService < Captain::Assistant::BaseAssistantService
|
||||
protected
|
||||
|
||||
def agent_name
|
||||
'GrammarFixer'
|
||||
end
|
||||
|
||||
def build_instructions
|
||||
Captain::PromptRenderer.render('rewrite/grammar', {})
|
||||
end
|
||||
|
||||
def response_schema
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
corrected_text: {
|
||||
type: 'string',
|
||||
description: 'The text with corrected grammar, spelling, and punctuation'
|
||||
},
|
||||
corrections_made: {
|
||||
type: 'array',
|
||||
description: 'List of corrections that were made',
|
||||
items: {
|
||||
type: 'string'
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['corrected_text'],
|
||||
additionalProperties: false
|
||||
}
|
||||
end
|
||||
|
||||
def build_success_response(output)
|
||||
{
|
||||
success: true,
|
||||
corrected_text: extract_field(output, 'corrected_text'),
|
||||
corrections_made: extract_corrections(output),
|
||||
original_text: @text
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_corrections(output)
|
||||
return [] unless output.is_a?(Hash)
|
||||
|
||||
output[:corrections_made] || output['corrections_made'] || []
|
||||
end
|
||||
end
|
||||
@@ -5,11 +5,13 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
assistant = Captain::Assistant.find(assistant_id)
|
||||
metadata = payload[:metadata]
|
||||
|
||||
canonical_url = normalize_link(metadata['url'])
|
||||
document = assistant.documents.find_or_initialize_by(
|
||||
external_link: metadata['url']
|
||||
external_link: canonical_url
|
||||
)
|
||||
|
||||
document.update!(
|
||||
external_link: canonical_url,
|
||||
content: payload[:markdown],
|
||||
name: metadata['title'],
|
||||
status: :available
|
||||
@@ -17,4 +19,10 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
rescue StandardError => e
|
||||
raise "Failed to parse FireCrawl data: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_link(raw_url)
|
||||
raw_url.to_s.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,11 +15,11 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
page_title = crawler.page_title || ''
|
||||
content = crawler.body_text_content || ''
|
||||
|
||||
document = assistant.documents.find_or_initialize_by(
|
||||
external_link: page_link
|
||||
)
|
||||
normalized_link = normalize_link(page_link)
|
||||
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
|
||||
|
||||
document.update!(
|
||||
external_link: normalized_link,
|
||||
name: page_title[0..254], content: content[0..14_999], status: :available
|
||||
)
|
||||
rescue StandardError => e
|
||||
@@ -28,6 +28,10 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def normalize_link(raw_link)
|
||||
raw_link.to_s.delete_suffix('/')
|
||||
end
|
||||
|
||||
def limit_exceeded?(account)
|
||||
limits = account.usage_limits[:captain][:documents]
|
||||
limits[:current_available].negative? || limits[:current_available].zero?
|
||||
|
||||
@@ -37,6 +37,7 @@ class Captain::Document < ApplicationRecord
|
||||
validate :validate_file_attachment, if: -> { pdf_file.attached? }
|
||||
before_validation :ensure_account_id
|
||||
before_validation :set_external_link_for_pdf
|
||||
before_validation :normalize_external_link
|
||||
|
||||
enum status: {
|
||||
in_progress: 0,
|
||||
@@ -47,7 +48,7 @@ class Captain::Document < ApplicationRecord
|
||||
after_create_commit :enqueue_crawl_job
|
||||
after_create_commit :update_document_usage
|
||||
after_destroy :update_document_usage
|
||||
after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
|
||||
after_commit :enqueue_response_builder_job
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
@@ -94,15 +95,18 @@ class Captain::Document < ApplicationRecord
|
||||
end
|
||||
|
||||
def enqueue_response_builder_job
|
||||
return if status != 'available'
|
||||
return unless should_enqueue_response_builder?
|
||||
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(self)
|
||||
end
|
||||
|
||||
def should_enqueue_response_builder?
|
||||
# Only enqueue when status changes to available
|
||||
# Avoid re-enqueueing when metadata is updated by the job itself
|
||||
saved_change_to_status? && status == 'available'
|
||||
return false if destroyed?
|
||||
return false unless available?
|
||||
|
||||
return saved_change_to_status? if pdf_document?
|
||||
|
||||
(saved_change_to_status? || saved_change_to_content?) && content.present?
|
||||
end
|
||||
|
||||
def update_document_usage
|
||||
@@ -140,4 +144,11 @@ class Captain::Document < ApplicationRecord
|
||||
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
|
||||
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
|
||||
end
|
||||
|
||||
def normalize_external_link
|
||||
return if external_link.blank?
|
||||
return if pdf_document?
|
||||
|
||||
self.external_link = external_link.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
require 'liquid'
|
||||
|
||||
class Captain::PromptRenderer
|
||||
class << self
|
||||
def render(template_name, context = {})
|
||||
template = load_template(template_name)
|
||||
liquid_template = Liquid::Template.parse(template)
|
||||
liquid_template.render(stringify_keys(context))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_template(template_name)
|
||||
template_path = Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
|
||||
|
||||
raise "Template not found: #{template_name}" unless File.exist?(template_path)
|
||||
|
||||
File.read(template_path)
|
||||
end
|
||||
|
||||
def stringify_keys(hash)
|
||||
hash.deep_stringify_keys
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Enterprise::Captain::PromptRenderer
|
||||
private
|
||||
|
||||
def candidate_paths(template_name)
|
||||
[
|
||||
Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid")
|
||||
] + super
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
require 'liquid'
|
||||
|
||||
class Captain::PromptRenderer
|
||||
class << self
|
||||
def render(template_name, context = {})
|
||||
template = load_template(template_name)
|
||||
liquid_template = Liquid::Template.parse(template)
|
||||
liquid_template.render(stringify_keys(context))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_template(template_name)
|
||||
candidate_paths(template_name).each do |template_path|
|
||||
next unless File.exist?(template_path)
|
||||
|
||||
return File.read(template_path)
|
||||
end
|
||||
|
||||
raise "Template not found: #{template_name}"
|
||||
end
|
||||
|
||||
def candidate_paths(template_name)
|
||||
[
|
||||
Rails.root.join('lib', 'captain', 'prompts', "#{template_name}.liquid")
|
||||
]
|
||||
end
|
||||
|
||||
def stringify_keys(hash)
|
||||
hash.deep_stringify_keys
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Captain::PromptRenderer.singleton_class.prepend_mod_with('Captain::PromptRenderer')
|
||||
@@ -0,0 +1,8 @@
|
||||
You are an expert writing editor. Rewrite the provided text to correct grammar, spelling, and punctuation while fully preserving the original meaning and style.
|
||||
|
||||
Rules:
|
||||
• Preserve tone, language, formatting, structure, proper nouns, URLs, quotes, numbers, IDs, addresses.
|
||||
• Do not modify content inside code blocks or backticks.
|
||||
• Do not add content or opinions. Keep length similar.
|
||||
• Minimal edits if already correct.
|
||||
• Do not alter legal, medical, or compliance meaning.
|
||||
@@ -0,0 +1,17 @@
|
||||
You are an expert writing editor. Rewrite the provided text according to the selected tone while keeping the original intent.
|
||||
|
||||
{% case tone %}
|
||||
{% when "professional" %}Rewrite in a formal and precise tone.
|
||||
{% when "casual" %}Rewrite in a relaxed and conversational tone.
|
||||
{% when "straightforward" %}Rewrite concise and direct. Remove filler.
|
||||
{% when "confident" %}Rewrite bold and assured.
|
||||
{% when "friendly" %}Rewrite warm and positive.
|
||||
{% else %}Rewrite to improve clarity and smoothness.
|
||||
{% endcase %}
|
||||
|
||||
Rules:
|
||||
• Preserve original language, meaning, formatting, structure, proper nouns, URLs, quotes, numbers, IDs, addresses.
|
||||
• Do not modify content inside code blocks or backticks.
|
||||
• Do not add content or opinions. Keep length similar.
|
||||
• Minimal edits if already correct.
|
||||
• Do not alter legal, medical, or compliance meaning.
|
||||
@@ -0,0 +1,19 @@
|
||||
As an AI-powered summarization tool, your task is to condense lengthy interactions between customer support agents and customers into brief, digestible summaries. The objective of these summaries is to provide a quick overview, enabling any agent, even those without prior context, to grasp the essence of the conversation promptly.
|
||||
|
||||
Make sure you strongly adhere to the following rules when generating the summary:
|
||||
|
||||
1. Be brief and concise. The shorter the summary the better.
|
||||
2. Aim to summarize the conversation in approximately 200 words, formatted as multiple small paragraphs that are easier to read.
|
||||
3. Describe the customer intent in around 50 words.
|
||||
4. Remove information that is not directly relevant to the customer's problem or the agent's solution. For example, personal anecdotes, small talk, etc.
|
||||
5. Don't include segments of the conversation that didn't contribute meaningful content, like greetings or farewell.
|
||||
6. The 'Action Items' should be a bullet list, arranged in order of priority if possible.
|
||||
7. 'Action Items' should strictly encapsulate tasks committed to by the agent or left incomplete. Any suggestions made by the agent should not be included.
|
||||
8. The 'Action Items' should be brief and concise.
|
||||
9. Mark important words or parts of sentences as bold.
|
||||
10. Apply markdown syntax to format any included code, using backticks.
|
||||
11. Include a section for "Follow-up Items" or "Open Questions" if there are any unresolved issues or outstanding questions.
|
||||
12. If any section does not have any content, remove that section and the heading from the response.
|
||||
13. Do not insert your own opinions about the conversation.
|
||||
|
||||
Reply in the user's language.
|
||||
@@ -23,7 +23,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
expect(document).to have_attributes(
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
external_link: payload[:metadata]['url'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
status: 'available'
|
||||
)
|
||||
end
|
||||
@@ -32,7 +32,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
external_link: payload[:metadata]['url'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: 'old content',
|
||||
name: 'old title',
|
||||
status: :in_progress)
|
||||
@@ -42,7 +42,9 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
end.not_to change(assistant.documents, :count)
|
||||
|
||||
existing_document.reload
|
||||
# Payload URL ends with '/', but we persist the canonical URL without it.
|
||||
expect(existing_document).to have_attributes(
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
status: 'available'
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:page_link) { 'https://example.com/page' }
|
||||
let(:page_link) { 'https://example.com/page/' }
|
||||
let(:page_title) { 'Example Page Title' }
|
||||
let(:content) { 'Some page content here' }
|
||||
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
|
||||
@@ -24,7 +24,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document.external_link).to eq(page_link)
|
||||
expect(document.external_link).to eq('https://example.com/page')
|
||||
expect(document.name).to eq(page_title)
|
||||
expect(document.content).to eq(content)
|
||||
expect(document.status).to eq('available')
|
||||
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
it 'updates existing document if one exists' do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
external_link: page_link,
|
||||
external_link: 'https://example.com/page',
|
||||
name: 'Old Title',
|
||||
content: 'Old content')
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@ RSpec.describe Captain::Document, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'URL normalization' do
|
||||
it 'removes a trailing slash before validation' do
|
||||
document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
external_link: 'https://example.com/path/')
|
||||
|
||||
expect(document.external_link).to eq('https://example.com/path')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF support' do
|
||||
let(:pdf_document) do
|
||||
doc = build(:captain_document, assistant: assistant, account: account)
|
||||
@@ -82,4 +93,161 @@ RSpec.describe Captain::Document, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'response builder job callback' do
|
||||
before { clear_enqueued_jobs }
|
||||
|
||||
describe 'non-PDF documents' do
|
||||
it 'enqueues when created with available status and content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when created available without content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available with existing content' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when status transitions to available without content' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil
|
||||
)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content is populated on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: nil
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Fresh content from crawl')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content changes on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Updated crawl content')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content is cleared on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue for metadata-only updates' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated Again' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue while document remains in progress' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF documents' do
|
||||
def build_pdf_document(status:, content:)
|
||||
build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: status,
|
||||
content: content
|
||||
).tap do |doc|
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('PDF content'),
|
||||
filename: 'sample.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'enqueues when created available without content' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
|
||||
expect do
|
||||
document.save!
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available' do
|
||||
document = build_pdf_document(status: :in_progress, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content updates without status change' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Extracted PDF text')
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not enqueue when the document is destroyed' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.destroy!
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+35
-17
@@ -1,17 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::PromptRenderer do
|
||||
let(:template_name) { 'test_template' }
|
||||
let(:template_content) { 'Hello {{name}}, your balance is {{balance}}' }
|
||||
let(:template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
|
||||
let(:enterprise_template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") }
|
||||
let(:oss_template_path) { Rails.root.join('lib', 'captain', 'prompts', "#{template_name}.liquid") }
|
||||
let(:context) { { name: 'John', balance: 100 } }
|
||||
|
||||
before do
|
||||
if defined?(Enterprise::Captain::PromptRenderer) &&
|
||||
!Captain::PromptRenderer.singleton_class.ancestors.include?(Enterprise::Captain::PromptRenderer)
|
||||
Captain::PromptRenderer.singleton_class.prepend(Enterprise::Captain::PromptRenderer)
|
||||
end
|
||||
|
||||
allow(File).to receive(:exist?).and_return(false)
|
||||
allow(File).to receive(:exist?).with(template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(template_path).and_return(template_content)
|
||||
allow(File).to receive(:read)
|
||||
|
||||
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(false)
|
||||
allow(File).to receive(:exist?).with(oss_template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(oss_template_path).and_return(template_content)
|
||||
end
|
||||
|
||||
describe '.render' do
|
||||
@@ -39,7 +46,7 @@ RSpec.describe Captain::PromptRenderer do
|
||||
nested_template = 'User: {{user.name}}, Account: {{user.account.type}}'
|
||||
nested_context = { user: { name: 'Alice', account: { type: 'premium' } } }
|
||||
|
||||
allow(File).to receive(:read).with(template_path).and_return(nested_template)
|
||||
allow(File).to receive(:read).with(oss_template_path).and_return(nested_template)
|
||||
|
||||
result = described_class.render(template_name, nested_context)
|
||||
|
||||
@@ -48,7 +55,7 @@ RSpec.describe Captain::PromptRenderer do
|
||||
|
||||
it 'handles empty context' do
|
||||
simple_template = 'Hello World'
|
||||
allow(File).to receive(:read).with(template_path).and_return(simple_template)
|
||||
allow(File).to receive(:read).with(oss_template_path).and_return(simple_template)
|
||||
|
||||
result = described_class.render(template_name, {})
|
||||
|
||||
@@ -65,30 +72,41 @@ RSpec.describe Captain::PromptRenderer do
|
||||
expect(result).to eq('rendered')
|
||||
expect(Liquid::Template).to have_received(:parse).with(template_content)
|
||||
end
|
||||
|
||||
it 'prefers enterprise template when available' do
|
||||
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(enterprise_template_path).and_return('Enterprise {{name}}')
|
||||
|
||||
result = described_class.render(template_name, context)
|
||||
|
||||
expect(result).to eq('Enterprise John')
|
||||
expect(File).to have_received(:read).with(enterprise_template_path)
|
||||
expect(File).not_to have_received(:read).with(oss_template_path)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.load_template' do
|
||||
it 'reads template file from correct path' do
|
||||
it 'reads template file from OSS path when enterprise file absent' do
|
||||
described_class.send(:load_template, template_name)
|
||||
|
||||
expect(File).to have_received(:read).with(template_path)
|
||||
expect(File).to have_received(:read).with(oss_template_path)
|
||||
end
|
||||
|
||||
it 'raises error when template does not exist' do
|
||||
allow(File).to receive(:exist?).with(template_path).and_return(false)
|
||||
it 'raises error when template does not exist in either location' do
|
||||
allow(File).to receive(:exist?).with(oss_template_path).and_return(false)
|
||||
|
||||
expect { described_class.send(:load_template, template_name) }
|
||||
.to raise_error("Template not found: #{template_name}")
|
||||
end
|
||||
|
||||
it 'constructs correct template path' do
|
||||
expected_path = Rails.root.join('enterprise/lib/captain/prompts/my_template.liquid')
|
||||
allow(File).to receive(:exist?).with(expected_path).and_return(true)
|
||||
allow(File).to receive(:read).with(expected_path).and_return('test content')
|
||||
it 'checks enterprise path before OSS path' do
|
||||
allow(File).to receive(:exist?).with(enterprise_template_path).and_return(true)
|
||||
allow(File).to receive(:read).with(enterprise_template_path).and_return('enterprise')
|
||||
|
||||
described_class.send(:load_template, 'my_template')
|
||||
described_class.send(:load_template, template_name)
|
||||
|
||||
expect(File).to have_received(:exist?).with(expected_path)
|
||||
expect(File).to have_received(:exist?).with(enterprise_template_path)
|
||||
expect(File).not_to have_received(:read).with(oss_template_path)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::ChangeToneService do
|
||||
let(:text) { 'Hey! Just checking if you got my email about the project' }
|
||||
let(:tone) { 'professional' }
|
||||
let(:service) { described_class.new(text: text, tone: tone) }
|
||||
let(:agent) { instance_double(Agents::Agent) }
|
||||
let(:runner) { instance_double(Agents::Runner) }
|
||||
let(:result) do
|
||||
instance_double(Agents::RunResult,
|
||||
output: {
|
||||
'rewritten_text' => 'Good afternoon. I am following up regarding my previous email about the project.',
|
||||
'tone_applied' => 'professional'
|
||||
},
|
||||
error: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
|
||||
allow(Agents::Agent).to receive(:new).and_return(agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
|
||||
allow(Captain::PromptRenderer).to receive(:render).and_return('tone prompt')
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'initializes with text and tone' do
|
||||
expect(service.instance_variable_get(:@text)).to eq(text)
|
||||
expect(service.instance_variable_get(:@tone)).to eq('professional')
|
||||
end
|
||||
|
||||
it 'converts tone to lowercase' do
|
||||
service = described_class.new(text: text, tone: 'PROFESSIONAL')
|
||||
expect(service.instance_variable_get(:@tone)).to eq('professional')
|
||||
end
|
||||
|
||||
context 'with invalid tone' do
|
||||
it 'raises ArgumentError' do
|
||||
expect do
|
||||
described_class.new(text: text, tone: 'invalid_tone')
|
||||
end.to raise_error(ArgumentError, /Unsupported tone: invalid_tone/)
|
||||
end
|
||||
|
||||
it 'includes supported tones in error message' do
|
||||
expect do
|
||||
described_class.new(text: text, tone: 'invalid')
|
||||
end.to raise_error(ArgumentError, /professional, casual, straightforward, confident, friendly/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when successful' do
|
||||
it 'renders the tone prompt with correct context' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'rewrite/tone',
|
||||
hash_including(tone: 'professional')
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'builds agent with correct parameters' do
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
name: 'ToneChanger',
|
||||
instructions: 'tone prompt',
|
||||
model: 'gpt-4o-mini',
|
||||
response_schema: service.send(:response_schema)
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns success response with rewritten text' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:rewritten_text]).to eq('Good afternoon. I am following up regarding my previous email about the project.')
|
||||
expect(response[:tone]).to eq('professional')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different tones' do
|
||||
%w[professional casual straightforward confident friendly].each do |test_tone|
|
||||
it "works with #{test_tone} tone" do
|
||||
service = described_class.new(text: text, tone: test_tone)
|
||||
allow(Agents::Agent).to receive(:new).and_return(agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
|
||||
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'rewrite/tone',
|
||||
hash_including(tone: test_tone)
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent returns an error' do
|
||||
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('Model error')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exception is raised' do
|
||||
before do
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with(/ChangeToneService error: API timeout/)
|
||||
expect(Rails.logger).to receive(:error).with(anything)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('API timeout')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_name' do
|
||||
it 'returns ToneChanger' do
|
||||
expect(service.send(:agent_name)).to eq('ToneChanger')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_instructions' do
|
||||
it 'renders the tone template with tone context' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with(
|
||||
'rewrite/tone',
|
||||
hash_including(tone: 'professional')
|
||||
)
|
||||
service.send(:build_instructions)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#response_schema' do
|
||||
let(:schema) { service.send(:response_schema) }
|
||||
|
||||
it 'defines object type' do
|
||||
expect(schema[:type]).to eq('object')
|
||||
end
|
||||
|
||||
it 'includes rewritten_text property' do
|
||||
expect(schema[:properties][:rewritten_text]).to include(
|
||||
type: 'string',
|
||||
description: 'The rewritten text with the requested tone applied'
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes tone_applied property' do
|
||||
expect(schema[:properties][:tone_applied]).to include(
|
||||
type: 'string',
|
||||
description: 'The tone that was applied to the text'
|
||||
)
|
||||
end
|
||||
|
||||
it 'marks both fields as required' do
|
||||
expect(schema[:required]).to match_array(%w[rewritten_text tone_applied])
|
||||
end
|
||||
|
||||
it 'disallows additional properties' do
|
||||
expect(schema[:additionalProperties]).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_success_response' do
|
||||
let(:output) do
|
||||
{
|
||||
'rewritten_text' => 'Rewritten content',
|
||||
'tone_applied' => 'professional'
|
||||
}
|
||||
end
|
||||
|
||||
it 'extracts rewritten_text from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:rewritten_text]).to eq('Rewritten content')
|
||||
end
|
||||
|
||||
it 'includes the requested tone' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:tone]).to eq('professional')
|
||||
end
|
||||
|
||||
it 'includes original text' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
|
||||
it 'marks response as successful' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:success]).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'SUPPORTED_TONES constant' do
|
||||
it 'includes all valid tones' do
|
||||
expect(described_class::SUPPORTED_TONES).to match_array(
|
||||
%w[professional casual straightforward confident friendly]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'End-to-End Tests' do
|
||||
context 'when making real API call (stubbed at HTTP level)' do
|
||||
let(:openai_response) do
|
||||
{
|
||||
id: 'chatcmpl-123',
|
||||
object: 'chat.completion',
|
||||
created: 1_677_652_288,
|
||||
model: 'gpt-4o-mini',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: JSON.generate({
|
||||
rewritten_text: 'Good afternoon. I am following up regarding my previous email about the project.',
|
||||
tone_applied: 'professional'
|
||||
})
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.to_return(status: 200, body: openai_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'successfully changes tone through entire stack' do
|
||||
response = service.execute
|
||||
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:rewritten_text]).to eq('Good afternoon. I am following up regarding my previous email about the project.')
|
||||
expect(response[:tone]).to eq('professional')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,222 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::ConversationSummaryService do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:service) { described_class.new(conversation: conversation) }
|
||||
let(:agent) { instance_double(Agents::Agent) }
|
||||
let(:runner) { instance_double(Agents::Runner) }
|
||||
let(:result) do
|
||||
instance_double(Agents::RunResult,
|
||||
output: {
|
||||
'customer_intent' => 'Customer wants to reset their password',
|
||||
'conversation_summary' => 'The customer contacted support to reset their password. ' \
|
||||
'Agent provided instructions and confirmed the reset was successful.',
|
||||
'action_items' => ['Send password reset email', 'Verify account security'],
|
||||
'follow_up_items' => ['Check if customer needs additional security settings']
|
||||
},
|
||||
error: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'I need to reset my password')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'I can help you with that')
|
||||
allow(Agents::Agent).to receive(:new).and_return(agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
|
||||
allow(Captain::PromptRenderer).to receive(:render).and_return('summary prompt')
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'initializes with conversation' do
|
||||
expect(service.instance_variable_get(:@conversation)).to eq(conversation)
|
||||
end
|
||||
|
||||
it 'formats conversation messages as text' do
|
||||
text = service.instance_variable_get(:@text)
|
||||
expect(text).to include('I need to reset my password')
|
||||
expect(text).to include('I can help you with that')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when successful' do
|
||||
it 'renders the summary prompt' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with('summary', {})
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'builds agent with correct parameters' do
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
name: 'ConversationSummarizer',
|
||||
instructions: 'summary prompt',
|
||||
model: 'gpt-4o-mini',
|
||||
response_schema: service.send(:response_schema)
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns success response with structured data' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:structured_data][:customer_intent]).to eq('Customer wants to reset their password')
|
||||
expect(response[:structured_data][:conversation_summary]).to include('contacted support')
|
||||
expect(response[:structured_data][:action_items]).to include('Send password reset email')
|
||||
expect(response[:structured_data][:follow_up_items]).to include('Check if customer needs additional security settings')
|
||||
end
|
||||
|
||||
it 'includes markdown formatted summary' do
|
||||
response = service.execute
|
||||
expect(response[:summary]).to include('**Customer Intent**')
|
||||
expect(response[:summary]).to include('**Conversation Summary**')
|
||||
expect(response[:summary]).to include('**Action Items**')
|
||||
expect(response[:summary]).to include('**Follow-up Items**')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent returns an error' do
|
||||
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('Model error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exception is raised' do
|
||||
before do
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with(/ConversationSummaryService error: API timeout/)
|
||||
expect(Rails.logger).to receive(:error).with(anything)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('API timeout')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_name' do
|
||||
it 'returns ConversationSummarizer' do
|
||||
expect(service.send(:agent_name)).to eq('ConversationSummarizer')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_instructions' do
|
||||
it 'renders the summary template' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with('summary', {})
|
||||
service.send(:build_instructions)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#response_schema' do
|
||||
let(:schema) { service.send(:response_schema) }
|
||||
|
||||
it 'defines object type' do
|
||||
expect(schema[:type]).to eq('object')
|
||||
end
|
||||
|
||||
it 'includes customer_intent property' do
|
||||
expect(schema[:properties][:customer_intent]).to include(
|
||||
type: 'string',
|
||||
description: 'Brief description of what the customer wants (around 50 words)'
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes conversation_summary property' do
|
||||
expect(schema[:properties][:conversation_summary]).to include(
|
||||
type: 'string',
|
||||
description: 'Summary of the conversation in approximately 200 words'
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes action_items property' do
|
||||
expect(schema[:properties][:action_items][:type]).to eq('array')
|
||||
expect(schema[:properties][:action_items][:items][:type]).to eq('string')
|
||||
end
|
||||
|
||||
it 'includes follow_up_items property' do
|
||||
expect(schema[:properties][:follow_up_items][:type]).to eq('array')
|
||||
expect(schema[:properties][:follow_up_items][:items][:type]).to eq('string')
|
||||
end
|
||||
|
||||
it 'marks required fields' do
|
||||
expect(schema[:required]).to match_array(%w[customer_intent conversation_summary])
|
||||
end
|
||||
|
||||
it 'disallows additional properties' do
|
||||
expect(schema[:additionalProperties]).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_success_response' do
|
||||
let(:output) do
|
||||
{
|
||||
'customer_intent' => 'Reset password',
|
||||
'conversation_summary' => 'Password reset conversation',
|
||||
'action_items' => ['Item 1', 'Item 2'],
|
||||
'follow_up_items' => ['Follow-up 1']
|
||||
}
|
||||
end
|
||||
|
||||
it 'extracts customer_intent from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:structured_data][:customer_intent]).to eq('Reset password')
|
||||
end
|
||||
|
||||
it 'extracts conversation_summary from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:structured_data][:conversation_summary]).to eq('Password reset conversation')
|
||||
end
|
||||
|
||||
it 'extracts action_items from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:structured_data][:action_items]).to eq(['Item 1', 'Item 2'])
|
||||
end
|
||||
|
||||
it 'extracts follow_up_items from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:structured_data][:follow_up_items]).to eq(['Follow-up 1'])
|
||||
end
|
||||
|
||||
it 'builds markdown summary' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:summary]).to include('**Customer Intent**')
|
||||
expect(response[:summary]).to include('Reset password')
|
||||
end
|
||||
|
||||
it 'marks response as successful' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:success]).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#format_conversation_messages' do
|
||||
it 'includes incoming and outgoing messages' do
|
||||
text = service.send(:format_conversation_messages)
|
||||
expect(text).to include('Customer')
|
||||
expect(text).to include('Agent')
|
||||
end
|
||||
|
||||
it 'excludes private messages' do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Private note', private: true)
|
||||
text = service.send(:format_conversation_messages)
|
||||
expect(text).not_to include('Private note')
|
||||
end
|
||||
|
||||
it 'respects token limit' do
|
||||
long_content = 'a' * 20_000
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: long_content)
|
||||
text = service.send(:format_conversation_messages)
|
||||
expect(text.length).to be <= described_class::TOKEN_LIMIT
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,249 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::FixGrammarService do
|
||||
let(:text) { 'their going too the store tommorow and there bringing they\'re friends' }
|
||||
let(:service) { described_class.new(text: text) }
|
||||
let(:agent) { instance_double(Agents::Agent) }
|
||||
let(:runner) { instance_double(Agents::Runner) }
|
||||
let(:result) do
|
||||
instance_double(Agents::RunResult,
|
||||
output: {
|
||||
'corrected_text' => 'They\'re going to the store tomorrow and they\'re bringing their friends.',
|
||||
'corrections_made' => [
|
||||
'Changed "their" to "They\'re"',
|
||||
'Changed "too" to "to"',
|
||||
'Fixed spelling: "tommorow" to "tomorrow"',
|
||||
'Changed "there" to "they\'re"',
|
||||
'Changed "they\'re" to "their"'
|
||||
]
|
||||
},
|
||||
error: nil)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4o-mini')
|
||||
allow(Agents::Agent).to receive(:new).and_return(agent)
|
||||
allow(Agents::Runner).to receive(:with_agents).and_return(runner)
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_return(result)
|
||||
allow(Captain::PromptRenderer).to receive(:render).and_return('grammar prompt')
|
||||
end
|
||||
|
||||
describe '#initialize' do
|
||||
it 'initializes with text only' do
|
||||
expect(service.instance_variable_get(:@text)).to eq(text)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
context 'when successful' do
|
||||
it 'renders the grammar prompt' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with('rewrite/grammar', {})
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'builds agent with correct parameters' do
|
||||
expect(Agents::Agent).to receive(:new).with(
|
||||
name: 'GrammarFixer',
|
||||
instructions: 'grammar prompt',
|
||||
model: 'gpt-4o-mini',
|
||||
response_schema: service.send(:response_schema)
|
||||
)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns success response with corrected text' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:corrected_text]).to eq('They\'re going to the store tomorrow and they\'re bringing their friends.')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
|
||||
it 'includes corrections made' do
|
||||
response = service.execute
|
||||
expect(response[:corrections_made]).to be_an(Array)
|
||||
expect(response[:corrections_made]).to include('Changed "their" to "They\'re"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when output does not include corrections_made' do
|
||||
let(:result) do
|
||||
instance_double(Agents::RunResult,
|
||||
output: {
|
||||
'corrected_text' => 'Corrected text without corrections list'
|
||||
},
|
||||
error: nil)
|
||||
end
|
||||
|
||||
it 'returns empty array for corrections_made' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:corrections_made]).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent returns an error' do
|
||||
let(:result) { instance_double(Agents::RunResult, output: { error: 'Model error' }, error: nil) }
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('Model error')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when exception is raised' do
|
||||
before do
|
||||
allow(runner).to receive(:run).with(anything, context: anything).and_raise(StandardError.new('API timeout'))
|
||||
end
|
||||
|
||||
it 'logs the error' do
|
||||
expect(Rails.logger).to receive(:error).with(/FixGrammarService error: API timeout/)
|
||||
expect(Rails.logger).to receive(:error).with(anything)
|
||||
service.execute
|
||||
end
|
||||
|
||||
it 'returns error response' do
|
||||
response = service.execute
|
||||
expect(response[:success]).to be false
|
||||
expect(response[:error]).to eq('API timeout')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_name' do
|
||||
it 'returns GrammarFixer' do
|
||||
expect(service.send(:agent_name)).to eq('GrammarFixer')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_instructions' do
|
||||
it 'renders the grammar template with empty context' do
|
||||
expect(Captain::PromptRenderer).to receive(:render).with('rewrite/grammar', {})
|
||||
service.send(:build_instructions)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#response_schema' do
|
||||
let(:schema) { service.send(:response_schema) }
|
||||
|
||||
it 'defines object type' do
|
||||
expect(schema[:type]).to eq('object')
|
||||
end
|
||||
|
||||
it 'includes corrected_text property' do
|
||||
expect(schema[:properties][:corrected_text]).to include(
|
||||
type: 'string',
|
||||
description: 'The text with corrected grammar, spelling, and punctuation'
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes corrections_made property' do
|
||||
expect(schema[:properties][:corrections_made]).to include(
|
||||
type: 'array',
|
||||
description: 'List of corrections that were made'
|
||||
)
|
||||
end
|
||||
|
||||
it 'marks corrected_text as required' do
|
||||
expect(schema[:required]).to include('corrected_text')
|
||||
end
|
||||
|
||||
it 'disallows additional properties' do
|
||||
expect(schema[:additionalProperties]).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_success_response' do
|
||||
let(:output) do
|
||||
{
|
||||
'corrected_text' => 'Corrected content',
|
||||
'corrections_made' => ['Fix 1', 'Fix 2']
|
||||
}
|
||||
end
|
||||
|
||||
it 'extracts corrected_text from output' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:corrected_text]).to eq('Corrected content')
|
||||
end
|
||||
|
||||
it 'includes corrections made' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:corrections_made]).to eq(['Fix 1', 'Fix 2'])
|
||||
end
|
||||
|
||||
it 'includes original text' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
|
||||
it 'marks response as successful' do
|
||||
response = service.send(:build_success_response, output)
|
||||
expect(response[:success]).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_corrections' do
|
||||
it 'returns array when output has corrections_made as symbol key' do
|
||||
output = { corrections_made: ['Fix 1'] }
|
||||
expect(service.send(:extract_corrections, output)).to eq(['Fix 1'])
|
||||
end
|
||||
|
||||
it 'returns array when output has corrections_made as string key' do
|
||||
output = { 'corrections_made' => ['Fix 1'] }
|
||||
expect(service.send(:extract_corrections, output)).to eq(['Fix 1'])
|
||||
end
|
||||
|
||||
it 'returns empty array when output is not a hash' do
|
||||
expect(service.send(:extract_corrections, 'string')).to eq([])
|
||||
end
|
||||
|
||||
it 'returns empty array when corrections_made is missing' do
|
||||
expect(service.send(:extract_corrections, {})).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'End-to-End Tests' do
|
||||
context 'when making real API call (stubbed at HTTP level)' do
|
||||
let(:openai_response) do
|
||||
{
|
||||
id: 'chatcmpl-456',
|
||||
object: 'chat.completion',
|
||||
created: 1_677_652_300,
|
||||
model: 'gpt-4o-mini',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: JSON.generate({
|
||||
corrected_text: 'They\'re going to the store tomorrow and they\'re bringing their friends.',
|
||||
corrections_made: [
|
||||
'Changed "their" to "They\'re"',
|
||||
'Changed "too" to "to"',
|
||||
'Fixed spelling: "tommorow" to "tomorrow"'
|
||||
]
|
||||
})
|
||||
},
|
||||
finish_reason: 'stop'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:post, 'https://api.openai.com/v1/chat/completions')
|
||||
.to_return(status: 200, body: openai_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'successfully fixes grammar through entire stack' do
|
||||
response = service.execute
|
||||
|
||||
expect(response[:success]).to be true
|
||||
expect(response[:corrected_text]).to eq('They\'re going to the store tomorrow and they\'re bringing their friends.')
|
||||
expect(response[:corrections_made]).to include('Changed "their" to "They\'re"')
|
||||
expect(response[:original_text]).to eq(text)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user