mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 03:27:52 +00:00
feat: Add UI for custom tools (#12585)
### Tools list <img width="2316" height="666" alt="CleanShot 2025-10-03 at 20 42 41@2x" src="https://github.com/user-attachments/assets/ccbffd16-804d-4eb8-9c64-2d1cfd407e4e" /> ### Tools form <img width="2294" height="2202" alt="CleanShot 2025-10-03 at 20 43 05@2x" src="https://github.com/user-attachments/assets/9f49aa09-75a1-4585-a09d-837ca64139b8" /> ## Response <img width="800" height="2144" alt="CleanShot 2025-10-03 at 20 45 56@2x" src="https://github.com/user-attachments/assets/b0c3c899-6050-4c51-baed-c8fbec5aae61" /> --------- Co-authored-by: Pranav <pranavrajs@gmail.com> Co-authored-by: Pranav <pranav@chatwoot.com>
This commit is contained in:
36
app/javascript/dashboard/api/captain/customTools.js
Normal file
36
app/javascript/dashboard/api/captain/customTools.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainCustomTools extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/custom_tools', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get({ page = 1, searchKey } = {}) {
|
||||||
|
return axios.get(this.url, {
|
||||||
|
params: { page, searchKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
show(id) {
|
||||||
|
return axios.get(`${this.url}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(data = {}) {
|
||||||
|
return axios.post(this.url, {
|
||||||
|
custom_tool: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id, data = {}) {
|
||||||
|
return axios.put(`${this.url}/${id}`, {
|
||||||
|
custom_tool: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(id) {
|
||||||
|
return axios.delete(`${this.url}/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainCustomTools();
|
||||||
@@ -10,6 +10,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
translationKey: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
entity: {
|
entity: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']);
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const deleteDialogRef = ref(null);
|
const deleteDialogRef = ref(null);
|
||||||
const i18nKey = computed(() => props.type.toUpperCase());
|
const i18nKey = computed(() => {
|
||||||
|
return props.translationKey || props.type.toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
const deleteEntity = async payload => {
|
const deleteEntity = async payload => {
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup>
|
||||||
|
import { defineModel, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
authType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const authConfig = defineModel('authConfig', {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.authType,
|
||||||
|
() => {
|
||||||
|
authConfig.value = {};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
v-if="authType === 'bearer'"
|
||||||
|
v-model="authConfig.token"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<template v-else-if="authType === 'basic'">
|
||||||
|
<Input
|
||||||
|
v-model="authConfig.username"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="authConfig.password"
|
||||||
|
type="password"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="authType === 'api_key'">
|
||||||
|
<Input
|
||||||
|
v-model="authConfig.name"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="authConfig.key"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE_PLACEHOLDER')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import CustomToolForm from './CustomToolForm.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selectedTool: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'create',
|
||||||
|
validator: value => ['create', 'edit'].includes(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
const updateTool = toolDetails =>
|
||||||
|
store.dispatch('captainCustomTools/update', {
|
||||||
|
id: props.selectedTool.id,
|
||||||
|
...toolDetails,
|
||||||
|
});
|
||||||
|
|
||||||
|
const i18nKey = computed(
|
||||||
|
() => `CAPTAIN.CUSTOM_TOOLS.${props.type.toUpperCase()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const createTool = toolDetails =>
|
||||||
|
store.dispatch('captainCustomTools/create', toolDetails);
|
||||||
|
|
||||||
|
const handleSubmit = async updatedTool => {
|
||||||
|
try {
|
||||||
|
if (props.type === 'edit') {
|
||||||
|
await updateTool(updatedTool);
|
||||||
|
} else {
|
||||||
|
await createTool(updatedTool);
|
||||||
|
}
|
||||||
|
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||||
|
dialogRef.value.close();
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
parseAPIErrorResponse(error) || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
||||||
|
useAlert(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
dialogRef.value.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
width="2xl"
|
||||||
|
:title="$t(`${i18nKey}.TITLE`)"
|
||||||
|
:description="$t('CAPTAIN.CUSTOM_TOOLS.FORM_DESCRIPTION')"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
:show-confirm-button="false"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<CustomToolForm
|
||||||
|
:mode="type"
|
||||||
|
:tool="selectedTool"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
/>
|
||||||
|
<template #footer />
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||||
|
|
||||||
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
authType: {
|
||||||
|
type: String,
|
||||||
|
default: 'none',
|
||||||
|
},
|
||||||
|
updatedAt: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['action']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
|
|
||||||
|
const menuItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'),
|
||||||
|
value: 'edit',
|
||||||
|
action: 'edit',
|
||||||
|
icon: 'i-lucide-pencil-line',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.DELETE_TOOL'),
|
||||||
|
value: 'delete',
|
||||||
|
action: 'delete',
|
||||||
|
icon: 'i-lucide-trash',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const timestamp = computed(() =>
|
||||||
|
dynamicTime(props.updatedAt || props.createdAt)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAction = ({ action, value }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
emit('action', { action, value, id: props.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const authTypeLabel = computed(() => {
|
||||||
|
return t(
|
||||||
|
`CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.${props.authType.toUpperCase()}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardLayout class="relative">
|
||||||
|
<div class="flex relative justify-between w-full gap-1">
|
||||||
|
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Policy
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
:permissions="['administrator']"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-ellipsis-vertical"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showActionsDropdown"
|
||||||
|
:menu-items="menuItems"
|
||||||
|
class="mt-1 ltr:right-0 rtl:right-0 top-full"
|
||||||
|
@action="handleAction($event)"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between w-full gap-4">
|
||||||
|
<div class="flex items-center gap-3 flex-1">
|
||||||
|
<span
|
||||||
|
v-if="description"
|
||||||
|
class="text-sm truncate text-n-slate-11 flex-1"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="authType !== 'none'"
|
||||||
|
class="text-sm shrink-0 text-n-slate-11 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<i class="i-lucide-lock text-base" />
|
||||||
|
{{ authTypeLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
|
||||||
|
{{ timestamp }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required } from '@vuelidate/validators';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
import ParamRow from './ParamRow.vue';
|
||||||
|
import AuthConfig from './AuthConfig.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'create',
|
||||||
|
validator: value => ['create', 'edit'].includes(value),
|
||||||
|
},
|
||||||
|
tool: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const formState = {
|
||||||
|
uiFlags: useMapGetter('captainCustomTools/getUIFlags'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
endpoint_url: '',
|
||||||
|
http_method: 'GET',
|
||||||
|
request_template: '',
|
||||||
|
response_template: '',
|
||||||
|
auth_type: 'none',
|
||||||
|
auth_config: {},
|
||||||
|
param_schema: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = reactive({ ...initialState });
|
||||||
|
|
||||||
|
// Populate form when in edit mode
|
||||||
|
watch(
|
||||||
|
() => props.tool,
|
||||||
|
newTool => {
|
||||||
|
if (props.mode === 'edit' && newTool && newTool.id) {
|
||||||
|
state.title = newTool.title || '';
|
||||||
|
state.description = newTool.description || '';
|
||||||
|
state.endpoint_url = newTool.endpoint_url || '';
|
||||||
|
state.http_method = newTool.http_method || 'GET';
|
||||||
|
state.request_template = newTool.request_template || '';
|
||||||
|
state.response_template = newTool.response_template || '';
|
||||||
|
state.auth_type = newTool.auth_type || 'none';
|
||||||
|
state.auth_config = newTool.auth_config || {};
|
||||||
|
state.param_schema = newTool.param_schema || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const DEFAULT_PARAM = {
|
||||||
|
name: '',
|
||||||
|
type: 'string',
|
||||||
|
description: '',
|
||||||
|
required: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationRules = {
|
||||||
|
title: { required },
|
||||||
|
endpoint_url: { required },
|
||||||
|
http_method: { required },
|
||||||
|
auth_type: { required },
|
||||||
|
};
|
||||||
|
|
||||||
|
const httpMethodOptions = computed(() => [
|
||||||
|
{ value: 'GET', label: 'GET' },
|
||||||
|
{ value: 'POST', label: 'POST' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const authTypeOptions = computed(() => [
|
||||||
|
{ value: 'none', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.NONE') },
|
||||||
|
{ value: 'bearer', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BEARER') },
|
||||||
|
{ value: 'basic', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BASIC') },
|
||||||
|
{
|
||||||
|
value: 'api_key',
|
||||||
|
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
|
const isLoading = computed(() =>
|
||||||
|
props.mode === 'edit'
|
||||||
|
? formState.uiFlags.value.updatingItem
|
||||||
|
: formState.uiFlags.value.creatingItem
|
||||||
|
);
|
||||||
|
|
||||||
|
const getErrorMessage = (field, errorKey) => {
|
||||||
|
return v$.value[field].$error
|
||||||
|
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||||
|
: '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formErrors = computed(() => ({
|
||||||
|
title: getErrorMessage('title', 'TITLE'),
|
||||||
|
endpoint_url: getErrorMessage('endpoint_url', 'ENDPOINT_URL'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const paramsRef = useTemplateRef('paramsRef');
|
||||||
|
|
||||||
|
const isParamsValid = () => {
|
||||||
|
if (!paramsRef.value || paramsRef.value.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return paramsRef.value.every(param => param.validate());
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeParam = index => {
|
||||||
|
state.param_schema.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addParam = () => {
|
||||||
|
state.param_schema.push({ ...DEFAULT_PARAM });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => emit('cancel');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const isFormValid = await v$.value.$validate();
|
||||||
|
if (!isFormValid || !isParamsValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('submit', state);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
|
||||||
|
@submit.prevent="handleSubmit"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="state.title"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.PLACEHOLDER')"
|
||||||
|
:message="formErrors.title"
|
||||||
|
:message-type="formErrors.title ? 'error' : 'info'"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
v-model="state.description"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
|
:rows="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="flex flex-col gap-1 w-28">
|
||||||
|
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<ComboBox
|
||||||
|
v-model="state.http_method"
|
||||||
|
:options="httpMethodOptions"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2 [&_li]:font-mono [&_button]:font-mono [&>div>button]:outline-offset-[-1px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
v-model="state.endpoint_url"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.PLACEHOLDER')"
|
||||||
|
:message="formErrors.endpoint_url"
|
||||||
|
:message-type="formErrors.endpoint_url ? 'error' : 'info'"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<ComboBox
|
||||||
|
v-model="state.auth_type"
|
||||||
|
:options="authTypeOptions"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthConfig
|
||||||
|
v-model:auth-config="state.auth_config"
|
||||||
|
:auth-type="state.auth_type"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-n-slate-11 -mt-1">
|
||||||
|
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }}
|
||||||
|
</p>
|
||||||
|
<ul v-if="state.param_schema.length > 0" class="grid gap-2 list-none">
|
||||||
|
<ParamRow
|
||||||
|
v-for="(param, index) in state.param_schema"
|
||||||
|
:key="index"
|
||||||
|
ref="paramsRef"
|
||||||
|
v-model:name="param.name"
|
||||||
|
v-model:type="param.type"
|
||||||
|
v-model:description="param.description"
|
||||||
|
v-model:required="param.required"
|
||||||
|
@remove="removeParam(index)"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
sm
|
||||||
|
ghost
|
||||||
|
blue
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_PARAMETER')"
|
||||||
|
@click="addParam"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
v-if="state.http_method === 'POST'"
|
||||||
|
v-model="state.request_template"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.LABEL')"
|
||||||
|
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.PLACEHOLDER')"
|
||||||
|
:rows="4"
|
||||||
|
class="[&_textarea]:font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
v-model="state.response_template"
|
||||||
|
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.LABEL')"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
:rows="4"
|
||||||
|
class="[&_textarea]:font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex gap-3 justify-between items-center w-full">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||||
|
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||||
|
@click="handleCancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
:label="
|
||||||
|
t(mode === 'edit' ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE')
|
||||||
|
"
|
||||||
|
class="w-full"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
:disabled="isLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, defineModel, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['remove']);
|
||||||
|
const { t } = useI18n();
|
||||||
|
const showErrors = ref(false);
|
||||||
|
|
||||||
|
const name = defineModel('name', {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const type = defineModel('type', {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const description = defineModel('description', {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const required = defineModel('required', {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paramTypeOptions = computed(() => [
|
||||||
|
{ value: 'string', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.STRING') },
|
||||||
|
{ value: 'number', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.NUMBER') },
|
||||||
|
{
|
||||||
|
value: 'boolean',
|
||||||
|
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.BOOLEAN'),
|
||||||
|
},
|
||||||
|
{ value: 'array', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.ARRAY') },
|
||||||
|
{ value: 'object', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.OBJECT') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const validationError = computed(() => {
|
||||||
|
if (!name.value || name.value.trim() === '') {
|
||||||
|
return 'PARAM_NAME_REQUIRED';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch([name, type, description, required], () => {
|
||||||
|
showErrors.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
showErrors.value = true;
|
||||||
|
return !validationError.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ validate });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<li class="list-none">
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
|
||||||
|
:class="{
|
||||||
|
'animate-wiggle border-n-ruby-9': showErrors && validationError,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col flex-1 gap-3">
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<Input
|
||||||
|
v-model="name"
|
||||||
|
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_NAME.PLACEHOLDER')"
|
||||||
|
class="col-span-2"
|
||||||
|
/>
|
||||||
|
<ComboBox
|
||||||
|
v-model="type"
|
||||||
|
:options="paramTypeOptions"
|
||||||
|
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPE.PLACEHOLDER')"
|
||||||
|
class="[&>div>button]:bg-n-alpha-black2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
v-model="description"
|
||||||
|
:placeholder="
|
||||||
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_DESCRIPTION.PLACEHOLDER')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<Checkbox v-model="required" />
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
solid
|
||||||
|
slate
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
@click.stop="emit('remove')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-if="showErrors && validationError"
|
||||||
|
class="block mt-1 text-sm text-n-ruby-11"
|
||||||
|
>
|
||||||
|
{{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup>
|
||||||
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['click']);
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
emit('click');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EmptyStateLayout
|
||||||
|
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||||
|
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||||
|
:action-perms="['administrator']"
|
||||||
|
>
|
||||||
|
<template #empty-state-item>
|
||||||
|
<div class="min-h-[600px]" />
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
:label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
@click="onClick"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</EmptyStateLayout>
|
||||||
|
</template>
|
||||||
@@ -232,6 +232,11 @@ const menuItems = computed(() => {
|
|||||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||||
to: accountScopedRoute('captain_responses_index'),
|
to: accountScopedRoute('captain_responses_index'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Tools',
|
||||||
|
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||||
|
to: accountScopedRoute('captain_tools_index'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -750,6 +750,115 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"CUSTOM_TOOLS": {
|
||||||
|
"HEADER": "Tools",
|
||||||
|
"ADD_NEW": "Create a new tool",
|
||||||
|
"EMPTY_STATE": {
|
||||||
|
"TITLE": "No custom tools available",
|
||||||
|
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||||
|
"FEATURE_SPOTLIGHT": {
|
||||||
|
"TITLE": "Custom Tools",
|
||||||
|
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
|
||||||
|
"OPTIONS": {
|
||||||
|
"EDIT_TOOL": "Edit tool",
|
||||||
|
"DELETE_TOOL": "Delete tool"
|
||||||
|
},
|
||||||
|
"CREATE": {
|
||||||
|
"TITLE": "Create Custom Tool",
|
||||||
|
"SUCCESS_MESSAGE": "Custom tool created successfully",
|
||||||
|
"ERROR_MESSAGE": "Failed to create custom tool"
|
||||||
|
},
|
||||||
|
"EDIT": {
|
||||||
|
"TITLE": "Edit Custom Tool",
|
||||||
|
"SUCCESS_MESSAGE": "Custom tool updated successfully",
|
||||||
|
"ERROR_MESSAGE": "Failed to update custom tool"
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"TITLE": "Delete Custom Tool",
|
||||||
|
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
|
||||||
|
"CONFIRM": "Yes, delete",
|
||||||
|
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||||
|
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||||
|
},
|
||||||
|
"FORM": {
|
||||||
|
"TITLE": {
|
||||||
|
"LABEL": "Tool Name",
|
||||||
|
"PLACEHOLDER": "Order Lookup",
|
||||||
|
"ERROR": "Tool name is required"
|
||||||
|
},
|
||||||
|
"DESCRIPTION": {
|
||||||
|
"LABEL": "Description",
|
||||||
|
"PLACEHOLDER": "Looks up order details by order ID"
|
||||||
|
},
|
||||||
|
"HTTP_METHOD": {
|
||||||
|
"LABEL": "Method"
|
||||||
|
},
|
||||||
|
"ENDPOINT_URL": {
|
||||||
|
"LABEL": "Endpoint URL",
|
||||||
|
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
|
||||||
|
"ERROR": "Valid URL is required"
|
||||||
|
},
|
||||||
|
"AUTH_TYPE": {
|
||||||
|
"LABEL": "Authentication Type"
|
||||||
|
},
|
||||||
|
"AUTH_TYPES": {
|
||||||
|
"NONE": "None",
|
||||||
|
"BEARER": "Bearer Token",
|
||||||
|
"BASIC": "Basic Auth",
|
||||||
|
"API_KEY": "API Key"
|
||||||
|
},
|
||||||
|
"AUTH_CONFIG": {
|
||||||
|
"BEARER_TOKEN": "Bearer Token",
|
||||||
|
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
|
||||||
|
"USERNAME": "Username",
|
||||||
|
"USERNAME_PLACEHOLDER": "Enter username",
|
||||||
|
"PASSWORD": "Password",
|
||||||
|
"PASSWORD_PLACEHOLDER": "Enter password",
|
||||||
|
"API_KEY": "Header Name",
|
||||||
|
"API_KEY_PLACEHOLDER": "X-API-Key",
|
||||||
|
"API_VALUE": "Header Value",
|
||||||
|
"API_VALUE_PLACEHOLDER": "Enter API key value"
|
||||||
|
},
|
||||||
|
"PARAMETERS": {
|
||||||
|
"LABEL": "Parameters",
|
||||||
|
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
|
||||||
|
},
|
||||||
|
"ADD_PARAMETER": "Add Parameter",
|
||||||
|
"PARAM_NAME": {
|
||||||
|
"PLACEHOLDER": "Parameter name (e.g., order_id)"
|
||||||
|
},
|
||||||
|
"PARAM_TYPE": {
|
||||||
|
"PLACEHOLDER": "Type"
|
||||||
|
},
|
||||||
|
"PARAM_TYPES": {
|
||||||
|
"STRING": "String",
|
||||||
|
"NUMBER": "Number",
|
||||||
|
"BOOLEAN": "Boolean",
|
||||||
|
"ARRAY": "Array",
|
||||||
|
"OBJECT": "Object"
|
||||||
|
},
|
||||||
|
"PARAM_DESCRIPTION": {
|
||||||
|
"PLACEHOLDER": "Description of the parameter"
|
||||||
|
},
|
||||||
|
"PARAM_REQUIRED": {
|
||||||
|
"LABEL": "Required"
|
||||||
|
},
|
||||||
|
"REQUEST_TEMPLATE": {
|
||||||
|
"LABEL": "Request Body Template (Optional)",
|
||||||
|
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
|
||||||
|
},
|
||||||
|
"RESPONSE_TEMPLATE": {
|
||||||
|
"LABEL": "Response Template (Optional)",
|
||||||
|
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||||
|
},
|
||||||
|
"ERRORS": {
|
||||||
|
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"RESPONSES": {
|
"RESPONSES": {
|
||||||
"HEADER": "FAQs",
|
"HEADER": "FAQs",
|
||||||
"ADD_NEW": "Create new FAQ",
|
"ADD_NEW": "Create new FAQ",
|
||||||
|
|||||||
@@ -304,6 +304,7 @@
|
|||||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||||
"CAPTAIN_DOCUMENTS": "Documents",
|
"CAPTAIN_DOCUMENTS": "Documents",
|
||||||
"CAPTAIN_RESPONSES": "FAQs",
|
"CAPTAIN_RESPONSES": "FAQs",
|
||||||
|
"CAPTAIN_TOOLS": "Tools",
|
||||||
"HOME": "Home",
|
"HOME": "Home",
|
||||||
"AGENTS": "Agents",
|
"AGENTS": "Agents",
|
||||||
"AGENT_BOTS": "Bots",
|
"AGENT_BOTS": "Bots",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
|||||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
import AssistantScenariosIndex from './assistants/scenarios/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';
|
||||||
|
import CustomToolsIndex from './tools/Index.vue';
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
{
|
{
|
||||||
@@ -124,4 +125,17 @@ export const routes = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: frontendURL('accounts/:accountId/captain/tools'),
|
||||||
|
component: CustomToolsIndex,
|
||||||
|
name: 'captain_tools_index',
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator', 'agent'],
|
||||||
|
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
|
||||||
|
installationTypes: [
|
||||||
|
INSTALLATION_TYPES.CLOUD,
|
||||||
|
INSTALLATION_TYPES.ENTERPRISE,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||||
|
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
|
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||||
|
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||||
|
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
||||||
|
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
||||||
|
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||||
|
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
||||||
|
const customTools = useMapGetter('captainCustomTools/getRecords');
|
||||||
|
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||||
|
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
|
||||||
|
|
||||||
|
const createDialogRef = ref(null);
|
||||||
|
const deleteDialogRef = ref(null);
|
||||||
|
const selectedTool = ref(null);
|
||||||
|
const dialogType = ref('');
|
||||||
|
|
||||||
|
const fetchCustomTools = (page = 1) => {
|
||||||
|
store.dispatch('captainCustomTools/get', { page });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageChange = page => fetchCustomTools(page);
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
dialogType.value = 'create';
|
||||||
|
selectedTool.value = null;
|
||||||
|
nextTick(() => createDialogRef.value.dialogRef.open());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = tool => {
|
||||||
|
dialogType.value = 'edit';
|
||||||
|
selectedTool.value = tool;
|
||||||
|
nextTick(() => createDialogRef.value.dialogRef.open());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = tool => {
|
||||||
|
selectedTool.value = tool;
|
||||||
|
nextTick(() => deleteDialogRef.value.dialogRef.open());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = ({ action, id }) => {
|
||||||
|
const tool = customTools.value.find(t => t.id === id);
|
||||||
|
if (action === 'edit') {
|
||||||
|
handleEdit(tool);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
handleDelete(tool);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogClose = () => {
|
||||||
|
dialogType.value = '';
|
||||||
|
selectedTool.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteSuccess = () => {
|
||||||
|
selectedTool.value = null;
|
||||||
|
// Check if page will be empty after deletion
|
||||||
|
if (customTools.value.length === 1 && customToolsMeta.value.page > 1) {
|
||||||
|
// Go to previous page if current page will be empty
|
||||||
|
onPageChange(customToolsMeta.value.page - 1);
|
||||||
|
} else {
|
||||||
|
// Refresh current page
|
||||||
|
fetchCustomTools(customToolsMeta.value.page);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCustomTools();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageLayout
|
||||||
|
:header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')"
|
||||||
|
:button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
|
||||||
|
:button-policy="['administrator']"
|
||||||
|
:total-count="customToolsMeta.totalCount"
|
||||||
|
:current-page="customToolsMeta.page"
|
||||||
|
:show-pagination-footer="!isFetching && !!customTools.length"
|
||||||
|
:is-fetching="isFetching"
|
||||||
|
:is-empty="!customTools.length"
|
||||||
|
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
|
||||||
|
@update:current-page="onPageChange"
|
||||||
|
@click="openCreateDialog"
|
||||||
|
>
|
||||||
|
<template #paywall>
|
||||||
|
<CaptainPaywall />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #emptyState>
|
||||||
|
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<CustomToolCard
|
||||||
|
v-for="tool in customTools"
|
||||||
|
:id="tool.id"
|
||||||
|
:key="tool.id"
|
||||||
|
:title="tool.title"
|
||||||
|
:description="tool.description"
|
||||||
|
:endpoint-url="tool.endpoint_url"
|
||||||
|
:http-method="tool.http_method"
|
||||||
|
:auth-type="tool.auth_type"
|
||||||
|
:param-schema="tool.param_schema"
|
||||||
|
:enabled="tool.enabled"
|
||||||
|
:created-at="tool.created_at"
|
||||||
|
:updated-at="tool.updated_at"
|
||||||
|
@action="handleAction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageLayout>
|
||||||
|
|
||||||
|
<CreateCustomToolDialog
|
||||||
|
v-if="dialogType"
|
||||||
|
ref="createDialogRef"
|
||||||
|
:type="dialogType"
|
||||||
|
:selected-tool="selectedTool"
|
||||||
|
@close="handleDialogClose"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
v-if="selectedTool"
|
||||||
|
ref="deleteDialogRef"
|
||||||
|
:entity="selectedTool"
|
||||||
|
type="CustomTools"
|
||||||
|
translation-key="CUSTOM_TOOLS"
|
||||||
|
@delete-success="onDeleteSuccess"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
35
app/javascript/dashboard/store/captain/customTools.js
Normal file
35
app/javascript/dashboard/store/captain/customTools.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import CaptainCustomTools from 'dashboard/api/captain/customTools';
|
||||||
|
import { createStore } from './storeFactory';
|
||||||
|
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
|
export default createStore({
|
||||||
|
name: 'CaptainCustomTool',
|
||||||
|
API: CaptainCustomTools,
|
||||||
|
actions: mutations => ({
|
||||||
|
update: async ({ commit }, { id, ...updateObj }) => {
|
||||||
|
commit(mutations.SET_UI_FLAG, { updatingItem: true });
|
||||||
|
try {
|
||||||
|
const response = await CaptainCustomTools.update(id, updateObj);
|
||||||
|
commit(mutations.EDIT, response.data);
|
||||||
|
commit(mutations.SET_UI_FLAG, { updatingItem: false });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
commit(mutations.SET_UI_FLAG, { updatingItem: false });
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async ({ commit }, id) => {
|
||||||
|
commit(mutations.SET_UI_FLAG, { deletingItem: true });
|
||||||
|
try {
|
||||||
|
await CaptainCustomTools.delete(id);
|
||||||
|
commit(mutations.DELETE, id);
|
||||||
|
commit(mutations.SET_UI_FLAG, { deletingItem: false });
|
||||||
|
return id;
|
||||||
|
} catch (error) {
|
||||||
|
commit(mutations.SET_UI_FLAG, { deletingItem: false });
|
||||||
|
return throwErrorMessage(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools';
|
|||||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
const toolsStore = createStore({
|
const toolsStore = createStore({
|
||||||
name: 'captainTool',
|
name: 'Tools',
|
||||||
API: CaptainToolsAPI,
|
API: CaptainToolsAPI,
|
||||||
actions: mutations => ({
|
actions: mutations => ({
|
||||||
getTools: async ({ commit }) => {
|
getTools: async ({ commit }) => {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads';
|
|||||||
import copilotMessages from './captain/copilotMessages';
|
import copilotMessages from './captain/copilotMessages';
|
||||||
import captainScenarios from './captain/scenarios';
|
import captainScenarios from './captain/scenarios';
|
||||||
import captainTools from './captain/tools';
|
import captainTools from './captain/tools';
|
||||||
|
import captainCustomTools from './captain/customTools';
|
||||||
|
|
||||||
const plugins = [];
|
const plugins = [];
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ export default createStore({
|
|||||||
copilotMessages,
|
copilotMessages,
|
||||||
captainScenarios,
|
captainScenarios,
|
||||||
captainTools,
|
captainTools,
|
||||||
|
captainCustomTools,
|
||||||
},
|
},
|
||||||
plugins,
|
plugins,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ en:
|
|||||||
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
|
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
|
||||||
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
|
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
|
||||||
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
||||||
|
custom_tool:
|
||||||
|
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
|
||||||
public_portal:
|
public_portal:
|
||||||
search:
|
search:
|
||||||
search_placeholder: Search for article by title or body...
|
search_placeholder: Search for article by title or body...
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ Rails.application.routes.draw do
|
|||||||
resources :copilot_threads, only: [:index, :create] do
|
resources :copilot_threads, only: [:index, :create] do
|
||||||
resources :copilot_messages, only: [:index, :create]
|
resources :copilot_messages, only: [:index, :create]
|
||||||
end
|
end
|
||||||
|
resources :custom_tools
|
||||||
resources :documents, only: [:index, :show, :create, :destroy]
|
resources :documents, only: [:index, :show, :create, :destroy]
|
||||||
end
|
end
|
||||||
resource :saml_settings, only: [:show, :create, :update, :destroy]
|
resource :saml_settings, only: [:show, :create, :update, :destroy]
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :current_account
|
||||||
|
before_action -> { check_authorization(Captain::CustomTool) }
|
||||||
|
before_action :set_custom_tool, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@custom_tools = account_custom_tools.enabled
|
||||||
|
end
|
||||||
|
|
||||||
|
def show; end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@custom_tool = account_custom_tools.create!(custom_tool_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
@custom_tool.update!(custom_tool_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@custom_tool.destroy
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_custom_tool
|
||||||
|
@custom_tool = account_custom_tools.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_custom_tools
|
||||||
|
@account_custom_tools ||= Current.account.captain_custom_tools
|
||||||
|
end
|
||||||
|
|
||||||
|
def custom_tool_params
|
||||||
|
params.require(:custom_tool).permit(
|
||||||
|
:title,
|
||||||
|
:description,
|
||||||
|
:endpoint_url,
|
||||||
|
:http_method,
|
||||||
|
:request_template,
|
||||||
|
:response_template,
|
||||||
|
:auth_type,
|
||||||
|
:enabled,
|
||||||
|
auth_config: {},
|
||||||
|
param_schema: [:name, :type, :description, :required]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -29,6 +29,8 @@ class Captain::CustomTool < ApplicationRecord
|
|||||||
|
|
||||||
self.table_name = 'captain_custom_tools'
|
self.table_name = 'captain_custom_tools'
|
||||||
|
|
||||||
|
NAME_PREFIX = 'custom'.freeze
|
||||||
|
NAME_SEPARATOR = '_'.freeze
|
||||||
PARAM_SCHEMA_VALIDATION = {
|
PARAM_SCHEMA_VALIDATION = {
|
||||||
'type': 'array',
|
'type': 'array',
|
||||||
'items': {
|
'items': {
|
||||||
@@ -73,16 +75,23 @@ class Captain::CustomTool < ApplicationRecord
|
|||||||
|
|
||||||
def generate_slug
|
def generate_slug
|
||||||
return if slug.present?
|
return if slug.present?
|
||||||
|
return if title.blank?
|
||||||
|
|
||||||
base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}"
|
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||||
|
|
||||||
|
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
|
||||||
self.slug = find_unique_slug(base_slug)
|
self.slug = find_unique_slug(base_slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_unique_slug(base_slug, counter = 0)
|
def find_unique_slug(base_slug)
|
||||||
slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}"
|
return base_slug unless slug_exists?(base_slug)
|
||||||
return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate)
|
|
||||||
|
|
||||||
slug_candidate
|
5.times do
|
||||||
|
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||||
|
return slug_candidate unless slug_exists?(slug_candidate)
|
||||||
|
end
|
||||||
|
|
||||||
|
raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed')
|
||||||
end
|
end
|
||||||
|
|
||||||
def slug_exists?(candidate)
|
def slug_exists?(candidate)
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ module Concerns::Toolable
|
|||||||
|
|
||||||
def tool(assistant)
|
def tool(assistant)
|
||||||
custom_tool_record = self
|
custom_tool_record = self
|
||||||
|
# Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize)
|
||||||
|
class_name = custom_tool_record.slug.underscore.camelize
|
||||||
|
|
||||||
|
# Always create a fresh class to reflect current metadata
|
||||||
tool_class = Class.new(Captain::Tools::HttpTool) do
|
tool_class = Class.new(Captain::Tools::HttpTool) do
|
||||||
description custom_tool_record.description
|
description custom_tool_record.description
|
||||||
|
|
||||||
@@ -15,6 +18,16 @@ module Concerns::Toolable
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Register the dynamically created class as a constant in the Captain::Tools namespace.
|
||||||
|
# This is required because RubyLLM's Tool base class derives the tool name from the class name
|
||||||
|
# (via Class#name). Anonymous classes created with Class.new have no name and return empty strings,
|
||||||
|
# which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API.
|
||||||
|
# By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup")
|
||||||
|
# which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API.
|
||||||
|
# We refresh the constant on each call to ensure tool metadata changes are reflected.
|
||||||
|
Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false)
|
||||||
|
Captain::Tools.const_set(class_name, tool_class)
|
||||||
|
|
||||||
tool_class.new(assistant, self)
|
tool_class.new(assistant, self)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
21
enterprise/app/policies/captain/custom_tool_policy.rb
Normal file
21
enterprise/app/policies/captain/custom_tool_policy.rb
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
class Captain::CustomToolPolicy < ApplicationPolicy
|
||||||
|
def index?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def show?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def create?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def update?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
@account_user.administrator?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
json.payload do
|
||||||
|
json.array! @custom_tools do |custom_tool|
|
||||||
|
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: custom_tool
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
json.meta do
|
||||||
|
json.total_count @custom_tools.count
|
||||||
|
json.page 1
|
||||||
|
end
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
json.id custom_tool.id
|
||||||
|
json.slug custom_tool.slug
|
||||||
|
json.title custom_tool.title
|
||||||
|
json.description custom_tool.description
|
||||||
|
json.endpoint_url custom_tool.endpoint_url
|
||||||
|
json.http_method custom_tool.http_method
|
||||||
|
json.request_template custom_tool.request_template
|
||||||
|
json.response_template custom_tool.response_template
|
||||||
|
json.auth_type custom_tool.auth_type
|
||||||
|
json.auth_config custom_tool.auth_config
|
||||||
|
json.param_schema custom_tool.param_schema
|
||||||
|
json.enabled custom_tool.enabled
|
||||||
|
json.account_id custom_tool.account_id
|
||||||
|
json.created_at custom_tool.created_at.to_i
|
||||||
|
json.updated_at custom_tool.updated_at.to_i
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||||
|
let(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
|
||||||
|
def json_response
|
||||||
|
JSON.parse(response.body, symbolize_names: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do
|
||||||
|
context 'when it is an un-authenticated user' do
|
||||||
|
it 'returns unauthorized status' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/custom_tools"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an agent' do
|
||||||
|
it 'returns success status' do
|
||||||
|
create_list(:captain_custom_tool, 3, account: account)
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response[:payload].length).to eq(3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an admin' do
|
||||||
|
it 'returns success status and custom tools' do
|
||||||
|
create_list(:captain_custom_tool, 5, account: account)
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response[:payload].length).to eq(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns only enabled custom tools' do
|
||||||
|
create(:captain_custom_tool, account: account, enabled: true)
|
||||||
|
create(:captain_custom_tool, account: account, enabled: false)
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response[:payload].length).to eq(1)
|
||||||
|
expect(json_response[:payload].first[:enabled]).to be(true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
|
||||||
|
let(:custom_tool) { create(:captain_custom_tool, account: account) }
|
||||||
|
|
||||||
|
context 'when it is an un-authenticated user' do
|
||||||
|
it 'returns unauthorized status' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an agent' do
|
||||||
|
it 'returns success status and custom tool' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response[:id]).to eq(custom_tool.id)
|
||||||
|
expect(json_response[:title]).to eq(custom_tool.title)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when custom tool does not exist' do
|
||||||
|
it 'returns not found status' do
|
||||||
|
get "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
|
||||||
|
headers: agent.create_new_auth_token
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /api/v1/accounts/{account.id}/captain/custom_tools' do
|
||||||
|
let(:valid_attributes) do
|
||||||
|
{
|
||||||
|
custom_tool: {
|
||||||
|
title: 'Fetch Order Status',
|
||||||
|
description: 'Fetches order status from external API',
|
||||||
|
endpoint_url: 'https://api.example.com/orders/{{ order_id }}',
|
||||||
|
http_method: 'GET',
|
||||||
|
enabled: true,
|
||||||
|
param_schema: [
|
||||||
|
{ name: 'order_id', type: 'string', description: 'The order ID', required: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an un-authenticated user' do
|
||||||
|
it 'returns unauthorized status' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||||
|
params: valid_attributes
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an agent' do
|
||||||
|
it 'returns unauthorized status' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||||
|
params: valid_attributes,
|
||||||
|
headers: agent.create_new_auth_token
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an admin' do
|
||||||
|
it 'creates a new custom tool and returns success status' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||||
|
params: valid_attributes,
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response[:title]).to eq('Fetch Order Status')
|
||||||
|
expect(json_response[:description]).to eq('Fetches order status from external API')
|
||||||
|
expect(json_response[:enabled]).to be(true)
|
||||||
|
expect(json_response[:slug]).to eq('custom_fetch_order_status')
|
||||||
|
expect(json_response[:param_schema]).to eq([
|
||||||
|
{ name: 'order_id', type: 'string', description: 'The order ID', required: true }
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid parameters' do
|
||||||
|
let(:invalid_attributes) do
|
||||||
|
{
|
||||||
|
custom_tool: {
|
||||||
|
title: '',
|
||||||
|
endpoint_url: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unprocessable entity status' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||||
|
params: invalid_attributes,
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid endpoint URL' do
|
||||||
|
let(:invalid_url_attributes) do
|
||||||
|
{
|
||||||
|
custom_tool: {
|
||||||
|
title: 'Test Tool',
|
||||||
|
endpoint_url: 'http://localhost/api',
|
||||||
|
http_method: 'GET'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unprocessable entity status' do
|
||||||
|
post "/api/v1/accounts/#{account.id}/captain/custom_tools",
|
||||||
|
params: invalid_url_attributes,
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PATCH /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
|
||||||
|
let(:custom_tool) { create(:captain_custom_tool, account: account) }
|
||||||
|
let(:update_attributes) do
|
||||||
|
{
|
||||||
|
custom_tool: {
|
||||||
|
title: 'Updated Tool Title',
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an un-authenticated user' do
|
||||||
|
it 'returns unauthorized status' do
|
||||||
|
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||||
|
params: update_attributes
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an agent' do
|
||||||
|
it 'returns unauthorized status' do
|
||||||
|
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||||
|
params: update_attributes,
|
||||||
|
headers: agent.create_new_auth_token
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an admin' do
|
||||||
|
it 'updates the custom tool and returns success status' do
|
||||||
|
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||||
|
params: update_attributes,
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
expect(json_response[:title]).to eq('Updated Tool Title')
|
||||||
|
expect(json_response[:enabled]).to be(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with invalid parameters' do
|
||||||
|
let(:invalid_attributes) do
|
||||||
|
{
|
||||||
|
custom_tool: {
|
||||||
|
title: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unprocessable entity status' do
|
||||||
|
patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||||
|
params: invalid_attributes,
|
||||||
|
headers: admin.create_new_auth_token,
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do
|
||||||
|
let!(:custom_tool) { create(:captain_custom_tool, account: account) }
|
||||||
|
|
||||||
|
context 'when it is an un-authenticated user' do
|
||||||
|
it 'returns unauthorized status' do
|
||||||
|
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an agent' do
|
||||||
|
it 'returns unauthorized status' do
|
||||||
|
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||||
|
headers: agent.create_new_auth_token
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when it is an admin' do
|
||||||
|
it 'deletes the custom tool and returns no content status' do
|
||||||
|
expect do
|
||||||
|
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}",
|
||||||
|
headers: admin.create_new_auth_token
|
||||||
|
end.to change(Captain::CustomTool, :count).by(-1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:no_content)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when custom tool does not exist' do
|
||||||
|
it 'returns not found status' do
|
||||||
|
delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999",
|
||||||
|
headers: admin.create_new_auth_token
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -19,8 +19,8 @@ RSpec.describe Captain::CustomTool, type: :model do
|
|||||||
let(:account) { create(:account) }
|
let(:account) { create(:account) }
|
||||||
|
|
||||||
it 'validates uniqueness of slug scoped to account' do
|
it 'validates uniqueness of slug scoped to account' do
|
||||||
create(:captain_custom_tool, account: account, slug: 'custom_test-tool')
|
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
||||||
duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test-tool')
|
duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
||||||
|
|
||||||
expect(duplicate).not_to be_valid
|
expect(duplicate).not_to be_valid
|
||||||
expect(duplicate.errors[:slug]).to include('has already been taken')
|
expect(duplicate.errors[:slug]).to include('has already been taken')
|
||||||
@@ -28,8 +28,8 @@ RSpec.describe Captain::CustomTool, type: :model do
|
|||||||
|
|
||||||
it 'allows same slug across different accounts' do
|
it 'allows same slug across different accounts' do
|
||||||
account2 = create(:account)
|
account2 = create(:account)
|
||||||
create(:captain_custom_tool, account: account, slug: 'custom_test-tool')
|
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
||||||
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test-tool')
|
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
|
||||||
|
|
||||||
expect(different_account_tool).to be_valid
|
expect(different_account_tool).to be_valid
|
||||||
end
|
end
|
||||||
@@ -114,7 +114,7 @@ RSpec.describe Captain::CustomTool, type: :model do
|
|||||||
it 'generates slug from title on creation' do
|
it 'generates slug from title on creation' do
|
||||||
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
|
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
|
||||||
|
|
||||||
expect(tool.slug).to eq('custom_fetch-order-status')
|
expect(tool.slug).to eq('custom_fetch_order_status')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'adds custom_ prefix to generated slug' do
|
it 'adds custom_ prefix to generated slug' do
|
||||||
@@ -124,37 +124,39 @@ RSpec.describe Captain::CustomTool, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'does not override manually set slug' do
|
it 'does not override manually set slug' do
|
||||||
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual-slug')
|
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug')
|
||||||
|
|
||||||
expect(tool.slug).to eq('custom_manual-slug')
|
expect(tool.slug).to eq('custom_manual_slug')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'handles slug collisions by appending counter' do
|
it 'handles slug collisions by appending random suffix' do
|
||||||
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool')
|
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
|
||||||
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
||||||
|
|
||||||
expect(tool2.slug).to eq('custom_test-tool-1')
|
expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'handles multiple slug collisions' do
|
it 'handles multiple slug collisions' do
|
||||||
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool')
|
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
|
||||||
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool-1')
|
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool_abc123')
|
||||||
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
||||||
|
|
||||||
expect(tool3.slug).to eq('custom_test-tool-2')
|
expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
|
||||||
|
expect(tool3.slug).not_to eq('custom_test_tool')
|
||||||
|
expect(tool3.slug).not_to eq('custom_test_tool_abc123')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'generates slug with UUID when title is blank' do
|
it 'does not generate slug when title is blank' do
|
||||||
tool = build(:captain_custom_tool, account: account, title: nil)
|
tool = build(:captain_custom_tool, account: account, title: nil)
|
||||||
tool.valid?
|
|
||||||
|
|
||||||
expect(tool.slug).to match(/^custom_[0-9a-f-]+$/)
|
expect(tool).not_to be_valid
|
||||||
|
expect(tool.errors[:title]).to include("can't be blank")
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'parameterizes title correctly' do
|
it 'parameterizes title correctly' do
|
||||||
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
|
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
|
||||||
|
|
||||||
expect(tool.slug).to eq('custom_fetch-order-status-details')
|
expect(tool.slug).to eq('custom_fetch_order_status_details')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user