feat: Allow customizing the responses, flows in Captain (#11385)

- Ability to provide custom instructions to captain

<img width="1107" alt="Screenshot 2025-04-28 at 6 11 43 PM"
src="https://github.com/user-attachments/assets/f94cbccc-b4d8-48fd-b6b9-55524129bc50"
/>
This commit is contained in:
Pranav
2025-04-29 15:42:15 -07:00
committed by GitHub
parent 970e76ace8
commit fb6409508b
21 changed files with 823 additions and 32 deletions

View File

@@ -14,6 +14,13 @@ class CaptainAssistant extends ApiClient {
}, },
}); });
} }
playground({ assistantId, messageContent, messageHistory }) {
return axios.post(`${this.url}/${assistantId}/playground`, {
message_content: messageContent,
message_history: messageHistory,
});
}
} }
export default new CaptainAssistant(); export default new CaptainAssistant();

View File

@@ -0,0 +1,39 @@
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
title: { type: String, required: true },
isOpen: { type: Boolean, default: false },
});
const isExpanded = ref(props.isOpen);
const toggleAccordion = () => {
isExpanded.value = !isExpanded.value;
};
watch(
() => props.isOpen,
newValue => {
isExpanded.value = newValue;
}
);
</script>
<template>
<div class="border rounded-lg border-n-slate-4">
<button
class="flex items-center justify-between w-full p-4 text-left"
@click="toggleAccordion"
>
<span class="text-sm font-medium text-n-slate-12">{{ title }}</span>
<span
class="w-5 h-5 transition-transform duration-200 i-lucide-chevron-down"
:class="{ 'rotate-180': isExpanded }"
/>
</button>
<div v-if="isExpanded" class="p-4 pt-0">
<slot />
</div>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { usePolicy } from 'dashboard/composables/usePolicy'; import { usePolicy } from 'dashboard/composables/usePolicy';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
@@ -23,6 +24,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
backUrl: {
type: [String, Object],
default: '',
},
buttonPolicy: { buttonPolicy: {
type: Array, type: Array,
default: () => [], default: () => [],
@@ -39,6 +44,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
showKnowMore: {
type: Boolean,
default: true,
},
isEmpty: { isEmpty: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -73,19 +82,23 @@ const handlePageChange = event => {
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
> >
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<BackButton v-if="backUrl" :to="backUrl" />
<slot name="headerTitle"> <slot name="headerTitle">
<span class="text-xl font-medium text-n-slate-12"> <span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
</span> </span>
</slot> </slot>
<div v-if="!isEmpty" class="flex items-center gap-2"> <div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" /> <div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" /> <slot name="knowMore" />
</div> </div>
</div> </div>
<div <div
v-if="!showPaywall" v-if="!showPaywall && buttonLabel"
v-on-clickaway="() => emit('close')" v-on-clickaway="() => emit('close')"
class="relative group/campaign-button" class="relative group/campaign-button"
> >
@@ -104,7 +117,7 @@ const handlePageChange = event => {
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0"> <main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[60rem] mx-auto py-4"> <div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
v-if="isFetching" v-if="isFetching"

View File

@@ -76,9 +76,12 @@ const handleAction = ({ action, value }) => {
<template> <template>
<CardLayout> <CardLayout>
<div class="flex justify-between w-full gap-1"> <div class="flex justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1"> <router-link
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }} {{ name }}
</span> </router-link>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"

View File

@@ -0,0 +1,111 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MessageList from './MessageList.vue';
import CaptainAssistant from 'dashboard/api/captain/assistant';
const { assistantId } = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const messages = ref([]);
const newMessage = ref('');
const isLoading = ref(false);
const formatMessagesForApi = () => {
return messages.value.map(message => ({
role: message.sender,
content: message.content,
}));
};
const resetConversation = () => {
messages.value = [];
newMessage.value = '';
};
const sendMessage = async () => {
if (!newMessage.value.trim() || isLoading.value) return;
const userMessage = {
content: newMessage.value,
sender: 'user',
timestamp: new Date().toISOString(),
};
messages.value.push(userMessage);
const currentMessage = newMessage.value;
newMessage.value = '';
try {
isLoading.value = true;
const { data } = await CaptainAssistant.playground({
assistantId,
messageContent: currentMessage,
messageHistory: formatMessagesForApi(),
});
messages.value.push({
content: data.response,
sender: 'assistant',
timestamp: new Date().toISOString(),
});
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error getting assistant response:', error);
} finally {
isLoading.value = false;
}
};
</script>
<template>
<div
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
>
<div class="mb-4">
<div class="flex justify-between items-center mb-1">
<h3 class="text-lg font-medium">
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
</h3>
<NextButton
ghost
size="small"
icon="i-lucide-rotate-ccw"
@click="resetConversation"
/>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.PLAYGROUND.DESCRIPTION') }}
</p>
</div>
<MessageList :messages="messages" :is-loading="isLoading" />
<div
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
>
<input
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
/>
<NextButton
ghost
size="small"
:disabled="!newMessage.trim()"
icon="i-lucide-send"
@click="sendMessage"
/>
</div>
<p class="text-xs text-n-slate-11 pt-2 text-center">
{{ t('CAPTAIN.PLAYGROUND.CREDIT_NOTE') }}
</p>
</div>
</template>

View File

@@ -0,0 +1,91 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref, watch, nextTick } from 'vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
messages: {
type: Array,
required: true,
},
isLoading: {
type: Boolean,
default: false,
},
});
const messageContainer = ref(null);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const isUserMessage = sender => sender === 'user';
const getMessageAlignment = sender =>
isUserMessage(sender) ? 'justify-end' : 'justify-start';
const getMessageDirection = sender =>
isUserMessage(sender) ? 'flex-row-reverse' : 'flex-row';
const getAvatarName = sender =>
isUserMessage(sender)
? t('CAPTAIN.PLAYGROUND.USER')
: t('CAPTAIN.PLAYGROUND.ASSISTANT');
const getMessageStyle = sender =>
isUserMessage(sender)
? 'bg-n-strong text-n-white'
: 'bg-n-solid-iris text-n-slate-12';
const scrollToBottom = async () => {
await nextTick();
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
}
};
watch(() => props.messages.length, scrollToBottom);
</script>
<template>
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
<div
v-for="(message, index) in messages"
:key="index"
class="flex"
:class="getMessageAlignment(message.sender)"
>
<div
class="flex items-start gap-1.5"
:class="getMessageDirection(message.sender)"
>
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
<div
class="max-w-[80%] rounded-lg p-3 text-sm"
:class="getMessageStyle(message.sender)"
>
<div v-html="formatMessage(message.content)" />
</div>
</div>
</div>
<div v-if="isLoading" class="flex justify-start">
<div class="flex items-start gap-1.5">
<Avatar :name="getAvatarName('assistant')" rounded-full :size="24" />
<div
class="max-w-sm rounded-lg p-3 text-sm bg-n-solid-iris text-n-slate-12"
>
<div class="flex gap-1">
<div class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce" />
<div
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.2s]"
/>
<div
class="w-2 h-2 rounded-full bg-n-iris-10 animate-bounce [animation-delay:0.4s]"
/>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,306 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
};
const initialState = {
name: '',
description: '',
productName: '',
welcomeMessage: '',
handoffMessage: '',
resolutionMessage: '',
instructions: '',
features: {
conversationFaqs: false,
memories: false,
},
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
welcomeMessage: { minLength: minLength(1) },
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
welcomeMessage: getErrorMessage('welcomeMessage'),
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.welcomeMessage = config.welcome_message;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
};
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
product_name: state.productName,
};
emit('submit', payload);
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.welcomeMessage.$validate(),
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
welcome_message: state.welcomeMessage,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
},
};
emit('submit', payload);
};
const handleInstructionsUpdate = async () => {
const result = await v$.value.instructions.$validate();
if (!result) return;
const payload = {
config: {
...props.assistant.config,
instructions: state.instructions,
},
};
emit('submit', payload);
};
const handleFeaturesUpdate = () => {
const payload = {
config: {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (props.mode === 'edit' && newAssistant) {
updateStateFromAssistant(newAssistant);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Basic Information Section -->
<Accordion
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
is-open
>
<div class="flex flex-col gap-4 pt-4">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleBasicInfoUpdate"
>
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4 pt-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="2000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleInstructionsUpdate"
/>
</div>
</div>
</Accordion>
<!-- Greeting Messages Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
<div class="flex flex-col gap-4 pt-4">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</Accordion>
<!-- Features Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input
v-model="state.features.conversationFaqs"
type="checkbox"
class="form-checkbox"
/>
{{
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
}}
</label>
<label class="flex items-center gap-2">
<input
v-model="state.features.memories"
type="checkbox"
class="form-checkbox"
/>
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
</div>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleFeaturesUpdate"
/>
</div>
</div>
</Accordion>
</form>
</template>

View File

@@ -333,6 +333,14 @@
"RESET": "Reset", "RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant" "SELECT_ASSISTANT": "Select Assistant"
}, },
"PLAYGROUND": {
"USER": "You",
"ASSISTANT": "Assistant",
"MESSAGE_PLACEHOLDER": "Type your message...",
"HEADER": "Playground",
"DESCRIPTION": "Use this playground to send messages to your assistant and check if it responds accurately, quickly, and in the tone you expect.",
"CREDIT_NOTE": "Messages sent here will count toward your Captain credits."
},
"PAYWALL": { "PAYWALL": {
"TITLE": "Upgrade to use Captain AI", "TITLE": "Upgrade to use Captain AI",
"AVAILABLE_ON": "Captain is not available on the free plan.", "AVAILABLE_ON": "Captain is not available on the free plan.",
@@ -371,20 +379,41 @@
"ERROR_MESSAGE": "There was an error creating the assistant, please try again." "ERROR_MESSAGE": "There was an error creating the assistant, please try again."
}, },
"FORM": { "FORM": {
"UPDATE": "Update",
"SECTIONS": {
"BASIC_INFO": "Basic Information",
"SYSTEM_MESSAGES": "System Messages",
"INSTRUCTIONS": "Instructions",
"FEATURES": "Features",
"TOOLS": "Tools "
},
"NAME": { "NAME": {
"LABEL": "Assistant Name", "LABEL": "Name",
"PLACEHOLDER": "Enter a name for the assistant", "PLACEHOLDER": "Enter assistant name"
"ERROR": "Please provide a name for the assistant"
}, },
"DESCRIPTION": { "DESCRIPTION": {
"LABEL": "Assistant Description", "LABEL": "Description",
"PLACEHOLDER": "Describe how and where this assistant will be used", "PLACEHOLDER": "Enter assistant description"
"ERROR": "A description is required"
}, },
"PRODUCT_NAME": { "PRODUCT_NAME": {
"LABEL": "Product Name", "LABEL": "Product Name",
"PLACEHOLDER": "Enter the name of the product this assistant is designed for", "PLACEHOLDER": "Enter product name"
"ERROR": "The product name is required" },
"WELCOME_MESSAGE": {
"LABEL": "Welcome Message",
"PLACEHOLDER": "Enter welcome message"
},
"HANDOFF_MESSAGE": {
"LABEL": "Handoff Message",
"PLACEHOLDER": "Enter handoff message"
},
"RESOLUTION_MESSAGE": {
"LABEL": "Resolution Message",
"PLACEHOLDER": "Enter resolution message"
},
"INSTRUCTIONS": {
"LABEL": "Instructions",
"PLACEHOLDER": "Enter instructions for the assistant"
}, },
"FEATURES": { "FEATURES": {
"TITLE": "Features", "TITLE": "Features",
@@ -395,7 +424,8 @@
"EDIT": { "EDIT": {
"TITLE": "Update the assistant", "TITLE": "Update the assistant",
"SUCCESS_MESSAGE": "The assistant has been successfully updated", "SUCCESS_MESSAGE": "The assistant has been successfully updated",
"ERROR_MESSAGE": "There was an error updating the assistant, please try again." "ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
"NOT_FOUND": "Could not find the assistant. Please try again."
}, },
"OPTIONS": { "OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant", "EDIT_ASSISTANT": "Edit Assistant",

View File

@@ -0,0 +1,71 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import EditAssistantForm from '../../../../components-next/captain/pageComponents/assistant/EditAssistantForm.vue';
import AssistantPlayground from 'dashboard/components-next/captain/assistant/AssistantPlayground.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const isAssistantAvailable = computed(() => !!assistant.value?.id);
const handleSubmit = async updatedAssistant => {
try {
await store.dispatch('captainAssistants/update', {
id: assistantId,
...updatedAssistant,
});
useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.message || t('CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
onMounted(() => {
if (!isAssistantAvailable.value) {
store.dispatch('captainAssistants/show', assistantId);
}
});
</script>
<template>
<PageLayout
:header-title="assistant?.name"
:show-pagination-footer="false"
:is-fetching="isFetching"
:show-know-more="false"
:back-url="{ name: 'captain_assistants_index' }"
>
<template #body>
<div v-if="!isAssistantAvailable">
{{ t('CAPTAIN.ASSISTANTS.EDIT.NOT_FOUND') }}
</div>
<div v-else class="flex gap-4 h-full">
<div class="flex-1 lg:overflow-auto pr-4 h-full md:h-auto">
<EditAssistantForm
:assistant="assistant"
mode="edit"
@submit="handleSubmit"
/>
</div>
<div class="w-[400px] hidden lg:block h-full">
<AssistantPlayground :assistant-id="Number(assistantId)" />
</div>
</div>
</template>
</PageLayout>
</template>

View File

@@ -36,8 +36,10 @@ const handleCreate = () => {
}; };
const handleEdit = () => { const handleEdit = () => {
dialogType.value = 'edit'; router.push({
nextTick(() => createAssistantDialog.value.dialogRef.open()); name: 'captain_assistants_edit',
params: { assistantId: selectedAssistant.value.id },
});
}; };
const handleViewConnectedInboxes = () => { const handleViewConnectedInboxes = () => {

View File

@@ -2,6 +2,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from '../../../helper/URLHelper'; import { frontendURL } from '../../../helper/URLHelper';
import AssistantIndex from './assistants/Index.vue'; import AssistantIndex from './assistants/Index.vue';
import AssistantEdit from './assistants/Edit.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue'; import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import DocumentsIndex from './documents/Index.vue'; import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue'; import ResponsesIndex from './responses/Index.vue';
@@ -20,6 +21,19 @@ export const routes = [
], ],
}, },
}, },
{
path: frontendURL('accounts/:accountId/captain/assistants/:assistantId'),
component: AssistantEdit,
name: 'captain_assistants_edit',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{ {
path: frontendURL( path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/inboxes' 'accounts/:accountId/captain/assistants/:assistantId/inboxes'

View File

@@ -53,6 +53,9 @@ Rails.application.routes.draw do
end end
namespace :captain do namespace :captain do
resources :assistants do resources :assistants do
member do
post :playground
end
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
end end
resources :documents, only: [:index, :show, :create, :destroy] resources :documents, only: [:index, :show, :create, :destroy]

View File

@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
before_action :current_account before_action :current_account
before_action -> { check_authorization(Captain::Assistant) } before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy] before_action :set_assistant, only: [:show, :update, :destroy, :playground]
def index def index
@assistants = account_assistants.ordered @assistants = account_assistants.ordered
@@ -23,6 +23,15 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
head :no_content head :no_content
end end
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
params[:message_content],
message_history
)
render json: response
end
private private
def set_assistant def set_assistant
@@ -34,6 +43,19 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end end
def assistant_params def assistant_params
params.require(:assistant).permit(:name, :description, config: [:product_name, :feature_faq, :feature_memory]) params.require(:assistant).permit(:name, :description,
config: [
:product_name, :feature_faq, :feature_memory,
:welcome_message, :handoff_message, :resolution_message,
:instructions
])
end
def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
end
def message_history
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
end end
end end

View File

@@ -36,6 +36,7 @@ module Captain::ChatHelper
end end
def handle_response(response) def handle_response(response)
Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
message = response.dig('choices', 0, 'message') message = response.dig('choices', 0, 'message')
if message['tool_calls'] if message['tool_calls']
process_tool_calls(message['tool_calls']) process_tool_calls(message['tool_calls'])
@@ -46,20 +47,26 @@ module Captain::ChatHelper
def process_tool_calls(tool_calls) def process_tool_calls(tool_calls)
append_tool_calls(tool_calls) append_tool_calls(tool_calls)
process_tool_call(tool_calls.first) tool_calls.each do |tool_call|
end process_tool_call(tool_call)
end
def process_tool_call(tool_call)
return unless tool_call['function']['name'] == 'search_documentation'
tool_call_id = tool_call['id']
query = JSON.parse(tool_call['function']['arguments'])['search_query']
sections = fetch_documentation(query)
append_tool_response(sections, tool_call_id)
request_chat_completion request_chat_completion
end end
def process_tool_call(tool_call)
tool_call_id = tool_call['id']
if tool_call['function']['name'] == 'search_documentation'
query = JSON.parse(tool_call['function']['arguments'])['search_query']
sections = fetch_documentation(query)
append_tool_response(sections, tool_call_id)
else
append_tool_response('', tool_call_id)
end
end
def fetch_documentation(query) def fetch_documentation(query)
Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" }
@assistant @assistant
.responses .responses
.approved .approved

View File

@@ -60,7 +60,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end end
def create_handoff_message def create_handoff_message
create_outgoing_message('Transferring to another agent for further assistance.') create_outgoing_message(@assistant.config['handoff_message'] || 'Transferring to another agent for further assistance.')
end end
def create_messages def create_messages

View File

@@ -5,12 +5,13 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
# limiting the number of conversations to be resolved to avoid any performance issues # limiting the number of conversations to be resolved to avoid any performance issues
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT) resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
resolvable_conversations.each do |conversation| resolvable_conversations.each do |conversation|
resolution_message = conversation.inbox.captain_assistant.config['resolution_message']
conversation.messages.create!( conversation.messages.create!(
{ {
message_type: :outgoing, message_type: :outgoing,
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
content: I18n.t('conversations.activity.auto_resolution_message') content: resolution_message || I18n.t('conversations.activity.auto_resolution_message')
} }
) )
conversation.resolved! conversation.resolved!

View File

@@ -18,4 +18,8 @@ class Captain::AssistantPolicy < ApplicationPolicy
def destroy? def destroy?
@account_user.administrator? @account_user.administrator?
end end
def playground?
true
end
end end

View File

@@ -22,7 +22,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
def system_message def system_message
{ {
role: 'system', role: 'system',
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name']) content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'], @assistant.config)
} }
end end
end end

View File

@@ -103,7 +103,7 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE SYSTEM_PROMPT_MESSAGE
end end
def assistant_response_generator(product_name) def assistant_response_generator(product_name, config = {})
<<~SYSTEM_PROMPT_MESSAGE <<~SYSTEM_PROMPT_MESSAGE
[Identity] [Identity]
You are Captain, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}. You are Captain, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}.
@@ -111,6 +111,7 @@ class Captain::Llm::SystemPromptsService
[Response Guideline] [Response Guideline]
- Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps. - Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps.
- Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words). - Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words).
- Always detect the language from input and reply in the same language. Do not use any other language.
- Be concise and relevant: Most of your responses should be a sentence or two, unless you're asked to go deeper. Don't monopolize the conversation. - Be concise and relevant: Most of your responses should be a sentence or two, unless you're asked to go deeper. Don't monopolize the conversation.
- Use discourse markers to ease comprehension. Never use the list format. - Use discourse markers to ease comprehension. Never use the list format.
- Do not generate a response more than three sentences. - Do not generate a response more than three sentences.
@@ -136,6 +137,7 @@ class Captain::Llm::SystemPromptsService
- Do not share anything outside of the context provided. - Do not share anything outside of the context provided.
- Add the reasoning why you arrived at the answer - Add the reasoning why you arrived at the answer
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format. - Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
#{config['instructions'] || ''}
```json ```json
{ {
reasoning: '', reasoning: '',

View File

@@ -175,4 +175,67 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end end
end end
end end
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) do
{
message_content: 'Hello assistant',
message_history: [
{ role: 'user', content: 'Previous message' },
{ role: 'assistant', content: 'Previous response' }
]
}
end
context 'when it is an un-authenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: valid_params,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an agent' do
it 'generates a response' do
chat_service = instance_double(Captain::Llm::AssistantChatService)
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
valid_params[:message_content],
valid_params[:message_history]
)
expect(json_response[:content]).to eq('Assistant response')
end
end
context 'when message_history is not provided' do
it 'uses empty array as default' do
params_without_history = { message_content: 'Hello assistant' }
chat_service = instance_double(Captain::Llm::AssistantChatService)
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: params_without_history,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(chat_service).to have_received(:generate_response).with(
params_without_history[:message_content],
[]
)
end
end
end
end end

View File

@@ -4,11 +4,13 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
include ActiveJob::TestHelper include ActiveJob::TestHelper
let!(:inbox) { create(:inbox) } let!(:inbox) { create(:inbox) }
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) } let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) } let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) }
let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) } let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
before do before do
create(:captain_inbox, inbox: inbox, captain_assistant: create(:captain_assistant, account: inbox.account))
stub_const('Limits::BULK_ACTIONS_LIMIT', 2) stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
end end