mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 18:47:51 +00:00
Merge branch 'develop' into fix/CW-5679
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,
|
||||
required: true,
|
||||
},
|
||||
translationKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
entity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const deleteDialogRef = ref(null);
|
||||
const i18nKey = computed(() => props.type.toUpperCase());
|
||||
const i18nKey = computed(() => {
|
||||
return props.translationKey || props.type.toUpperCase();
|
||||
});
|
||||
|
||||
const deleteEntity = async payload => {
|
||||
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'),
|
||||
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": {
|
||||
"HEADER": "FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
"AGENT_BOTS": "Bots",
|
||||
|
||||
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
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';
|
||||
|
||||
const toolsStore = createStore({
|
||||
name: 'captainTool',
|
||||
name: 'Tools',
|
||||
API: CaptainToolsAPI,
|
||||
actions: mutations => ({
|
||||
getTools: async ({ commit }) => {
|
||||
|
||||
@@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads';
|
||||
import copilotMessages from './captain/copilotMessages';
|
||||
import captainScenarios from './captain/scenarios';
|
||||
import captainTools from './captain/tools';
|
||||
import captainCustomTools from './captain/customTools';
|
||||
|
||||
const plugins = [];
|
||||
|
||||
@@ -119,6 +120,7 @@ export default createStore({
|
||||
copilotMessages,
|
||||
captainScenarios,
|
||||
captainTools,
|
||||
captainCustomTools,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
@@ -336,6 +336,8 @@ en:
|
||||
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
|
||||
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
|
||||
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
||||
custom_tool:
|
||||
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
|
||||
public_portal:
|
||||
search:
|
||||
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_messages, only: [:index, :create]
|
||||
end
|
||||
resources :custom_tools
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
end
|
||||
resource :saml_settings, only: [:show, :create, :update, :destroy]
|
||||
|
||||
22
db/migrate/20251003091242_create_captain_custom_tools.rb
Normal file
22
db/migrate/20251003091242_create_captain_custom_tools.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class CreateCaptainCustomTools < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :captain_custom_tools do |t|
|
||||
t.references :account, null: false, index: true
|
||||
t.string :slug, null: false
|
||||
t.string :title, null: false
|
||||
t.text :description
|
||||
t.string :http_method, null: false, default: 'GET'
|
||||
t.text :endpoint_url, null: false
|
||||
t.text :request_template
|
||||
t.text :response_template
|
||||
t.string :auth_type, default: 'none'
|
||||
t.jsonb :auth_config, default: {}
|
||||
t.jsonb :param_schema, default: []
|
||||
t.boolean :enabled, default: true, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :captain_custom_tools, [:account_id, :slug], unique: true
|
||||
end
|
||||
end
|
||||
21
db/schema.rb
21
db/schema.rb
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -323,6 +323,25 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
|
||||
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
|
||||
end
|
||||
|
||||
create_table "captain_custom_tools", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "slug", null: false
|
||||
t.string "title", null: false
|
||||
t.text "description"
|
||||
t.string "http_method", default: "GET", null: false
|
||||
t.text "endpoint_url", null: false
|
||||
t.text "request_template"
|
||||
t.text "response_template"
|
||||
t.string "auth_type", default: "none"
|
||||
t.jsonb "auth_config", default: {}
|
||||
t.jsonb "param_schema", default: []
|
||||
t.boolean "enabled", default: true, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "slug"], name: "index_captain_custom_tools_on_account_id_and_slug", unique: true
|
||||
t.index ["account_id"], name: "index_captain_custom_tools_on_account_id"
|
||||
end
|
||||
|
||||
create_table "captain_documents", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.string "external_link", null: false
|
||||
|
||||
@@ -33,7 +33,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def tools
|
||||
@tools = Captain::Assistant.available_agent_tools
|
||||
assistant = Captain::Assistant.new(account: Current.account)
|
||||
@tools = assistant.available_agent_tools
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -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
|
||||
@@ -50,6 +50,19 @@ class Captain::Assistant < ApplicationRecord
|
||||
name
|
||||
end
|
||||
|
||||
def available_agent_tools
|
||||
tools = self.class.built_in_agent_tools.dup
|
||||
|
||||
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
|
||||
tools.concat(custom_tools)
|
||||
|
||||
tools
|
||||
end
|
||||
|
||||
def available_tool_ids
|
||||
available_agent_tools.pluck(:id)
|
||||
end
|
||||
|
||||
def push_event_data
|
||||
{
|
||||
id: id,
|
||||
|
||||
100
enterprise/app/models/captain/custom_tool.rb
Normal file
100
enterprise/app/models/captain/custom_tool.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_custom_tools
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# auth_config :jsonb
|
||||
# auth_type :string default("none")
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# endpoint_url :text not null
|
||||
# http_method :string default("GET"), not null
|
||||
# param_schema :jsonb
|
||||
# request_template :text
|
||||
# response_template :text
|
||||
# slug :string not null
|
||||
# title :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_custom_tools_on_account_id (account_id)
|
||||
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
|
||||
#
|
||||
class Captain::CustomTool < ApplicationRecord
|
||||
include Concerns::Toolable
|
||||
include Concerns::SafeEndpointValidatable
|
||||
|
||||
self.table_name = 'captain_custom_tools'
|
||||
|
||||
NAME_PREFIX = 'custom'.freeze
|
||||
NAME_SEPARATOR = '_'.freeze
|
||||
PARAM_SCHEMA_VALIDATION = {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': { 'type': 'string' },
|
||||
'type': { 'type': 'string' },
|
||||
'description': { 'type': 'string' },
|
||||
'required': { 'type': 'boolean' }
|
||||
},
|
||||
'required': %w[name type description],
|
||||
'additionalProperties': false
|
||||
}
|
||||
}.to_json.freeze
|
||||
|
||||
belongs_to :account
|
||||
|
||||
enum :http_method, %w[GET POST].index_by(&:itself), validate: true
|
||||
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
|
||||
|
||||
before_validation :generate_slug
|
||||
|
||||
validates :slug, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :title, presence: true
|
||||
validates :endpoint_url, presence: true
|
||||
validates_with JsonSchemaValidator,
|
||||
schema: PARAM_SCHEMA_VALIDATION,
|
||||
attribute_resolver: ->(record) { record.param_schema }
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
def to_tool_metadata
|
||||
{
|
||||
id: slug,
|
||||
title: title,
|
||||
description: description,
|
||||
custom: true
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_slug
|
||||
return if slug.present?
|
||||
return if title.blank?
|
||||
|
||||
paramterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||
|
||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}"
|
||||
self.slug = find_unique_slug(base_slug)
|
||||
end
|
||||
|
||||
def find_unique_slug(base_slug)
|
||||
return base_slug unless slug_exists?(base_slug)
|
||||
|
||||
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
|
||||
|
||||
def slug_exists?(candidate)
|
||||
self.class.exists?(account_id: account_id, slug: candidate)
|
||||
end
|
||||
end
|
||||
@@ -57,7 +57,7 @@ class Captain::Scenario < ApplicationRecord
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) }
|
||||
resolved_tools.map { |tool| resolve_tool_instance(tool) }
|
||||
end
|
||||
|
||||
def resolved_instructions
|
||||
@@ -69,12 +69,24 @@ class Captain::Scenario < ApplicationRecord
|
||||
def resolved_tools
|
||||
return [] if tools.blank?
|
||||
|
||||
available_tools = self.class.available_agent_tools
|
||||
available_tools = assistant.available_agent_tools
|
||||
tools.filter_map do |tool_id|
|
||||
available_tools.find { |tool| tool[:id] == tool_id }
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_tool_instance(tool_metadata)
|
||||
tool_id = tool_metadata[:id]
|
||||
|
||||
if tool_metadata[:custom]
|
||||
custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true)
|
||||
custom_tool&.tool(assistant)
|
||||
else
|
||||
tool_class = self.class.resolve_tool_class(tool_id)
|
||||
tool_class&.new(assistant)
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that all tool references in the instruction are valid.
|
||||
# Parses the instruction for tool references and checks if they exist
|
||||
# in the available tools configuration.
|
||||
@@ -95,8 +107,8 @@ class Captain::Scenario < ApplicationRecord
|
||||
tool_ids = extract_tool_ids_from_text(instruction)
|
||||
return if tool_ids.empty?
|
||||
|
||||
available_tool_ids = self.class.available_tool_ids
|
||||
invalid_tools = tool_ids - available_tool_ids
|
||||
all_available_tool_ids = assistant.available_tool_ids
|
||||
invalid_tools = tool_ids - all_available_tool_ids
|
||||
|
||||
return unless invalid_tools.any?
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ module Concerns::CaptainToolsHelpers
|
||||
TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)}
|
||||
|
||||
class_methods do
|
||||
# Returns all available agent tools with their metadata.
|
||||
# Returns all built-in agent tools with their metadata.
|
||||
# Only includes tools that have corresponding class files and can be resolved.
|
||||
#
|
||||
# @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon
|
||||
def available_agent_tools
|
||||
@available_agent_tools ||= load_agent_tools
|
||||
def built_in_agent_tools
|
||||
@built_in_agent_tools ||= load_agent_tools
|
||||
end
|
||||
|
||||
# Resolves a tool class from a tool ID.
|
||||
@@ -26,12 +26,12 @@ module Concerns::CaptainToolsHelpers
|
||||
class_name.safe_constantize
|
||||
end
|
||||
|
||||
# Returns an array of all available tool IDs.
|
||||
# Convenience method that extracts just the IDs from available_agent_tools.
|
||||
# Returns an array of all built-in tool IDs.
|
||||
# Convenience method that extracts just the IDs from built_in_agent_tools.
|
||||
#
|
||||
# @return [Array<String>] Array of available tool IDs
|
||||
def available_tool_ids
|
||||
@available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] }
|
||||
# @return [Array<String>] Array of built-in tool IDs
|
||||
def built_in_tool_ids
|
||||
@built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
84
enterprise/app/models/concerns/safe_endpoint_validatable.rb
Normal file
84
enterprise/app/models/concerns/safe_endpoint_validatable.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
module Concerns::SafeEndpointValidatable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
FRONTEND_HOST = URI.parse(ENV.fetch('FRONTEND_URL', 'http://localhost:3000')).host.freeze
|
||||
DISALLOWED_HOSTS = ['localhost', /\.local\z/i].freeze
|
||||
|
||||
included do
|
||||
validate :validate_safe_endpoint_url
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_safe_endpoint_url
|
||||
return if endpoint_url.blank?
|
||||
|
||||
uri = parse_endpoint_uri
|
||||
return errors.add(:endpoint_url, 'must be a valid URL') unless uri
|
||||
|
||||
validate_endpoint_scheme(uri)
|
||||
validate_endpoint_host(uri)
|
||||
validate_not_ip_address(uri)
|
||||
validate_no_unicode_chars(uri)
|
||||
end
|
||||
|
||||
def parse_endpoint_uri
|
||||
# Strip Liquid template syntax for validation
|
||||
# Replace {{ variable }} with a placeholder value
|
||||
sanitized_url = endpoint_url.gsub(/\{\{[^}]+\}\}/, 'placeholder')
|
||||
URI.parse(sanitized_url)
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_endpoint_scheme(uri)
|
||||
return if uri.scheme == 'https'
|
||||
|
||||
errors.add(:endpoint_url, 'must use HTTPS protocol')
|
||||
end
|
||||
|
||||
def validate_endpoint_host(uri)
|
||||
if uri.host.blank?
|
||||
errors.add(:endpoint_url, 'must have a valid hostname')
|
||||
return
|
||||
end
|
||||
|
||||
if uri.host == FRONTEND_HOST
|
||||
errors.add(:endpoint_url, 'cannot point to the application itself')
|
||||
return
|
||||
end
|
||||
|
||||
DISALLOWED_HOSTS.each do |pattern|
|
||||
matched = if pattern.is_a?(Regexp)
|
||||
uri.host =~ pattern
|
||||
else
|
||||
uri.host.downcase == pattern
|
||||
end
|
||||
|
||||
next unless matched
|
||||
|
||||
errors.add(:endpoint_url, 'cannot use disallowed hostname')
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
def validate_not_ip_address(uri)
|
||||
# Check for IPv4
|
||||
if /\A\d+\.\d+\.\d+\.\d+\z/.match?(uri.host)
|
||||
errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname')
|
||||
return
|
||||
end
|
||||
|
||||
# Check for IPv6
|
||||
return unless uri.host.include?(':')
|
||||
|
||||
errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname')
|
||||
end
|
||||
|
||||
def validate_no_unicode_chars(uri)
|
||||
return unless uri.host
|
||||
return if /\A[\x00-\x7F]+\z/.match?(uri.host)
|
||||
|
||||
errors.add(:endpoint_url, 'hostname cannot contain non-ASCII characters')
|
||||
end
|
||||
end
|
||||
91
enterprise/app/models/concerns/toolable.rb
Normal file
91
enterprise/app/models/concerns/toolable.rb
Normal file
@@ -0,0 +1,91 @@
|
||||
module Concerns::Toolable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def tool(assistant)
|
||||
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
|
||||
description custom_tool_record.description
|
||||
|
||||
custom_tool_record.param_schema.each do |param_def|
|
||||
param param_def['name'].to_sym,
|
||||
type: param_def['type'],
|
||||
desc: param_def['description'],
|
||||
required: param_def.fetch('required', true)
|
||||
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)
|
||||
end
|
||||
|
||||
def build_request_url(params)
|
||||
return endpoint_url if endpoint_url.blank? || endpoint_url.exclude?('{{')
|
||||
|
||||
render_template(endpoint_url, params)
|
||||
end
|
||||
|
||||
def build_request_body(params)
|
||||
return nil if request_template.blank?
|
||||
|
||||
render_template(request_template, params)
|
||||
end
|
||||
|
||||
def build_auth_headers
|
||||
return {} if auth_none?
|
||||
|
||||
case auth_type
|
||||
when 'bearer'
|
||||
{ 'Authorization' => "Bearer #{auth_config['token']}" }
|
||||
when 'api_key'
|
||||
if auth_config['location'] == 'header'
|
||||
{ auth_config['name'] => auth_config['key'] }
|
||||
else
|
||||
{}
|
||||
end
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def build_basic_auth_credentials
|
||||
return nil unless auth_type == 'basic'
|
||||
|
||||
[auth_config['username'], auth_config['password']]
|
||||
end
|
||||
|
||||
def format_response(raw_response_body)
|
||||
return raw_response_body if response_template.blank?
|
||||
|
||||
response_data = parse_response_body(raw_response_body)
|
||||
render_template(response_template, { 'response' => response_data })
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_template(template, context)
|
||||
liquid_template = Liquid::Template.parse(template, error_mode: :strict)
|
||||
liquid_template.render(context.deep_stringify_keys, registers: {}, strict_variables: true, strict_filters: true)
|
||||
rescue Liquid::SyntaxError, Liquid::UndefinedVariable, Liquid::UndefinedFilter => e
|
||||
Rails.logger.error("Liquid template error: #{e.message}")
|
||||
raise "Template rendering failed: #{e.message}"
|
||||
end
|
||||
|
||||
def parse_response_body(body)
|
||||
JSON.parse(body)
|
||||
rescue JSON::ParserError
|
||||
body
|
||||
end
|
||||
end
|
||||
@@ -10,6 +10,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
|
||||
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
|
||||
105
enterprise/lib/captain/tools/http_tool.rb
Normal file
105
enterprise/lib/captain/tools/http_tool.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
require 'agents'
|
||||
|
||||
class Captain::Tools::HttpTool < Agents::Tool
|
||||
def initialize(assistant, custom_tool)
|
||||
@assistant = assistant
|
||||
@custom_tool = custom_tool
|
||||
super()
|
||||
end
|
||||
|
||||
def active?
|
||||
@custom_tool.enabled?
|
||||
end
|
||||
|
||||
def perform(_tool_context, **params)
|
||||
url = @custom_tool.build_request_url(params)
|
||||
body = @custom_tool.build_request_body(params)
|
||||
|
||||
response = execute_http_request(url, body)
|
||||
@custom_tool.format_response(response.body)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
|
||||
'An error occurred while executing the request'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
PRIVATE_IP_RANGES = [
|
||||
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
|
||||
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
|
||||
IPAddr.new('172.16.0.0/12'), # IPv4 Private network
|
||||
IPAddr.new('192.168.0.0/16'), # IPv4 Private network
|
||||
IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
|
||||
IPAddr.new('::1'), # IPv6 Loopback
|
||||
IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
|
||||
IPAddr.new('fe80::/10') # IPv6 Link-local
|
||||
].freeze
|
||||
|
||||
# Limit response size to prevent memory exhaustion and match LLM token limits
|
||||
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
|
||||
MAX_RESPONSE_SIZE = 1.megabyte
|
||||
|
||||
def execute_http_request(url, body)
|
||||
uri = URI.parse(url)
|
||||
|
||||
# Check if resolved IP is private
|
||||
check_private_ip!(uri.host)
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = uri.scheme == 'https'
|
||||
http.read_timeout = 30
|
||||
http.open_timeout = 10
|
||||
http.max_retries = 0 # Disable redirects
|
||||
|
||||
request = build_http_request(uri, body)
|
||||
apply_authentication(request)
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
||||
|
||||
validate_response!(response)
|
||||
|
||||
response
|
||||
end
|
||||
|
||||
def check_private_ip!(hostname)
|
||||
ip_address = IPAddr.new(Resolv.getaddress(hostname))
|
||||
|
||||
raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
|
||||
rescue Resolv::ResolvError, SocketError => e
|
||||
raise "DNS resolution failed: #{e.message}"
|
||||
end
|
||||
|
||||
def validate_response!(response)
|
||||
content_length = response['content-length']&.to_i
|
||||
if content_length && content_length > MAX_RESPONSE_SIZE
|
||||
raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE
|
||||
|
||||
raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
|
||||
end
|
||||
|
||||
def build_http_request(uri, body)
|
||||
if @custom_tool.http_method == 'POST'
|
||||
request = Net::HTTP::Post.new(uri.request_uri)
|
||||
if body
|
||||
request.body = body
|
||||
request['Content-Type'] = 'application/json'
|
||||
end
|
||||
else
|
||||
request = Net::HTTP::Get.new(uri.request_uri)
|
||||
end
|
||||
request
|
||||
end
|
||||
|
||||
def apply_authentication(request)
|
||||
headers = @custom_tool.build_auth_headers
|
||||
headers.each { |key, value| request[key] = value }
|
||||
|
||||
credentials = @custom_tool.build_basic_auth_credentials
|
||||
request.basic_auth(*credentials) if credentials
|
||||
end
|
||||
end
|
||||
@@ -118,7 +118,7 @@ class CaptainChatSession
|
||||
end
|
||||
|
||||
def show_available_tools
|
||||
available_tools = Captain::Assistant.available_tool_ids
|
||||
available_tools = @assistant.available_tool_ids
|
||||
if available_tools.any?
|
||||
puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}"
|
||||
else
|
||||
|
||||
@@ -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
|
||||
241
spec/enterprise/lib/captain/tools/http_tool_spec.rb
Normal file
241
spec/enterprise/lib/captain/tools/http_tool_spec.rb
Normal file
@@ -0,0 +1,241 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:custom_tool) { create(:captain_custom_tool, account: account) }
|
||||
let(:tool) { described_class.new(assistant, custom_tool) }
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true when custom tool is enabled' do
|
||||
custom_tool.update!(enabled: true)
|
||||
|
||||
expect(tool.active?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when custom tool is disabled' do
|
||||
custom_tool.update!(enabled: false)
|
||||
|
||||
expect(tool.active?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'with GET request' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
http_method: 'GET',
|
||||
endpoint_url: 'https://example.com/orders/123',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/orders/123')
|
||||
.to_return(status: 200, body: '{"status": "success"}')
|
||||
end
|
||||
|
||||
it 'executes GET request and returns response body' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"status": "success"}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with POST request' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
http_method: 'POST',
|
||||
endpoint_url: 'https://example.com/orders',
|
||||
request_template: '{"order_id": "{{ order_id }}"}',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:post, 'https://example.com/orders')
|
||||
.with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' })
|
||||
.to_return(status: 200, body: '{"created": true}')
|
||||
end
|
||||
|
||||
it 'executes POST request with rendered body' do
|
||||
result = tool.perform(tool_context, order_id: '123')
|
||||
|
||||
expect(result).to eq('{"created": true}')
|
||||
expect(WebMock).to have_requested(:post, 'https://example.com/orders')
|
||||
.with(body: '{"order_id": "123"}')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with template variables in URL' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
endpoint_url: 'https://example.com/orders/{{ order_id }}',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/orders/456')
|
||||
.to_return(status: 200, body: '{"order_id": "456"}')
|
||||
end
|
||||
|
||||
it 'renders URL template with params' do
|
||||
result = tool.perform(tool_context, order_id: '456')
|
||||
|
||||
expect(result).to eq('{"order_id": "456"}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/orders/456')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with bearer token authentication' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
auth_type: 'bearer',
|
||||
auth_config: { 'token' => 'secret_bearer_token' },
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
|
||||
it 'adds Authorization header with bearer token' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with basic authentication' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
auth_type: 'basic',
|
||||
auth_config: { 'username' => 'user123', 'password' => 'pass456' },
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(basic_auth: %w[user123 pass456])
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
|
||||
it 'adds basic auth credentials' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(basic_auth: %w[user123 pass456])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with API key authentication' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
auth_type: 'api_key',
|
||||
auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' },
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(headers: { 'X-API-Key' => 'api_key_123' })
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
|
||||
it 'adds API key header' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(headers: { 'X-API-Key' => 'api_key_123' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with response template' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
endpoint_url: 'https://example.com/orders/123',
|
||||
response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}'
|
||||
)
|
||||
stub_request(:get, 'https://example.com/orders/123')
|
||||
.to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}')
|
||||
end
|
||||
|
||||
it 'formats response using template' do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('Order status: shipped, ID: 123')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling errors' do
|
||||
it 'returns generic error message on network failure' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('An error occurred while executing the request')
|
||||
end
|
||||
|
||||
it 'returns generic error message on timeout' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_timeout
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('An error occurred while executing the request')
|
||||
end
|
||||
|
||||
it 'returns generic error message on HTTP 404' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found')
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('An error occurred while executing the request')
|
||||
end
|
||||
|
||||
it 'returns generic error message on HTTP 500' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error')
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('An error occurred while executing the request')
|
||||
end
|
||||
|
||||
it 'logs error details' do
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error'))
|
||||
|
||||
expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/)
|
||||
|
||||
tool.perform(tool_context)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when integrating with Toolable methods' do
|
||||
it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do
|
||||
custom_tool.update!(
|
||||
http_method: 'POST',
|
||||
endpoint_url: 'https://example.com/users/{{ user_id }}/orders',
|
||||
request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}',
|
||||
auth_type: 'bearer',
|
||||
auth_config: { 'token' => 'integration_token' },
|
||||
response_template: 'Created order #{{ response.order_number }} for {{ response.product }}'
|
||||
)
|
||||
|
||||
stub_request(:post, 'https://example.com/users/42/orders')
|
||||
.with(
|
||||
body: '{"product": "Widget", "quantity": 5}',
|
||||
headers: {
|
||||
'Authorization' => 'Bearer integration_token',
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
)
|
||||
.to_return(status: 200, body: '{"order_number": "ORD-789", "product": "Widget"}')
|
||||
|
||||
result = tool.perform(tool_context, user_id: '42', product: 'Widget', quantity: 5)
|
||||
|
||||
expect(result).to eq('Created order #ORD-789 for Widget')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
388
spec/enterprise/models/captain/custom_tool_spec.rb
Normal file
388
spec/enterprise/models/captain/custom_tool_spec.rb
Normal file
@@ -0,0 +1,388 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::CustomTool, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:title) }
|
||||
it { is_expected.to validate_presence_of(:endpoint_url) }
|
||||
it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
|
||||
|
||||
it {
|
||||
expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
|
||||
'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
|
||||
}
|
||||
|
||||
describe 'slug uniqueness' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'validates uniqueness of slug scoped to account' do
|
||||
create(: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.errors[:slug]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'allows same slug across different accounts' do
|
||||
account2 = create(:account)
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
|
||||
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
|
||||
|
||||
expect(different_account_tool).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe 'param_schema validation' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'is valid with proper param_schema' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true }
|
||||
])
|
||||
|
||||
expect(tool).to be_valid
|
||||
end
|
||||
|
||||
it 'is valid with empty param_schema' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [])
|
||||
|
||||
expect(tool).to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid when param_schema is missing name' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'type' => 'string', 'description' => 'Order ID' }
|
||||
])
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid when param_schema is missing type' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'description' => 'Order ID' }
|
||||
])
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid when param_schema is missing description' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'type' => 'string' }
|
||||
])
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid with additional properties in param_schema' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' }
|
||||
])
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
end
|
||||
|
||||
it 'is valid when required field is omitted (defaults to optional param)' do
|
||||
tool = build(:captain_custom_tool, account: account, param_schema: [
|
||||
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' }
|
||||
])
|
||||
|
||||
expect(tool).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'scopes' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe '.enabled' do
|
||||
it 'returns only enabled custom tools' do
|
||||
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
|
||||
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
|
||||
|
||||
expect(described_class.enabled).to include(enabled_tool)
|
||||
expect(described_class.enabled).not_to include(disabled_tool)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'slug generation' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'generates slug from title on creation' do
|
||||
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
|
||||
|
||||
expect(tool.slug).to eq('custom_fetch_order_status')
|
||||
end
|
||||
|
||||
it 'adds custom_ prefix to generated slug' do
|
||||
tool = create(:captain_custom_tool, account: account, title: 'My Tool')
|
||||
|
||||
expect(tool.slug).to start_with('custom_')
|
||||
end
|
||||
|
||||
it 'does not override manually set slug' do
|
||||
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug')
|
||||
|
||||
expect(tool.slug).to eq('custom_manual_slug')
|
||||
end
|
||||
|
||||
it 'handles slug collisions by appending random suffix' do
|
||||
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
|
||||
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
||||
|
||||
expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
|
||||
end
|
||||
|
||||
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_abc123')
|
||||
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
|
||||
|
||||
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
|
||||
|
||||
it 'does not generate slug when title is blank' do
|
||||
tool = build(:captain_custom_tool, account: account, title: nil)
|
||||
|
||||
expect(tool).not_to be_valid
|
||||
expect(tool.errors[:title]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'parameterizes title correctly' do
|
||||
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
|
||||
|
||||
expect(tool.slug).to eq('custom_fetch_order_status_details')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'creates a valid custom tool with default attributes' do
|
||||
tool = create(:captain_custom_tool)
|
||||
|
||||
expect(tool).to be_valid
|
||||
expect(tool.title).to be_present
|
||||
expect(tool.slug).to be_present
|
||||
expect(tool.endpoint_url).to be_present
|
||||
expect(tool.http_method).to eq('GET')
|
||||
expect(tool.auth_type).to eq('none')
|
||||
expect(tool.enabled).to be true
|
||||
end
|
||||
|
||||
it 'creates valid tool with POST trait' do
|
||||
tool = create(:captain_custom_tool, :with_post)
|
||||
|
||||
expect(tool.http_method).to eq('POST')
|
||||
expect(tool.request_template).to be_present
|
||||
end
|
||||
|
||||
it 'creates valid tool with bearer auth trait' do
|
||||
tool = create(:captain_custom_tool, :with_bearer_auth)
|
||||
|
||||
expect(tool.auth_type).to eq('bearer')
|
||||
expect(tool.auth_config['token']).to eq('test_bearer_token_123')
|
||||
end
|
||||
|
||||
it 'creates valid tool with basic auth trait' do
|
||||
tool = create(:captain_custom_tool, :with_basic_auth)
|
||||
|
||||
expect(tool.auth_type).to eq('basic')
|
||||
expect(tool.auth_config['username']).to eq('test_user')
|
||||
expect(tool.auth_config['password']).to eq('test_pass')
|
||||
end
|
||||
|
||||
it 'creates valid tool with api key trait' do
|
||||
tool = create(:captain_custom_tool, :with_api_key)
|
||||
|
||||
expect(tool.auth_type).to eq('api_key')
|
||||
expect(tool.auth_config['key']).to eq('test_api_key')
|
||||
expect(tool.auth_config['location']).to eq('header')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Toolable concern' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe '#build_request_url' do
|
||||
it 'returns static URL when no template variables present' do
|
||||
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders')
|
||||
|
||||
expect(tool.build_request_url({})).to eq('https://api.example.com/orders')
|
||||
end
|
||||
|
||||
it 'renders URL template with params' do
|
||||
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}')
|
||||
|
||||
expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345')
|
||||
end
|
||||
|
||||
it 'handles multiple template variables' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}')
|
||||
|
||||
result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' })
|
||||
expect(result).to eq('https://api.example.com/orders/123?details=true')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_request_body' do
|
||||
it 'returns nil when request_template is blank' do
|
||||
tool = create(:captain_custom_tool, account: account, request_template: nil)
|
||||
|
||||
expect(tool.build_request_body({})).to be_nil
|
||||
end
|
||||
|
||||
it 'renders request body template with params' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }')
|
||||
|
||||
result = tool.build_request_body({ order_id: '12345' })
|
||||
expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_auth_headers' do
|
||||
it 'returns empty hash for none auth type' do
|
||||
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
|
||||
|
||||
expect(tool.build_auth_headers).to eq({})
|
||||
end
|
||||
|
||||
it 'returns bearer token header' do
|
||||
tool = create(:captain_custom_tool, :with_bearer_auth, account: account)
|
||||
|
||||
expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
|
||||
end
|
||||
|
||||
it 'returns API key header when location is header' do
|
||||
tool = create(:captain_custom_tool, :with_api_key, account: account)
|
||||
|
||||
expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
|
||||
end
|
||||
|
||||
it 'returns empty hash for API key when location is not header' do
|
||||
tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
|
||||
auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
|
||||
|
||||
expect(tool.build_auth_headers).to eq({})
|
||||
end
|
||||
|
||||
it 'returns empty hash for basic auth' do
|
||||
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
|
||||
|
||||
expect(tool.build_auth_headers).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_basic_auth_credentials' do
|
||||
it 'returns nil for non-basic auth types' do
|
||||
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
|
||||
|
||||
expect(tool.build_basic_auth_credentials).to be_nil
|
||||
end
|
||||
|
||||
it 'returns username and password array for basic auth' do
|
||||
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
|
||||
|
||||
expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#format_response' do
|
||||
it 'returns raw response when no response_template' do
|
||||
tool = create(:captain_custom_tool, account: account, response_template: nil)
|
||||
|
||||
expect(tool.format_response('raw response')).to eq('raw response')
|
||||
end
|
||||
|
||||
it 'renders response template with JSON response' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
response_template: 'Order status: {{ response.status }}')
|
||||
raw_response = '{"status": "shipped", "tracking": "123ABC"}'
|
||||
|
||||
result = tool.format_response(raw_response)
|
||||
expect(result).to eq('Order status: shipped')
|
||||
end
|
||||
|
||||
it 'handles response template with multiple fields' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}')
|
||||
raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}'
|
||||
|
||||
result = tool.format_response(raw_response)
|
||||
expect(result).to eq('Order 12345 is delivered. Tracking: ABC123')
|
||||
end
|
||||
|
||||
it 'handles non-JSON response' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
response_template: 'Response: {{ response }}')
|
||||
raw_response = 'plain text response'
|
||||
|
||||
result = tool.format_response(raw_response)
|
||||
expect(result).to eq('Response: plain text response')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_tool_metadata' do
|
||||
it 'returns tool metadata hash with custom flag' do
|
||||
tool = create(:captain_custom_tool, account: account,
|
||||
slug: 'custom_test-tool',
|
||||
title: 'Test Tool',
|
||||
description: 'A test tool')
|
||||
|
||||
metadata = tool.to_tool_metadata
|
||||
expect(metadata).to eq({
|
||||
id: 'custom_test-tool',
|
||||
title: 'Test Tool',
|
||||
description: 'A test tool',
|
||||
custom: true
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe '#tool' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'returns HttpTool instance' do
|
||||
tool = create(:captain_custom_tool, account: account)
|
||||
|
||||
tool_instance = tool.tool(assistant)
|
||||
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
|
||||
it 'sets description on the tool class' do
|
||||
tool = create(:captain_custom_tool, account: account, description: 'Fetches order data')
|
||||
|
||||
tool_instance = tool.tool(assistant)
|
||||
expect(tool_instance.description).to eq('Fetches order data')
|
||||
end
|
||||
|
||||
it 'sets parameters on the tool class' do
|
||||
tool = create(:captain_custom_tool, :with_params, account: account)
|
||||
|
||||
tool_instance = tool.tool(assistant)
|
||||
params = tool_instance.parameters
|
||||
|
||||
expect(params.keys).to contain_exactly(:order_id, :include_details)
|
||||
expect(params[:order_id].name).to eq(:order_id)
|
||||
expect(params[:order_id].type).to eq('string')
|
||||
expect(params[:order_id].description).to eq('The order ID')
|
||||
expect(params[:order_id].required).to be true
|
||||
|
||||
expect(params[:include_details].name).to eq(:include_details)
|
||||
expect(params[:include_details].required).to be false
|
||||
end
|
||||
|
||||
it 'works with empty param_schema' do
|
||||
tool = create(:captain_custom_tool, account: account, param_schema: [])
|
||||
|
||||
tool_instance = tool.tool(assistant)
|
||||
expect(tool_instance.parameters).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -48,7 +48,7 @@ RSpec.describe Captain::Scenario, type: :model do
|
||||
|
||||
before do
|
||||
# Mock available tools
|
||||
allow(described_class).to receive(:available_tool_ids).and_return(%w[
|
||||
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[
|
||||
add_contact_note add_private_note update_priority
|
||||
])
|
||||
end
|
||||
@@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do
|
||||
expect(scenario).not_to be_valid
|
||||
expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/)
|
||||
end
|
||||
|
||||
it 'is valid with custom tool references' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
|
||||
|
||||
expect(scenario).to be_valid
|
||||
end
|
||||
|
||||
it 'is invalid with custom tool from different account' do
|
||||
other_account = create(:account)
|
||||
create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order')
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
|
||||
|
||||
expect(scenario).not_to be_valid
|
||||
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
|
||||
end
|
||||
|
||||
it 'is invalid with disabled custom tool' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
|
||||
|
||||
expect(scenario).not_to be_valid
|
||||
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
|
||||
end
|
||||
|
||||
it 'is valid with mixed static and custom tool references' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = build(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
expect(scenario).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe 'resolve_tool_references' do
|
||||
@@ -146,6 +189,140 @@ RSpec.describe Captain::Scenario, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'custom tool integration' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note])
|
||||
allow(described_class).to receive(:built_in_agent_tools).and_return([
|
||||
{ id: 'add_contact_note', title: 'Add Contact Note',
|
||||
description: 'Add a note' }
|
||||
])
|
||||
end
|
||||
|
||||
describe '#resolved_tools' do
|
||||
it 'includes custom tool metadata' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order',
|
||||
title: 'Fetch Order', description: 'Gets order details')
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
resolved = scenario.send(:resolved_tools)
|
||||
expect(resolved.length).to eq(1)
|
||||
expect(resolved.first[:id]).to eq('custom_fetch-order')
|
||||
expect(resolved.first[:title]).to eq('Fetch Order')
|
||||
expect(resolved.first[:description]).to eq('Gets order details')
|
||||
end
|
||||
|
||||
it 'includes both static and custom tools' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
resolved = scenario.send(:resolved_tools)
|
||||
expect(resolved.length).to eq(2)
|
||||
expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order')
|
||||
end
|
||||
|
||||
it 'excludes disabled custom tools' do
|
||||
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
custom_tool.update!(enabled: false)
|
||||
|
||||
resolved = scenario.send(:resolved_tools)
|
||||
expect(resolved).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe '#resolve_tool_instance' do
|
||||
it 'returns HttpTool instance for custom tools' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
|
||||
tool_metadata = { id: 'custom_fetch-order', custom: true }
|
||||
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
|
||||
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
|
||||
it 'returns nil for disabled custom tools' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
|
||||
tool_metadata = { id: 'custom_fetch-order', custom: true }
|
||||
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
|
||||
expect(tool_instance).to be_nil
|
||||
end
|
||||
|
||||
it 'returns static tool instance for non-custom tools' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
|
||||
Class.new do
|
||||
def initialize(_assistant); end
|
||||
end
|
||||
)
|
||||
|
||||
tool_metadata = { id: 'add_contact_note' }
|
||||
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
|
||||
expect(tool_instance).not_to be_nil
|
||||
expect(tool_instance).not_to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#agent_tools' do
|
||||
it 'returns array of tool instances including custom tools' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
tools = scenario.send(:agent_tools)
|
||||
expect(tools.length).to eq(1)
|
||||
expect(tools.first).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
|
||||
it 'excludes disabled custom tools from execution' do
|
||||
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
custom_tool.update!(enabled: false)
|
||||
|
||||
tools = scenario.send(:agent_tools)
|
||||
expect(tools).to be_empty
|
||||
end
|
||||
|
||||
it 'returns mixed static and custom tool instances' do
|
||||
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
|
||||
scenario = create(:captain_scenario,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
|
||||
|
||||
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
|
||||
Class.new do
|
||||
def initialize(_assistant); end
|
||||
end
|
||||
)
|
||||
|
||||
tools = scenario.send(:agent_tools)
|
||||
expect(tools.length).to eq(2)
|
||||
expect(tools.last).to be_a(Captain::Tools::HttpTool)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it 'creates a valid scenario with associations' do
|
||||
account = create(:account)
|
||||
|
||||
@@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
|
||||
end
|
||||
end
|
||||
|
||||
describe '.available_agent_tools' do
|
||||
before do
|
||||
# Mock the YAML file loading
|
||||
allow(YAML).to receive(:load_file).and_return([
|
||||
{
|
||||
'id' => 'add_contact_note',
|
||||
'title' => 'Add Contact Note',
|
||||
'description' => 'Add a note to a contact',
|
||||
'icon' => 'note-add'
|
||||
},
|
||||
{
|
||||
'id' => 'invalid_tool',
|
||||
'title' => 'Invalid Tool',
|
||||
'description' => 'This tool does not exist',
|
||||
'icon' => 'invalid'
|
||||
}
|
||||
])
|
||||
|
||||
# Mock class resolution - only add_contact_note exists
|
||||
allow(test_class).to receive(:resolve_tool_class) do |tool_id|
|
||||
case tool_id
|
||||
when 'add_contact_note'
|
||||
Captain::Tools::AddContactNoteTool
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns only resolvable tools' do
|
||||
tools = test_class.available_agent_tools
|
||||
|
||||
expect(tools.length).to eq(1)
|
||||
expect(tools.first).to eq({
|
||||
id: 'add_contact_note',
|
||||
title: 'Add Contact Note',
|
||||
description: 'Add a note to a contact',
|
||||
icon: 'note-add'
|
||||
})
|
||||
end
|
||||
|
||||
it 'logs warnings for unresolvable tools' do
|
||||
expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool')
|
||||
|
||||
test_class.available_agent_tools
|
||||
end
|
||||
|
||||
it 'memoizes the result' do
|
||||
expect(YAML).to receive(:load_file).once.and_return([])
|
||||
|
||||
2.times { test_class.available_agent_tools }
|
||||
end
|
||||
end
|
||||
|
||||
describe '.resolve_tool_class' do
|
||||
it 'resolves valid tool classes' do
|
||||
# Mock the constantize to return a class
|
||||
@@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
|
||||
end
|
||||
end
|
||||
|
||||
describe '.available_tool_ids' do
|
||||
before do
|
||||
allow(test_class).to receive(:available_agent_tools).and_return([
|
||||
{ id: 'add_contact_note', title: 'Add Contact Note', description: '...',
|
||||
icon: 'note' },
|
||||
{ id: 'update_priority', title: 'Update Priority', description: '...',
|
||||
icon: 'priority' }
|
||||
])
|
||||
end
|
||||
|
||||
it 'returns array of tool IDs' do
|
||||
ids = test_class.available_tool_ids
|
||||
expect(ids).to eq(%w[add_contact_note update_priority])
|
||||
end
|
||||
|
||||
it 'memoizes the result' do
|
||||
expect(test_class).to receive(:available_agent_tools).once.and_return([])
|
||||
|
||||
2.times { test_class.available_tool_ids }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_tool_ids_from_text' do
|
||||
it 'extracts tool IDs from text' do
|
||||
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
|
||||
|
||||
51
spec/factories/captain/custom_tool.rb
Normal file
51
spec/factories/captain/custom_tool.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
FactoryBot.define do
|
||||
factory :captain_custom_tool, class: 'Captain::CustomTool' do
|
||||
sequence(:title) { |n| "Custom Tool #{n}" }
|
||||
description { 'A custom HTTP tool for external API integration' }
|
||||
endpoint_url { 'https://api.example.com/endpoint' }
|
||||
http_method { 'GET' }
|
||||
auth_type { 'none' }
|
||||
auth_config { {} }
|
||||
param_schema { [] }
|
||||
enabled { true }
|
||||
association :account
|
||||
|
||||
trait :with_post do
|
||||
http_method { 'POST' }
|
||||
request_template { '{ "key": "{{ value }}" }' }
|
||||
end
|
||||
|
||||
trait :with_bearer_auth do
|
||||
auth_type { 'bearer' }
|
||||
auth_config { { token: 'test_bearer_token_123' } }
|
||||
end
|
||||
|
||||
trait :with_basic_auth do
|
||||
auth_type { 'basic' }
|
||||
auth_config { { username: 'test_user', password: 'test_pass' } }
|
||||
end
|
||||
|
||||
trait :with_api_key do
|
||||
auth_type { 'api_key' }
|
||||
auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } }
|
||||
end
|
||||
|
||||
trait :with_templates do
|
||||
request_template { '{ "order_id": "{{ order_id }}", "source": "chatwoot" }' }
|
||||
response_template { 'Order status: {{ response.status }}' }
|
||||
end
|
||||
|
||||
trait :with_params do
|
||||
param_schema do
|
||||
[
|
||||
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'The order ID', 'required' => true },
|
||||
{ 'name' => 'include_details', 'type' => 'boolean', 'description' => 'Include order details', 'required' => false }
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
trait :disabled do
|
||||
enabled { false }
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user