Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7db152f7b | ||
|
|
c0490794d1 |
@@ -0,0 +1,40 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainWorkflows extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/assistants', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ assistantId, page = 1 } = {}) {
|
||||
return axios.get(`${this.url}/${assistantId}/workflows`, {
|
||||
params: { page },
|
||||
});
|
||||
}
|
||||
|
||||
show({ assistantId, id }) {
|
||||
return axios.get(`${this.url}/${assistantId}/workflows/${id}`);
|
||||
}
|
||||
|
||||
create({ assistantId, ...data } = {}) {
|
||||
return axios.post(`${this.url}/${assistantId}/workflows`, {
|
||||
workflow: data,
|
||||
});
|
||||
}
|
||||
|
||||
update({ assistantId, id }, data = {}) {
|
||||
return axios.put(`${this.url}/${assistantId}/workflows/${id}`, {
|
||||
workflow: data,
|
||||
});
|
||||
}
|
||||
|
||||
delete({ assistantId, id }) {
|
||||
return axios.delete(`${this.url}/${assistantId}/workflows/${id}`);
|
||||
}
|
||||
|
||||
executions({ assistantId, id }) {
|
||||
return axios.get(`${this.url}/${assistantId}/workflows/${id}/executions`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainWorkflows();
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup>
|
||||
import { 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 Textarea from 'dashboard/components-next/textarea/Textarea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update', 'delete', 'close']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const nodeData = ref({ ...props.node.data });
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
val => {
|
||||
nodeData.value = { ...val.data };
|
||||
}
|
||||
);
|
||||
|
||||
const FIELD_CONFIGS = {
|
||||
send_message: [
|
||||
{
|
||||
key: 'message',
|
||||
component: 'textarea',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.MESSAGE',
|
||||
},
|
||||
],
|
||||
add_label: [
|
||||
{
|
||||
key: 'label',
|
||||
component: 'input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.LABEL',
|
||||
},
|
||||
],
|
||||
assign_agent: [
|
||||
{
|
||||
key: 'agent_id',
|
||||
component: 'input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.AGENT_ID',
|
||||
},
|
||||
],
|
||||
assign_team: [
|
||||
{
|
||||
key: 'team_id',
|
||||
component: 'input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.TEAM_ID',
|
||||
},
|
||||
],
|
||||
add_private_note: [
|
||||
{
|
||||
key: 'message',
|
||||
component: 'textarea',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.MESSAGE',
|
||||
},
|
||||
],
|
||||
update_priority: [
|
||||
{
|
||||
key: 'priority',
|
||||
component: 'input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.PRIORITY',
|
||||
},
|
||||
],
|
||||
collect_input: [
|
||||
{
|
||||
key: 'input_key',
|
||||
component: 'input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.INPUT_KEY',
|
||||
},
|
||||
{
|
||||
key: 'prompt',
|
||||
component: 'textarea',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.PROMPT',
|
||||
},
|
||||
],
|
||||
condition: [
|
||||
{
|
||||
key: 'attribute',
|
||||
component: 'input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.ATTRIBUTE',
|
||||
},
|
||||
{
|
||||
key: 'operator',
|
||||
component: 'input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.OPERATOR',
|
||||
},
|
||||
{
|
||||
key: 'value',
|
||||
component: 'input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.VALUE',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fields = ref(FIELD_CONFIGS[props.node.type] || []);
|
||||
|
||||
watch(
|
||||
() => props.node.type,
|
||||
type => {
|
||||
fields.value = FIELD_CONFIGS[type] || [];
|
||||
}
|
||||
);
|
||||
|
||||
const applyChanges = () => {
|
||||
emit('update', props.node.id, nodeData.value);
|
||||
};
|
||||
|
||||
const deleteNode = () => {
|
||||
emit('delete', props.node.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-72 shrink-0 border-l border-n-weak bg-n-solid-2 overflow-y-auto flex flex-col"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between items-center px-4 py-3 border-b border-n-weak"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.TITLE') }}
|
||||
</h3>
|
||||
<Button icon="i-lucide-x" xs ghost slate @click="emit('close')" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 p-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.LABEL_FIELD') }}
|
||||
</label>
|
||||
<Input v-model="nodeData.label" @change="applyChanges" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="flex flex-col gap-1.5"
|
||||
>
|
||||
<label class="text-xs font-medium text-n-slate-11">
|
||||
{{ t(field.label) }}
|
||||
</label>
|
||||
<Textarea
|
||||
v-if="field.component === 'textarea'"
|
||||
v-model="nodeData[field.key]"
|
||||
rows="3"
|
||||
@change="applyChanges"
|
||||
/>
|
||||
<Input v-else v-model="nodeData[field.key]" @change="applyChanges" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto flex items-center gap-2 p-4 border-t border-n-weak">
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.APPLY')"
|
||||
sm
|
||||
@click="applyChanges"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.DELETE')"
|
||||
icon="i-lucide-trash-2"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
@click="deleteNode"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const NODE_CATEGORIES = [
|
||||
{
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.PALETTE.INTERACTIVE',
|
||||
color: 'n-violet',
|
||||
icon: 'i-lucide-message-circle-question',
|
||||
nodes: [
|
||||
{
|
||||
type: 'collect_input',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.COLLECT_INPUT',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.PALETTE.LOGIC',
|
||||
color: 'n-amber',
|
||||
icon: 'i-lucide-git-branch',
|
||||
nodes: [
|
||||
{
|
||||
type: 'condition',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.CONDITION',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.PALETTE.ACTIONS',
|
||||
color: 'n-blue',
|
||||
icon: 'i-lucide-play',
|
||||
nodes: [
|
||||
{
|
||||
type: 'send_message',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.SEND_MESSAGE',
|
||||
},
|
||||
{
|
||||
type: 'add_label',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.ADD_LABEL',
|
||||
},
|
||||
{
|
||||
type: 'assign_agent',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.ASSIGN_AGENT',
|
||||
},
|
||||
{
|
||||
type: 'assign_team',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.ASSIGN_TEAM',
|
||||
},
|
||||
{
|
||||
type: 'resolve_conversation',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.RESOLVE_CONVERSATION',
|
||||
},
|
||||
{
|
||||
type: 'add_private_note',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.ADD_PRIVATE_NOTE',
|
||||
},
|
||||
{
|
||||
type: 'update_priority',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.UPDATE_PRIORITY',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.PALETTE.SHOPIFY',
|
||||
color: 'n-teal',
|
||||
icon: 'i-lucide-shopping-bag',
|
||||
nodes: [
|
||||
{
|
||||
type: 'shopify_search_customer',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.SHOPIFY_SEARCH_CUSTOMER',
|
||||
},
|
||||
{
|
||||
type: 'shopify_get_customer_orders',
|
||||
label: 'CAPTAIN.ASSISTANTS.WORKFLOWS.NODES.SHOPIFY_GET_CUSTOMER_ORDERS',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const onDragStart = (event, type) => {
|
||||
event.dataTransfer.setData('application/captainnode', type);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-60 shrink-0 border-r border-n-weak bg-n-solid-2 overflow-y-auto"
|
||||
>
|
||||
<div class="p-3 flex flex-col gap-4">
|
||||
<div v-for="category in NODE_CATEGORIES" :key="category.label">
|
||||
<div class="flex items-center gap-1.5 mb-2 px-1">
|
||||
<span class="size-3 text-n-slate-10" :class="[category.icon]" />
|
||||
<h4 class="text-[11px] font-semibold text-n-slate-10 uppercase">
|
||||
{{ t(category.label) }}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="node in category.nodes"
|
||||
:key="node.type"
|
||||
class="flex items-center gap-2 px-2.5 py-2 text-xs text-n-slate-12 rounded-lg cursor-grab bg-n-solid-2 hover:bg-n-alpha-2 outline outline-1 -outline-offset-1 outline-n-container transition-colors"
|
||||
draggable="true"
|
||||
@dragstart="e => onDragStart(e, node.type)"
|
||||
>
|
||||
{{ t(node.label) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { 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 Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const props = defineProps({
|
||||
workflow: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save', 'back']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const name = ref('');
|
||||
const description = ref('');
|
||||
const enabled = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.workflow,
|
||||
val => {
|
||||
if (val && val.id) {
|
||||
name.value = val.name || '';
|
||||
description.value = val.description || '';
|
||||
enabled.value = val.enabled || false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const save = () => {
|
||||
emit('save', {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
enabled: enabled.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-2.5 border-b border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<Button icon="i-lucide-arrow-left" xs ghost slate @click="emit('back')" />
|
||||
<span class="w-px h-4 bg-n-weak" />
|
||||
<Input
|
||||
v-model="name"
|
||||
class="max-w-xs"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.WORKFLOWS.TOOLBAR.NAME_PLACEHOLDER')"
|
||||
/>
|
||||
<div class="flex items-center gap-2.5 ml-auto">
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.TOOLBAR.ENABLED') }}
|
||||
</span>
|
||||
<Switch v-model="enabled" />
|
||||
<span class="w-px h-4 bg-n-weak" />
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.WORKFLOWS.TOOLBAR.SAVE')"
|
||||
sm
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
|
||||
defineProps({
|
||||
data: { type: Object, default: () => ({}) },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex rounded-lg bg-n-solid-2 outline outline-1 -outline-offset-1 outline-n-container shadow-sm"
|
||||
>
|
||||
<Handle type="target" :position="Position.Top" class="!bg-n-blue-9" />
|
||||
<div class="w-0.5 shrink-0 bg-n-blue-9" />
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 min-w-0">
|
||||
<span class="i-lucide-play text-n-blue-11 size-2.5 shrink-0" />
|
||||
<span
|
||||
class="text-[11px] leading-none font-medium text-n-slate-12 truncate"
|
||||
>
|
||||
{{ data.label || 'Action' }}
|
||||
</span>
|
||||
</div>
|
||||
<Handle type="source" :position="Position.Bottom" class="!bg-n-blue-9" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
|
||||
defineProps({
|
||||
data: { type: Object, default: () => ({}) },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex rounded-lg bg-n-solid-2 outline outline-1 -outline-offset-1 outline-n-container shadow-sm"
|
||||
>
|
||||
<Handle type="target" :position="Position.Top" class="!bg-n-violet-9" />
|
||||
<div class="w-0.5 shrink-0 bg-n-violet-9" />
|
||||
<div class="flex items-center gap-1 px-1.5 py-1 min-w-0">
|
||||
<span
|
||||
class="i-lucide-message-circle-question text-n-violet-11 size-2.5 shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-[11px] leading-none font-medium text-n-slate-12 truncate"
|
||||
>
|
||||
{{ data.label || 'Collect Input' }}
|
||||
</span>
|
||||
</div>
|
||||
<Handle type="source" :position="Position.Bottom" class="!bg-n-violet-9" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
|
||||
defineProps({
|
||||
data: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-32 rounded-xl bg-n-solid-2 outline outline-1 -outline-offset-1 outline-n-container shadow-sm"
|
||||
>
|
||||
<Handle type="target" :position="Position.Top" class="!bg-n-amber-9" />
|
||||
<div class="w-1 shrink-0 bg-n-amber-9" />
|
||||
<div class="flex flex-col gap-0.5 px-2 py-1.5 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-lucide-git-branch text-n-amber-11 size-3 shrink-0" />
|
||||
<span class="text-xs font-medium text-n-slate-12 truncate">
|
||||
{{ data.label || 'Condition' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span class="text-[11px] font-medium text-n-teal-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.CONDITION_TRUE') }}
|
||||
</span>
|
||||
<span class="text-[11px] font-medium text-n-ruby-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.WORKFLOWS.CONFIG.CONDITION_FALSE') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
id="true"
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
class="!left-[25%] !bg-n-teal-9"
|
||||
/>
|
||||
<Handle
|
||||
id="false"
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
class="!left-[75%] !bg-n-ruby-9"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
|
||||
defineProps({
|
||||
data: { type: Object, default: () => ({}) },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-32 rounded-xl bg-n-solid-2 outline outline-1 -outline-offset-1 outline-n-container shadow-sm"
|
||||
>
|
||||
<Handle type="target" :position="Position.Top" class="!bg-n-teal-9" />
|
||||
<div class="w-1 shrink-0 bg-n-teal-9" />
|
||||
<div class="flex flex-col gap-0.5 px-2 py-1.5 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-lucide-shopping-bag text-n-teal-11 size-3 shrink-0" />
|
||||
<span class="text-xs font-medium text-n-slate-12 truncate">
|
||||
{{ data.label || 'Shopify' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[11px] text-n-slate-10 leading-tight">
|
||||
{{ data.description || 'Shopify integration' }}
|
||||
</span>
|
||||
</div>
|
||||
<Handle type="source" :position="Position.Bottom" class="!bg-n-teal-9" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import { Handle, Position } from '@vue-flow/core';
|
||||
|
||||
defineProps({
|
||||
data: { type: Object, default: () => ({}) },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-52 rounded-xl bg-n-solid-2 outline outline-1 -outline-offset-1 outline-n-container shadow-sm overflow-hidden"
|
||||
>
|
||||
<div class="w-1 shrink-0 bg-n-teal-9" />
|
||||
<div class="flex flex-col gap-1 px-3 py-2.5 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="i-lucide-zap text-n-teal-11 size-3.5 shrink-0" />
|
||||
<span class="text-xs font-medium text-n-slate-12 truncate">
|
||||
{{ data.label || 'Trigger' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[11px] text-n-slate-10 leading-tight">
|
||||
{{ data.event || 'Starts the workflow' }}
|
||||
</span>
|
||||
</div>
|
||||
<Handle type="source" :position="Position.Bottom" class="!bg-n-teal-9" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -348,6 +348,17 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_scenarios_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Workflows',
|
||||
label: t('SIDEBAR.CAPTAIN_WORKFLOWS'),
|
||||
activeOn: [
|
||||
'captain_assistants_workflows_index',
|
||||
'captain_assistants_workflow_editor',
|
||||
],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_workflows_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Playground',
|
||||
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
|
||||
|
||||
@@ -732,6 +732,69 @@
|
||||
"ERROR": "There was an error deleting scenarios, please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WORKFLOWS": {
|
||||
"TITLE": "Workflows",
|
||||
"DESCRIPTION": "Build interactive workflows that Captain can execute during conversations.",
|
||||
"ADD_BUTTON": "New Workflow",
|
||||
"DEFAULT_NAME": "New Workflow",
|
||||
"EMPTY_MESSAGE": "No workflows yet. Create one to automate conversation actions.",
|
||||
"PALETTE": {
|
||||
"INTERACTIVE": "Interactive",
|
||||
"LOGIC": "Logic",
|
||||
"ACTIONS": "Actions",
|
||||
"SHOPIFY": "Shopify"
|
||||
},
|
||||
"NODES": {
|
||||
"COLLECT_INPUT": "Collect Input",
|
||||
"CONDITION": "Condition",
|
||||
"SEND_MESSAGE": "Send Message",
|
||||
"ADD_LABEL": "Add Label",
|
||||
"ASSIGN_AGENT": "Assign Agent",
|
||||
"ASSIGN_TEAM": "Assign Team",
|
||||
"RESOLVE_CONVERSATION": "Resolve Conversation",
|
||||
"ADD_PRIVATE_NOTE": "Add Private Note",
|
||||
"UPDATE_PRIORITY": "Update Priority",
|
||||
"SHOPIFY_SEARCH_CUSTOMER": "Search Customer",
|
||||
"SHOPIFY_GET_CUSTOMER_ORDERS": "Get Customer Orders"
|
||||
},
|
||||
"CONFIG": {
|
||||
"TITLE": "Node Configuration",
|
||||
"LABEL_FIELD": "Label",
|
||||
"MESSAGE": "Message",
|
||||
"LABEL": "Label",
|
||||
"AGENT_ID": "Agent ID",
|
||||
"TEAM_ID": "Team ID",
|
||||
"PRIORITY": "Priority",
|
||||
"ATTRIBUTE": "Attribute",
|
||||
"OPERATOR": "Operator",
|
||||
"VALUE": "Value",
|
||||
"INPUT_KEY": "Input Key",
|
||||
"PROMPT": "Prompt",
|
||||
"APPLY": "Apply",
|
||||
"DELETE": "Delete Node",
|
||||
"CONDITION_TRUE": "True",
|
||||
"CONDITION_FALSE": "False"
|
||||
},
|
||||
"TOOLBAR": {
|
||||
"NAME_PLACEHOLDER": "Workflow name",
|
||||
"ENABLED": "Enabled",
|
||||
"SAVE": "Save"
|
||||
},
|
||||
"API": {
|
||||
"CREATE": {
|
||||
"SUCCESS": "Workflow created successfully",
|
||||
"ERROR": "There was an error creating the workflow, please try again."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Workflow updated successfully",
|
||||
"ERROR": "There was an error updating the workflow, please try again."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Workflow deleted successfully",
|
||||
"ERROR": "There was an error deleting the workflow, please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DOCUMENTS": {
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
"CAPTAIN_SCENARIOS": "Scenarios",
|
||||
"CAPTAIN_WORKFLOWS": "Workflows",
|
||||
"CAPTAIN_PLAYGROUND": "Playground",
|
||||
"CAPTAIN_INBOXES": "Inboxes",
|
||||
"CAPTAIN_SETTINGS": "Settings",
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { VueFlow, useVueFlow } from '@vue-flow/core';
|
||||
|
||||
import WorkflowToolbar from 'dashboard/components-next/captain/workflows/WorkflowToolbar.vue';
|
||||
import NodePalette from 'dashboard/components-next/captain/workflows/NodePalette.vue';
|
||||
import NodeConfigPanel from 'dashboard/components-next/captain/workflows/NodeConfigPanel.vue';
|
||||
import ActionNode from 'dashboard/components-next/captain/workflows/nodes/ActionNode.vue';
|
||||
import ConditionNode from 'dashboard/components-next/captain/workflows/nodes/ConditionNode.vue';
|
||||
import ShopifyNode from 'dashboard/components-next/captain/workflows/nodes/ShopifyNode.vue';
|
||||
import CollectInputNode from 'dashboard/components-next/captain/workflows/nodes/CollectInputNode.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const assistantId = computed(() => Number(route.params.assistantId));
|
||||
const workflowId = computed(() => Number(route.params.workflowId));
|
||||
const workflow = computed(() =>
|
||||
store.getters['captainWorkflows/getRecord'](workflowId.value)
|
||||
);
|
||||
|
||||
const selectedNode = ref(null);
|
||||
|
||||
const { onConnect, addEdges, project } = useVueFlow();
|
||||
const nodes = ref([]);
|
||||
const edges = ref([]);
|
||||
|
||||
const nodeTypes = {
|
||||
collect_input: CollectInputNode,
|
||||
condition: ConditionNode,
|
||||
send_message: ActionNode,
|
||||
add_label: ActionNode,
|
||||
assign_agent: ActionNode,
|
||||
assign_team: ActionNode,
|
||||
resolve_conversation: ActionNode,
|
||||
add_private_note: ActionNode,
|
||||
update_priority: ActionNode,
|
||||
shopify_search_customer: ShopifyNode,
|
||||
shopify_get_customer_orders: ShopifyNode,
|
||||
};
|
||||
|
||||
watch(workflow, val => {
|
||||
if (val && val.id) {
|
||||
nodes.value = (val.nodes || []).map(n => ({
|
||||
...n,
|
||||
type: n.type,
|
||||
}));
|
||||
edges.value = val.edges || [];
|
||||
}
|
||||
});
|
||||
|
||||
onConnect(params => {
|
||||
addEdges([params]);
|
||||
});
|
||||
|
||||
const onDragOver = event => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const onDrop = event => {
|
||||
const type = event.dataTransfer.getData('application/captainnode');
|
||||
if (!type) return;
|
||||
|
||||
const { left, top } = event.currentTarget.getBoundingClientRect();
|
||||
const position = project({
|
||||
x: event.clientX - left,
|
||||
y: event.clientY - top,
|
||||
});
|
||||
|
||||
const newNode = {
|
||||
id: `node_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: { label: type.replace(/_/g, ' ') },
|
||||
};
|
||||
nodes.value = [...nodes.value, newNode];
|
||||
};
|
||||
|
||||
const onNodeClick = (_event, node) => {
|
||||
selectedNode.value = node;
|
||||
};
|
||||
|
||||
const onPaneClick = () => {
|
||||
selectedNode.value = null;
|
||||
};
|
||||
|
||||
const updateNodeData = (nodeId, data) => {
|
||||
nodes.value = nodes.value.map(n =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n
|
||||
);
|
||||
if (selectedNode.value?.id === nodeId) {
|
||||
selectedNode.value = {
|
||||
...selectedNode.value,
|
||||
data: { ...selectedNode.value.data, ...data },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNode = nodeId => {
|
||||
nodes.value = nodes.value.filter(n => n.id !== nodeId);
|
||||
edges.value = edges.value.filter(
|
||||
e => e.source !== nodeId && e.target !== nodeId
|
||||
);
|
||||
if (selectedNode.value?.id === nodeId) {
|
||||
selectedNode.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveWorkflow = async ({ name, description, enabled }) => {
|
||||
try {
|
||||
await store.dispatch('captainWorkflows/update', {
|
||||
id: workflowId.value,
|
||||
assistantId: assistantId.value,
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
nodes: nodes.value.map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
position: n.position,
|
||||
data: n.data,
|
||||
})),
|
||||
edges: edges.value.map(e => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
sourceHandle: e.sourceHandle,
|
||||
target: e.target,
|
||||
targetHandle: e.targetHandle,
|
||||
})),
|
||||
});
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.WORKFLOWS.API.UPDATE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('CAPTAIN.ASSISTANTS.WORKFLOWS.API.UPDATE.ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_workflows_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await store.dispatch('captainWorkflows/show', {
|
||||
assistantId: assistantId.value,
|
||||
id: workflowId.value,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full w-full bg-n-surface-1">
|
||||
<WorkflowToolbar :workflow="workflow" @save="saveWorkflow" @back="goBack" />
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<NodePalette />
|
||||
<div class="flex-1 relative" @dragover="onDragOver" @drop="onDrop">
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:node-types="nodeTypes"
|
||||
:default-viewport="{ zoom: 1 }"
|
||||
fit-view-on-init
|
||||
class="w-full h-full"
|
||||
@node-click="onNodeClick"
|
||||
@pane-click="onPaneClick"
|
||||
/>
|
||||
</div>
|
||||
<NodeConfigPanel
|
||||
v-if="selectedNode"
|
||||
:node="selectedNode"
|
||||
@update="updateNodeData"
|
||||
@delete="deleteNode"
|
||||
@close="selectedNode = null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.vue-flow__node {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
font-size: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vue-flow__handle {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid white;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path {
|
||||
stroke: var(--n-alpha-7);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.vue-flow__edge.selected .vue-flow__edge-path,
|
||||
.vue-flow__edge:hover .vue-flow__edge-path {
|
||||
stroke: var(--n-slate-9);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.vue-flow__connection-line {
|
||||
stroke: var(--n-slate-9);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Switch from 'dashboard/components-next/switch/Switch.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const assistantId = computed(() => Number(route.params.assistantId));
|
||||
const uiFlags = useMapGetter('captainWorkflows/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const workflows = useMapGetter('captainWorkflows/getRecords');
|
||||
|
||||
const createWorkflow = async () => {
|
||||
try {
|
||||
const workflow = await store.dispatch('captainWorkflows/create', {
|
||||
assistantId: assistantId.value,
|
||||
name: t('CAPTAIN.ASSISTANTS.WORKFLOWS.DEFAULT_NAME'),
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
router.push({
|
||||
name: 'captain_assistants_workflow_editor',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('CAPTAIN.ASSISTANTS.WORKFLOWS.API.CREATE.ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const editWorkflow = workflowId => {
|
||||
router.push({
|
||||
name: 'captain_assistants_workflow_editor',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
workflowId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleWorkflow = async workflow => {
|
||||
try {
|
||||
await store.dispatch('captainWorkflows/update', {
|
||||
id: workflow.id,
|
||||
assistantId: assistantId.value,
|
||||
enabled: !workflow.enabled,
|
||||
});
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.WORKFLOWS.API.UPDATE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('CAPTAIN.ASSISTANTS.WORKFLOWS.API.UPDATE.ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteWorkflow = async id => {
|
||||
try {
|
||||
await store.dispatch('captainWorkflows/delete', {
|
||||
id,
|
||||
assistantId: assistantId.value,
|
||||
});
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.WORKFLOWS.API.DELETE.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.response?.message ||
|
||||
t('CAPTAIN.ASSISTANTS.WORKFLOWS.API.DELETE.ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainWorkflows/get', {
|
||||
assistantId: assistantId.value,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
|
||||
:is-fetching="isFetching"
|
||||
:show-know-more="false"
|
||||
:show-pagination-footer="false"
|
||||
>
|
||||
<template #body>
|
||||
<SettingsHeader
|
||||
:heading="$t('CAPTAIN.ASSISTANTS.WORKFLOWS.TITLE')"
|
||||
:description="$t('CAPTAIN.ASSISTANTS.WORKFLOWS.DESCRIPTION')"
|
||||
/>
|
||||
<div class="flex mt-7 flex-col gap-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.ASSISTANTS.WORKFLOWS.ADD_BUTTON')"
|
||||
icon="i-lucide-plus"
|
||||
@click="createWorkflow"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="workflows.length === 0" class="mt-1 mb-2">
|
||||
<span class="text-n-slate-11 text-sm">
|
||||
{{ $t('CAPTAIN.ASSISTANTS.WORKFLOWS.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="workflow in workflows"
|
||||
:key="workflow.id"
|
||||
class="flex items-center justify-between p-4 border border-n-weak rounded-lg bg-n-solid-2 hover:bg-n-solid-3 cursor-pointer"
|
||||
@click="editWorkflow(workflow.id)"
|
||||
>
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<span class="text-sm font-medium text-n-slate-12 truncate">
|
||||
{{ workflow.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="workflow.description"
|
||||
class="text-xs text-n-slate-11 truncate"
|
||||
>
|
||||
{{ workflow.description }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<Switch
|
||||
:model-value="workflow.enabled"
|
||||
@click.stop
|
||||
@update:model-value="toggleWorkflow(workflow)"
|
||||
/>
|
||||
<Button
|
||||
icon="i-lucide-trash-2"
|
||||
xs
|
||||
ghost
|
||||
slate
|
||||
@click.stop="deleteWorkflow(workflow.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -12,6 +12,8 @@ import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
|
||||
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
|
||||
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import AssistantWorkflowsIndex from './assistants/workflows/Index.vue';
|
||||
import AssistantWorkflowEditor from './assistants/workflows/Editor.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
@@ -54,6 +56,20 @@ const assistantRoutes = [
|
||||
name: 'captain_assistants_scenarios_index',
|
||||
meta: metaV2,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/workflows'),
|
||||
component: AssistantWorkflowsIndex,
|
||||
name: 'captain_assistants_workflows_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/:assistantId/workflows/:workflowId'
|
||||
),
|
||||
component: AssistantWorkflowEditor,
|
||||
name: 'captain_assistants_workflow_editor',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/playground'),
|
||||
component: AssistantPlaygroundIndex,
|
||||
|
||||
@@ -56,6 +56,7 @@ const routeToLastActiveAssistant = () => {
|
||||
'captain_assistants_responses_index', // Faq page
|
||||
'captain_assistants_documents_index', // Document page
|
||||
'captain_assistants_scenarios_index', // Scenario page
|
||||
'captain_assistants_workflows_index', // Workflows page
|
||||
'captain_assistants_playground_index', // Playground page
|
||||
'captain_assistants_inboxes_index', // Inboxes page
|
||||
'captain_tools_index', // Tools page
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import CaptainWorkflows from 'dashboard/api/captain/workflows';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainWorkflow',
|
||||
API: CaptainWorkflows,
|
||||
actions: mutations => ({
|
||||
get: async ({ commit }, { assistantId, page = 1 } = {}) => {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const response = await CaptainWorkflows.get({ assistantId, page });
|
||||
commit(mutations.SET, response.data.payload);
|
||||
commit(mutations.SET_META, response.data.meta);
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: false });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
show: async ({ commit }, { assistantId, id }) => {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingItem: true });
|
||||
try {
|
||||
const response = await CaptainWorkflows.show({ assistantId, id });
|
||||
commit(mutations.ADD, response.data);
|
||||
commit(mutations.SET_UI_FLAG, { fetchingItem: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingItem: false });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
create: async ({ commit }, { assistantId, ...data }) => {
|
||||
commit(mutations.SET_UI_FLAG, { creatingItem: true });
|
||||
try {
|
||||
const response = await CaptainWorkflows.create({
|
||||
assistantId,
|
||||
...data,
|
||||
});
|
||||
commit(mutations.ADD, response.data);
|
||||
commit(mutations.SET_UI_FLAG, { creatingItem: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(mutations.SET_UI_FLAG, { creatingItem: false });
|
||||
return throwErrorMessage(error);
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { id, assistantId, ...updateObj }) => {
|
||||
commit(mutations.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await CaptainWorkflows.update(
|
||||
{ id, assistantId },
|
||||
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, assistantId }) => {
|
||||
commit(mutations.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await CaptainWorkflows.delete({ id, assistantId });
|
||||
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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -58,6 +58,7 @@ import copilotMessages from './captain/copilotMessages';
|
||||
import captainScenarios from './captain/scenarios';
|
||||
import captainTools from './captain/tools';
|
||||
import captainCustomTools from './captain/customTools';
|
||||
import captainWorkflows from './captain/workflows';
|
||||
|
||||
const plugins = [];
|
||||
|
||||
@@ -121,6 +122,7 @@ export default createStore({
|
||||
captainScenarios,
|
||||
captainTools,
|
||||
captainCustomTools,
|
||||
captainWorkflows,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ import { vResizeObserver } from '@vueuse/components';
|
||||
import { directive as onClickaway } from 'vue3-click-away';
|
||||
|
||||
import 'floating-vue/dist/style.css';
|
||||
import '@vue-flow/core/dist/style.css';
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false, // https://github.com/intlify/vue-i18n/issues/1902
|
||||
|
||||
@@ -66,6 +66,11 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
|
||||
resources :scenarios
|
||||
resources :workflows do
|
||||
member do
|
||||
get :executions
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :assistant_responses
|
||||
resources :bulk_actions, only: [:create]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class CreateCaptainWorkflows < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :captain_workflows do |t|
|
||||
t.string :name, null: false
|
||||
t.text :description
|
||||
t.references :account, null: false
|
||||
t.references :assistant, null: false
|
||||
t.string :trigger_event, null: false
|
||||
t.jsonb :trigger_conditions, default: {}
|
||||
t.jsonb :nodes, default: []
|
||||
t.jsonb :edges, default: []
|
||||
t.boolean :enabled, default: false, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :captain_workflows, [:account_id, :trigger_event, :enabled], name: 'index_captain_workflows_on_account_event_enabled'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class CreateCaptainWorkflowExecutions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :captain_workflow_executions do |t|
|
||||
t.references :workflow, null: false
|
||||
t.references :account, null: false
|
||||
t.references :conversation
|
||||
t.references :contact
|
||||
t.integer :status, default: 0, null: false
|
||||
t.datetime :started_at
|
||||
t.datetime :completed_at
|
||||
t.text :error_message
|
||||
t.jsonb :execution_log, default: []
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddPauseResumeToCaptainWorkflowExecutions < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_workflow_executions, :current_node_id, :string
|
||||
add_column :captain_workflow_executions, :context_store, :jsonb, default: {}
|
||||
end
|
||||
end
|
||||
+122
-33
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_27_100003) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -28,6 +28,32 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.index ["token"], name: "index_access_tokens_on_token", unique: true
|
||||
end
|
||||
|
||||
create_table "account_hourly_reports", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "hour", null: false
|
||||
t.string "scope_type", default: "account", null: false
|
||||
t.bigint "scope_id"
|
||||
t.integer "conversations_count", default: 0, null: false
|
||||
t.integer "incoming_messages_count", default: 0, null: false
|
||||
t.integer "outgoing_messages_count", default: 0, null: false
|
||||
t.integer "resolutions_count", default: 0, null: false
|
||||
t.integer "bot_resolutions_count", default: 0, null: false
|
||||
t.integer "bot_handoffs_count", default: 0, null: false
|
||||
t.float "first_response_time_sum", default: 0.0, null: false
|
||||
t.integer "first_response_time_count", default: 0, null: false
|
||||
t.float "resolution_time_sum", default: 0.0, null: false
|
||||
t.integer "resolution_time_count", default: 0, null: false
|
||||
t.float "reply_time_sum", default: 0.0, null: false
|
||||
t.integer "reply_time_count", default: 0, null: false
|
||||
t.float "first_response_time_bh_sum", default: 0.0, null: false
|
||||
t.float "resolution_time_bh_sum", default: 0.0, null: false
|
||||
t.float "reply_time_bh_sum", default: 0.0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "hour", "scope_type", "scope_id"], name: "idx_account_hourly_reports_unique", unique: true
|
||||
t.index ["account_id", "scope_type", "scope_id", "hour"], name: "idx_account_hourly_reports_lookup"
|
||||
end
|
||||
|
||||
create_table "account_saml_settings", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "sso_url"
|
||||
@@ -71,6 +97,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.jsonb "limits", default: {}
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.integer "status", default: 0
|
||||
t.integer "contactable_contacts_count", default: 0
|
||||
t.jsonb "internal_attributes", default: {}, null: false
|
||||
t.jsonb "settings", default: {}
|
||||
t.index ["status"], name: "index_accounts_on_status"
|
||||
@@ -384,6 +411,43 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.index ["enabled"], name: "index_captain_scenarios_on_enabled"
|
||||
end
|
||||
|
||||
create_table "captain_workflow_executions", force: :cascade do |t|
|
||||
t.bigint "workflow_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "conversation_id"
|
||||
t.bigint "contact_id"
|
||||
t.integer "status", default: 0, null: false
|
||||
t.datetime "started_at"
|
||||
t.datetime "completed_at"
|
||||
t.text "error_message"
|
||||
t.jsonb "execution_log", default: []
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "current_node_id"
|
||||
t.jsonb "context_store", default: {}
|
||||
t.index ["account_id"], name: "index_captain_workflow_executions_on_account_id"
|
||||
t.index ["contact_id"], name: "index_captain_workflow_executions_on_contact_id"
|
||||
t.index ["conversation_id"], name: "index_captain_workflow_executions_on_conversation_id"
|
||||
t.index ["workflow_id"], name: "index_captain_workflow_executions_on_workflow_id"
|
||||
end
|
||||
|
||||
create_table "captain_workflows", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.text "description"
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "assistant_id", null: false
|
||||
t.string "trigger_event", null: false
|
||||
t.jsonb "trigger_conditions", default: {}
|
||||
t.jsonb "nodes", default: []
|
||||
t.jsonb "edges", default: []
|
||||
t.boolean "enabled", default: false, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "trigger_event", "enabled"], name: "index_captain_workflows_on_account_event_enabled"
|
||||
t.index ["account_id"], name: "index_captain_workflows_on_account_id"
|
||||
t.index ["assistant_id"], name: "index_captain_workflows_on_assistant_id"
|
||||
end
|
||||
|
||||
create_table "categories", force: :cascade do |t|
|
||||
t.integer "account_id", null: false
|
||||
t.integer "portal_id", null: false
|
||||
@@ -589,13 +653,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.bigint "account_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "contacts_count"
|
||||
t.integer "contacts_count", default: 0, null: false
|
||||
t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)"
|
||||
t.index ["account_id"], name: "index_companies_on_account_id"
|
||||
t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id"
|
||||
end
|
||||
|
||||
create_table "contact_inboxes", force: :cascade do |t|
|
||||
create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.bigint "contact_id"
|
||||
t.bigint "inbox_id"
|
||||
t.text "source_id", null: false
|
||||
@@ -603,14 +667,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "hmac_verified", default: false
|
||||
t.string "pubsub_token"
|
||||
t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id"
|
||||
t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true
|
||||
t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id"
|
||||
t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true
|
||||
t.index ["source_id"], name: "index_contact_inboxes_on_source_id"
|
||||
t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx"
|
||||
t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true
|
||||
t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx"
|
||||
t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true
|
||||
t.index ["source_id"], name: "contact_inboxes2_source_id_idx"
|
||||
end
|
||||
|
||||
create_table "contacts", id: :serial, force: :cascade do |t|
|
||||
create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.string "name", default: ""
|
||||
t.string "email"
|
||||
t.string "phone_number"
|
||||
@@ -621,25 +685,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.string "identifier"
|
||||
t.jsonb "custom_attributes", default: {}
|
||||
t.datetime "last_activity_at", precision: nil
|
||||
t.integer "contact_type", default: 0
|
||||
t.boolean "resolved", default: false, null: false
|
||||
t.integer "contact_type", default: 0, null: false
|
||||
t.string "middle_name", default: ""
|
||||
t.string "last_name", default: ""
|
||||
t.string "location", default: ""
|
||||
t.string "country_code", default: ""
|
||||
t.boolean "blocked", default: false, null: false
|
||||
t.bigint "company_id"
|
||||
t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id"
|
||||
t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx"
|
||||
t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type"
|
||||
t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" }
|
||||
t.index ["account_id"], name: "index_contacts_on_account_id"
|
||||
t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved"
|
||||
t.index ["account_id"], name: "contacts2_account_id_idx"
|
||||
t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))"
|
||||
t.index ["blocked"], name: "index_contacts_on_blocked"
|
||||
t.index ["company_id"], name: "index_contacts_on_company_id"
|
||||
t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true
|
||||
t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true
|
||||
t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id"
|
||||
t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true
|
||||
t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true
|
||||
t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx"
|
||||
end
|
||||
|
||||
create_table "conversation_participants", force: :cascade do |t|
|
||||
@@ -667,7 +733,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.datetime "agent_last_seen_at", precision: nil
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.bigint "contact_inbox_id"
|
||||
t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false
|
||||
t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false
|
||||
t.string "identifier"
|
||||
t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false
|
||||
t.bigint "team_id"
|
||||
@@ -681,6 +747,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.datetime "waiting_since"
|
||||
t.text "cached_label_list"
|
||||
t.bigint "assignee_agent_bot_id"
|
||||
t.bigint "last_message_id"
|
||||
t.bigint "last_incoming_message_id"
|
||||
t.bigint "last_non_activity_message_id"
|
||||
t.text "cached_summary"
|
||||
t.datetime "cached_summary_at"
|
||||
t.index ["account_id", "created_at", "inbox_id"], name: "index_conversations_on_account_created_inbox"
|
||||
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
|
||||
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
|
||||
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
|
||||
@@ -692,6 +764,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at"
|
||||
t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id"
|
||||
t.index ["inbox_id"], name: "index_conversations_on_inbox_id"
|
||||
t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id"
|
||||
t.index ["last_message_id"], name: "index_conversations_on_last_message_id"
|
||||
t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id"
|
||||
t.index ["priority"], name: "index_conversations_on_priority"
|
||||
t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id"
|
||||
t.index ["status", "priority"], name: "index_conversations_on_status_and_priority"
|
||||
@@ -758,7 +833,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.string "regex_pattern"
|
||||
t.string "regex_cue"
|
||||
t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id"
|
||||
t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true
|
||||
end
|
||||
|
||||
create_table "custom_filters", force: :cascade do |t|
|
||||
@@ -941,7 +1015,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.integer "visibility", default: 0
|
||||
t.bigint "created_by_id"
|
||||
t.bigint "updated_by_id"
|
||||
t.jsonb "actions", default: {}, null: false
|
||||
t.jsonb "actions", default: "{}", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_macros_on_account_id"
|
||||
@@ -960,7 +1034,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.index ["user_id"], name: "index_mentions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "messages", id: :serial, force: :cascade do |t|
|
||||
create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t|
|
||||
t.text "content"
|
||||
t.integer "account_id", null: false
|
||||
t.integer "inbox_id", null: false
|
||||
@@ -979,18 +1053,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.jsonb "additional_attributes", default: {}
|
||||
t.text "processed_message_content"
|
||||
t.jsonb "sentiment", default: {}
|
||||
t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin
|
||||
t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin
|
||||
t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created"
|
||||
t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type"
|
||||
t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id"
|
||||
t.index ["account_id"], name: "index_messages_on_account_id"
|
||||
t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created"
|
||||
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
|
||||
t.index ["created_at"], name: "index_messages_on_created_at"
|
||||
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
|
||||
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
|
||||
t.index ["source_id"], name: "index_messages_on_source_id"
|
||||
t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx"
|
||||
t.index ["account_id"], name: "messages2_account_id_idx"
|
||||
t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin
|
||||
t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx"
|
||||
t.index ["conversation_id"], name: "messages2_conversation_id_idx"
|
||||
t.index ["created_at"], name: "messages2_created_at_idx"
|
||||
t.index ["inbox_id"], name: "messages2_inbox_id_idx"
|
||||
t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx"
|
||||
t.index ["source_id"], name: "messages2_source_id_idx"
|
||||
end
|
||||
|
||||
create_table "notes", force: :cascade do |t|
|
||||
@@ -1048,6 +1122,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||
end
|
||||
|
||||
create_table "pg_search_documents", force: :cascade do |t|
|
||||
t.text "content"
|
||||
t.bigint "conversation_id"
|
||||
t.bigint "account_id"
|
||||
t.string "searchable_type"
|
||||
t.bigint "searchable_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_pg_search_documents_on_account_id"
|
||||
t.index ["conversation_id"], name: "index_pg_search_documents_on_conversation_id"
|
||||
t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable"
|
||||
end
|
||||
|
||||
create_table "platform_app_permissibles", force: :cascade do |t|
|
||||
t.bigint "platform_app_id", null: false
|
||||
t.string "permissible_type", null: false
|
||||
@@ -1114,6 +1201,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.float "value_in_business_hours"
|
||||
t.datetime "event_start_time", precision: nil
|
||||
t.datetime "event_end_time", precision: nil
|
||||
t.index ["account_id", "name", "created_at", "inbox_id"], name: "index_reporting_events_on_account_name_created_inbox"
|
||||
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
|
||||
t.index ["account_id", "name", "inbox_id", "created_at"], name: "index_reporting_events_for_response_distribution"
|
||||
t.index ["account_id"], name: "index_reporting_events_on_account_id"
|
||||
@@ -1231,7 +1319,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.text "message_signature"
|
||||
t.string "otp_secret"
|
||||
t.integer "consumed_timestep"
|
||||
t.boolean "otp_required_for_login", default: false
|
||||
t.boolean "otp_required_for_login", default: false, null: false
|
||||
t.text "otp_backup_codes"
|
||||
t.index ["email"], name: "index_users_on_email"
|
||||
t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login"
|
||||
@@ -1270,6 +1358,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do
|
||||
t.index ["inbox_id"], name: "index_working_hours_on_inbox_id"
|
||||
end
|
||||
|
||||
add_foreign_key "account_hourly_reports", "accounts"
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "inboxes", "portals"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
class Api::V1::Accounts::Captain::WorkflowsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Workflow) }
|
||||
before_action :set_assistant
|
||||
before_action :set_workflow, only: [:show, :update, :destroy, :executions]
|
||||
|
||||
def index
|
||||
@workflows = assistant_workflows.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@workflow = assistant_workflows.create!(workflow_params.merge(account: Current.account))
|
||||
end
|
||||
|
||||
def update
|
||||
@workflow.update!(workflow_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@workflow.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def executions
|
||||
@executions = @workflow.executions.order(created_at: :desc).limit(50)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_assistant
|
||||
@assistant = account_assistants.find(params[:assistant_id])
|
||||
end
|
||||
|
||||
def account_assistants
|
||||
@account_assistants ||= Current.account.captain_assistants
|
||||
end
|
||||
|
||||
def set_workflow
|
||||
@workflow = assistant_workflows.find(params[:id])
|
||||
end
|
||||
|
||||
def assistant_workflows
|
||||
@assistant.workflows
|
||||
end
|
||||
|
||||
def workflow_params
|
||||
permitted = params.require(:workflow).permit(:name, :description, :trigger_event, :enabled, trigger_conditions: {})
|
||||
permitted[:nodes] = params[:workflow][:nodes] if params[:workflow][:nodes].present?
|
||||
permitted[:edges] = params[:workflow][:edges] if params[:workflow][:edges].present?
|
||||
permitted
|
||||
end
|
||||
end
|
||||
@@ -28,7 +28,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
delegate :account, :inbox, to: :@conversation
|
||||
|
||||
def generate_and_process_response
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.display_id).generate_response(
|
||||
@response = Captain::Llm::AssistantChatService.new(
|
||||
assistant: @assistant,
|
||||
conversation: @conversation,
|
||||
conversation_id: @conversation.display_id
|
||||
).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
process_response
|
||||
|
||||
@@ -35,6 +35,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
has_many :workflows, class_name: 'Captain::Workflow', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class Captain::Workflow < ApplicationRecord
|
||||
self.table_name = 'captain_workflows'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
belongs_to :account
|
||||
has_many :executions, class_name: 'Captain::WorkflowExecution', dependent: :destroy_async
|
||||
|
||||
validates :name, presence: true
|
||||
validates :assistant_id, presence: true
|
||||
validates :account_id, presence: true
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
class Captain::WorkflowExecution < ApplicationRecord
|
||||
self.table_name = 'captain_workflow_executions'
|
||||
|
||||
belongs_to :workflow, class_name: 'Captain::Workflow'
|
||||
belongs_to :account
|
||||
belongs_to :conversation, optional: true
|
||||
belongs_to :contact, optional: true
|
||||
|
||||
enum :status, { pending: 0, running: 1, completed: 2, failed: 3, waiting_for_input: 4 }
|
||||
end
|
||||
@@ -13,6 +13,7 @@ module Enterprise::Concerns::Account
|
||||
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 :captain_workflows, dependent: :destroy_async, class_name: 'Captain::Workflow'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :companies, dependent: :destroy_async
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class Captain::WorkflowPolicy < 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
|
||||
|
||||
def executions?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,11 @@
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
def initialize(assistant: nil, conversation: nil, conversation_id: nil)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@conversation_id = conversation_id
|
||||
|
||||
@messages = [system_message]
|
||||
@@ -28,16 +29,28 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
private
|
||||
|
||||
def build_tools
|
||||
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
tools = [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
if @conversation && @assistant.workflows.enabled.exists?
|
||||
tools << Captain::Tools::ExecuteWorkflowService.new(@assistant, conversation: @conversation)
|
||||
end
|
||||
tools
|
||||
end
|
||||
|
||||
def system_message
|
||||
{
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.name, @assistant.config['product_name'], @assistant.config)
|
||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
|
||||
@assistant.name, @assistant.config['product_name'], @assistant.config,
|
||||
workflows: @assistant.workflows.enabled,
|
||||
active_execution: @conversation ? find_active_execution : nil
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
def find_active_execution
|
||||
Captain::WorkflowExecution.where(conversation: @conversation, status: :waiting_for_input).last
|
||||
end
|
||||
|
||||
def persist_message(message, message_type = 'assistant')
|
||||
# No need to implement
|
||||
end
|
||||
|
||||
@@ -152,7 +152,7 @@ class Captain::Llm::SystemPromptsService
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def assistant_response_generator(assistant_name, product_name, config = {})
|
||||
def assistant_response_generator(assistant_name, product_name, config = {}, workflows: [], active_execution: nil)
|
||||
assistant_citation_guidelines = if config['feature_citation']
|
||||
<<~CITATION_TEXT
|
||||
- Always include citations for any information provided, referencing the specific source (document only - skip if it was derived from a conversation).
|
||||
@@ -203,6 +203,7 @@ class Captain::Llm::SystemPromptsService
|
||||
```
|
||||
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
|
||||
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
|
||||
#{workflow_prompt_section(workflows, active_execution)}
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
@@ -288,6 +289,39 @@ class Captain::Llm::SystemPromptsService
|
||||
PROMPT
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
private
|
||||
|
||||
def workflow_prompt_section(workflows, active_execution)
|
||||
return '' if workflows.blank? && active_execution.blank?
|
||||
|
||||
sections = []
|
||||
sections << available_workflows_prompt(workflows) if workflows.present?
|
||||
sections << active_execution_prompt(active_execution) if active_execution.present?
|
||||
sections.join("\n")
|
||||
end
|
||||
|
||||
def available_workflows_prompt(workflows)
|
||||
workflow_list = workflows.map { |w| "- ID: #{w.id}, Name: \"#{w.name}\", Description: \"#{w.description}\"" }.join("\n")
|
||||
<<~WORKFLOWS
|
||||
[Available Workflows]
|
||||
You can execute workflows to help the customer. Use the execute_workflow tool to start or continue a workflow.
|
||||
#{workflow_list}
|
||||
WORKFLOWS
|
||||
end
|
||||
|
||||
def active_execution_prompt(active_execution)
|
||||
paused_node = active_execution.workflow.nodes.find { |n| n['id'] == active_execution.current_node_id }
|
||||
input_key = paused_node&.dig('data', 'input_key') || 'user_input'
|
||||
prompt = paused_node&.dig('data', 'prompt') || 'Please provide input'
|
||||
|
||||
<<~EXECUTION
|
||||
[Active Workflow Execution]
|
||||
Execution ID: #{active_execution.id} is waiting for input.
|
||||
Input key: "#{input_key}", Prompt: "#{prompt}"
|
||||
Call execute_workflow(execution_id: #{active_execution.id}, user_input: "<user's response>") to continue.
|
||||
EXECUTION
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/ClassLength
|
||||
|
||||
@@ -3,9 +3,10 @@ class Captain::Tools::BaseTool < RubyLLM::Tool
|
||||
|
||||
attr_accessor :assistant
|
||||
|
||||
def initialize(assistant, user: nil)
|
||||
def initialize(assistant, user: nil, conversation: nil)
|
||||
@assistant = assistant
|
||||
@user = user
|
||||
@conversation = conversation
|
||||
super()
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
class Captain::Tools::ExecuteWorkflowService < Captain::Tools::BaseTool
|
||||
def self.name
|
||||
'execute_workflow'
|
||||
end
|
||||
|
||||
description 'Execute or continue a workflow. Use workflow_id to start a new workflow, execution_id to continue a paused one.'
|
||||
|
||||
param :workflow_id, desc: 'ID of workflow to start (for new executions)', required: false
|
||||
param :execution_id, desc: 'ID of paused execution to continue', required: false
|
||||
param :user_input, desc: 'User response for the current step', required: false
|
||||
|
||||
def execute(workflow_id: nil, execution_id: nil, user_input: nil)
|
||||
if execution_id.present?
|
||||
continue_execution(execution_id, user_input)
|
||||
elsif workflow_id.present?
|
||||
start_execution(workflow_id)
|
||||
else
|
||||
'Provide either workflow_id to start or execution_id to continue a workflow.'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def start_execution(workflow_id)
|
||||
workflow = assistant.workflows.enabled.find_by(id: workflow_id)
|
||||
return "Workflow #{workflow_id} not found or not enabled." unless workflow
|
||||
|
||||
context = build_context
|
||||
result = Captain::Workflows::ExecutionService.new(workflow, context).perform
|
||||
format_result(result)
|
||||
end
|
||||
|
||||
def continue_execution(execution_id, user_input)
|
||||
execution = Captain::WorkflowExecution.find_by(id: execution_id, status: :waiting_for_input)
|
||||
return "Execution #{execution_id} not found or not waiting for input." unless execution
|
||||
|
||||
result = Captain::Workflows::ExecutionService.resume(execution, user_input)
|
||||
format_result(result)
|
||||
end
|
||||
|
||||
def build_context
|
||||
context = { account: assistant.account }
|
||||
if @conversation
|
||||
context[:conversation] = @conversation
|
||||
context[:contact] = @conversation.contact
|
||||
end
|
||||
context
|
||||
end
|
||||
|
||||
def format_result(result)
|
||||
return result.to_json unless result.is_a?(Hash)
|
||||
|
||||
case result[:status]
|
||||
when 'input_required'
|
||||
"Workflow paused. Need user input: #{result[:prompt]} (input_key: #{result[:input_key]}). " \
|
||||
"To continue, call execute_workflow(execution_id: #{result[:execution_id]}, user_input: \"<user's response>\")."
|
||||
when 'completed'
|
||||
"Workflow completed successfully. Actions taken: #{result[:actions].to_json}"
|
||||
when 'failed'
|
||||
"Workflow failed: #{result[:error]}"
|
||||
else
|
||||
result.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,188 @@
|
||||
class Captain::Workflows::ExecutionService
|
||||
attr_reader :workflow, :context, :execution
|
||||
|
||||
def initialize(workflow, context)
|
||||
@workflow = workflow
|
||||
@context = context.with_indifferent_access
|
||||
@paused = false
|
||||
end
|
||||
|
||||
def perform
|
||||
@execution = create_execution
|
||||
execution.update!(status: :running, started_at: Time.current)
|
||||
traverse_graph
|
||||
finalize_execution
|
||||
rescue StandardError => e
|
||||
handle_failure(e)
|
||||
end
|
||||
|
||||
def self.resume(execution, user_input)
|
||||
context = build_resume_context(execution, user_input)
|
||||
service = new(execution.workflow, context)
|
||||
service.resume_from(execution)
|
||||
end
|
||||
|
||||
def resume_from(existing_execution)
|
||||
@execution = existing_execution
|
||||
@paused = false
|
||||
execution.update!(status: :running)
|
||||
|
||||
resume_from_paused_node
|
||||
finalize_execution
|
||||
rescue StandardError => e
|
||||
handle_failure(e, 'resume')
|
||||
end
|
||||
|
||||
def self.build_resume_context(execution, user_input)
|
||||
context = execution.context_store.with_indifferent_access
|
||||
context[:account] = execution.workflow.account
|
||||
context[:conversation] = execution.conversation if execution.conversation
|
||||
context[:contact] = execution.contact if execution.contact
|
||||
|
||||
paused_node = execution.workflow.nodes.find { |n| n['id'] == execution.current_node_id }
|
||||
input_key = paused_node&.dig('data', 'input_key') || 'user_input'
|
||||
context[input_key] = user_input
|
||||
context
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resume_from_paused_node
|
||||
nodes = workflow.nodes
|
||||
edges = workflow.edges
|
||||
paused_node = nodes.find { |n| n['id'] == execution.current_node_id }
|
||||
return unless paused_node
|
||||
|
||||
execute_node(paused_node, nodes, edges, Set.new)
|
||||
end
|
||||
|
||||
def finalize_execution
|
||||
if @paused
|
||||
save_paused_state
|
||||
{ status: 'input_required', prompt: @pause_result[:prompt], input_key: @pause_result[:input_key], execution_id: execution.id }
|
||||
else
|
||||
execution.update!(status: :completed, completed_at: Time.current)
|
||||
{ status: 'completed', actions: execution.execution_log }
|
||||
end
|
||||
end
|
||||
|
||||
def save_paused_state
|
||||
execution.update!(status: :waiting_for_input, current_node_id: @pause_node_id, context_store: serializable_context)
|
||||
end
|
||||
|
||||
def handle_failure(error, label = nil)
|
||||
execution&.update!(status: :failed, completed_at: Time.current, error_message: error.message)
|
||||
tag = label ? " #{label}" : ''
|
||||
Rails.logger.error("[CaptainWorkflow] Workflow #{workflow.id}#{tag} failed: #{error.message}")
|
||||
{ status: 'failed', error: error.message }
|
||||
end
|
||||
|
||||
def create_execution
|
||||
Captain::WorkflowExecution.create!(
|
||||
workflow: workflow,
|
||||
account: workflow.account,
|
||||
conversation_id: context[:conversation]&.id,
|
||||
contact_id: context[:contact]&.id,
|
||||
status: :pending,
|
||||
execution_log: []
|
||||
)
|
||||
end
|
||||
|
||||
def traverse_graph
|
||||
nodes = workflow.nodes
|
||||
edges = workflow.edges
|
||||
return if nodes.blank?
|
||||
|
||||
entry_nodes = find_entry_nodes(nodes, edges)
|
||||
return if entry_nodes.empty?
|
||||
|
||||
visited = Set.new
|
||||
entry_nodes.each do |entry_node|
|
||||
break if @paused
|
||||
|
||||
execute_node(entry_node, nodes, edges, visited)
|
||||
end
|
||||
end
|
||||
|
||||
def find_entry_nodes(nodes, edges)
|
||||
target_ids = edges.to_set { |e| e['target'] }
|
||||
nodes.reject { |n| target_ids.include?(n['id']) }
|
||||
end
|
||||
|
||||
def execute_node(node, nodes, edges, visited)
|
||||
return if @paused
|
||||
|
||||
node_id = node['id']
|
||||
return if visited.include?(node_id)
|
||||
|
||||
visited.add(node_id)
|
||||
|
||||
result = run_node_executor(node)
|
||||
return unless result
|
||||
|
||||
return pause_at(node_id, result) if input_required?(result)
|
||||
|
||||
follow_edges(node_id, nodes, edges, result, visited)
|
||||
end
|
||||
|
||||
def run_node_executor(node)
|
||||
executor_class = Captain::Workflows::NodeRegistry.resolve(node['type'])
|
||||
unless executor_class
|
||||
log_step(node['id'], node['type'], 'skipped', { reason: 'unknown node type' })
|
||||
return nil
|
||||
end
|
||||
|
||||
result = executor_class.new(node, context).execute
|
||||
log_step(node['id'], node['type'], 'completed', result)
|
||||
result
|
||||
end
|
||||
|
||||
def input_required?(result)
|
||||
result.is_a?(Hash) && result[:status] == 'input_required'
|
||||
end
|
||||
|
||||
def pause_at(node_id, result)
|
||||
@paused = true
|
||||
@pause_node_id = node_id
|
||||
@pause_result = result
|
||||
end
|
||||
|
||||
def follow_edges(node_id, nodes, edges, result, visited)
|
||||
next_edges = find_next_edges(node_id, edges, result)
|
||||
next_edges.each do |edge|
|
||||
break if @paused
|
||||
|
||||
target_node = nodes.find { |n| n['id'] == edge['target'] }
|
||||
execute_node(target_node, nodes, edges, visited) if target_node
|
||||
end
|
||||
end
|
||||
|
||||
def find_next_edges(node_id, edges, result)
|
||||
outgoing = edges.select { |e| e['source'] == node_id }
|
||||
|
||||
if result.is_a?(Hash) && result[:next_handle]
|
||||
outgoing.select { |e| e['sourceHandle'] == result[:next_handle] }
|
||||
else
|
||||
outgoing
|
||||
end
|
||||
end
|
||||
|
||||
def log_step(node_id, node_type, status, result)
|
||||
execution.execution_log << {
|
||||
node_id: node_id,
|
||||
node_type: node_type,
|
||||
status: status,
|
||||
result: result,
|
||||
timestamp: Time.current.iso8601
|
||||
}
|
||||
execution.save!
|
||||
end
|
||||
|
||||
def serializable_context
|
||||
context.each_with_object({}) do |(key, value), hash|
|
||||
next if value.is_a?(ActiveRecord::Base)
|
||||
|
||||
hash[key] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class Captain::Workflows::NodeRegistry
|
||||
REGISTRY = {
|
||||
'collect_input' => Captain::Workflows::Nodes::CollectInputNode,
|
||||
'condition' => Captain::Workflows::Nodes::ConditionNode,
|
||||
'send_message' => Captain::Workflows::Nodes::SendMessageNode,
|
||||
'add_label' => Captain::Workflows::Nodes::AddLabelNode,
|
||||
'assign_agent' => Captain::Workflows::Nodes::AssignAgentNode,
|
||||
'assign_team' => Captain::Workflows::Nodes::AssignTeamNode,
|
||||
'resolve_conversation' => Captain::Workflows::Nodes::ResolveConversationNode,
|
||||
'add_private_note' => Captain::Workflows::Nodes::AddPrivateNoteNode,
|
||||
'update_priority' => Captain::Workflows::Nodes::UpdatePriorityNode,
|
||||
'shopify_search_customer' => Captain::Workflows::Nodes::Shopify::SearchCustomerNode,
|
||||
'shopify_get_customer_orders' => Captain::Workflows::Nodes::Shopify::GetCustomerOrdersNode
|
||||
}.freeze
|
||||
|
||||
def self.resolve(node_type)
|
||||
REGISTRY[node_type]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class Captain::Workflows::Nodes::AddLabelNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
label = node_data['label']
|
||||
conversation.label_list.add(label)
|
||||
conversation.save!
|
||||
{ label: label }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class Captain::Workflows::Nodes::AddPrivateNoteNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
content = interpolate(node_data['message'])
|
||||
note = conversation.messages.create!(
|
||||
account: account,
|
||||
message_type: :activity,
|
||||
content: content,
|
||||
private: true
|
||||
)
|
||||
{ note_id: note.id }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class Captain::Workflows::Nodes::AssignAgentNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
agent_id = node_data['agent_id']
|
||||
conversation.update!(assignee_id: agent_id)
|
||||
{ agent_id: agent_id }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class Captain::Workflows::Nodes::AssignTeamNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
team_id = node_data['team_id']
|
||||
conversation.update!(team_id: team_id)
|
||||
{ team_id: team_id }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Captain::Workflows::Nodes::BaseNode
|
||||
attr_reader :node_config, :context
|
||||
|
||||
def initialize(node_config, context)
|
||||
@node_config = node_config
|
||||
@context = context
|
||||
end
|
||||
|
||||
def execute
|
||||
raise NotImplementedError, "#{self.class} must implement #execute"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
context[:account]
|
||||
end
|
||||
|
||||
def conversation
|
||||
context[:conversation]
|
||||
end
|
||||
|
||||
def contact
|
||||
context[:contact]
|
||||
end
|
||||
|
||||
def node_data
|
||||
node_config['data'] || {}
|
||||
end
|
||||
|
||||
def interpolate(template)
|
||||
return template unless template.is_a?(String)
|
||||
|
||||
template.gsub(/\{\{(\w+(?:\.\w+)*)\}\}/) do |match|
|
||||
keys = ::Regexp.last_match(1).split('.')
|
||||
value = context.dig(*keys.map(&:to_sym))
|
||||
value.nil? ? match : value.to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class Captain::Workflows::Nodes::CollectInputNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
input_key = node_data['input_key'] || 'user_input'
|
||||
|
||||
if context[input_key].present?
|
||||
{ collected: context[input_key] }
|
||||
else
|
||||
{ status: 'input_required', prompt: node_data['prompt'] || 'Please provide input', input_key: input_key }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Captain::Workflows::Nodes::ConditionNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
result = evaluate_condition
|
||||
{ next_handle: result ? 'true' : 'false' }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def evaluate_condition
|
||||
attribute = node_data['attribute']
|
||||
operator = node_data['operator']
|
||||
value = node_data['value']
|
||||
|
||||
actual_value = resolve_attribute(attribute)
|
||||
compare(actual_value, operator, value)
|
||||
end
|
||||
|
||||
def resolve_attribute(attribute)
|
||||
case attribute
|
||||
when /\Aconversation\./
|
||||
conversation&.send(attribute.sub('conversation.', ''))
|
||||
when /\Acontact\./
|
||||
contact&.send(attribute.sub('contact.', ''))
|
||||
else
|
||||
context.dig(*attribute.split('.').map(&:to_sym))
|
||||
end
|
||||
rescue NoMethodError
|
||||
nil
|
||||
end
|
||||
|
||||
def compare(actual, operator, expected)
|
||||
case operator
|
||||
when 'equals' then actual.to_s == expected.to_s
|
||||
when 'not_equals' then actual.to_s != expected.to_s
|
||||
when 'contains' then actual.to_s.include?(expected.to_s)
|
||||
when 'not_contains' then actual.to_s.exclude?(expected.to_s)
|
||||
when 'is_present' then actual.present?
|
||||
when 'is_blank' then actual.blank?
|
||||
else false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class Captain::Workflows::Nodes::ResolveConversationNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
conversation.resolve!
|
||||
{ resolved: true }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
class Captain::Workflows::Nodes::SendMessageNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
content = interpolate(node_data['message'])
|
||||
message = conversation.messages.create!(
|
||||
account: account,
|
||||
message_type: :outgoing,
|
||||
content: content
|
||||
)
|
||||
{ message_id: message.id }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
class Captain::Workflows::Nodes::Shopify::BaseShopifyNode < Captain::Workflows::Nodes::BaseNode
|
||||
include ::Shopify::IntegrationHelper
|
||||
|
||||
private
|
||||
|
||||
def hook
|
||||
@hook ||= Integrations::Hook.find_by!(account: account, app_id: 'shopify')
|
||||
end
|
||||
|
||||
def setup_shopify_context
|
||||
ShopifyAPI::Context.setup(
|
||||
api_key: client_id,
|
||||
api_secret_key: client_secret,
|
||||
api_version: '2025-01'.freeze,
|
||||
scope: REQUIRED_SCOPES.join(','),
|
||||
is_embedded: true,
|
||||
is_private: false
|
||||
)
|
||||
end
|
||||
|
||||
def shopify_session
|
||||
ShopifyAPI::Auth::Session.new(shop: hook.reference_id, access_token: hook.access_token)
|
||||
end
|
||||
|
||||
def shopify_client
|
||||
@shopify_client ||= begin
|
||||
setup_shopify_context
|
||||
ShopifyAPI::Clients::Rest::Admin.new(session: shopify_session)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class Captain::Workflows::Nodes::Shopify::GetCustomerOrdersNode < Captain::Workflows::Nodes::Shopify::BaseShopifyNode
|
||||
def execute
|
||||
customer_id = context[:shopify_customer_id] || node_data['shopify_customer_id']
|
||||
return { orders: [], error: 'No Shopify customer ID available' } if customer_id.blank?
|
||||
|
||||
orders = shopify_client.get(
|
||||
path: 'orders.json',
|
||||
query: {
|
||||
customer_id: customer_id,
|
||||
status: 'any',
|
||||
fields: 'id,email,created_at,total_price,currency,fulfillment_status,financial_status'
|
||||
}
|
||||
).body['orders'] || []
|
||||
|
||||
{ orders: orders }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class Captain::Workflows::Nodes::Shopify::SearchCustomerNode < Captain::Workflows::Nodes::Shopify::BaseShopifyNode
|
||||
def execute
|
||||
query = build_search_query
|
||||
return { customers: [], error: 'No contact email or phone available' } if query.blank?
|
||||
|
||||
customers = shopify_client.get(
|
||||
path: 'customers/search.json',
|
||||
query: { query: query, fields: 'id,email,phone,first_name,last_name' }
|
||||
).body['customers'] || []
|
||||
|
||||
context[:shopify_customer_id] = customers.first&.dig('id')
|
||||
{ customers: customers }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_search_query
|
||||
parts = []
|
||||
parts << "email:#{contact.email}" if contact&.email.present?
|
||||
parts << "phone:#{contact&.phone_number}" if contact&.phone_number.present?
|
||||
parts.join(' OR ').presence
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class Captain::Workflows::Nodes::TriggerNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
{ event: node_data['event'], triggered_at: Time.current.iso8601 }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class Captain::Workflows::Nodes::UpdatePriorityNode < Captain::Workflows::Nodes::BaseNode
|
||||
def execute
|
||||
priority = node_data['priority']
|
||||
conversation.update!(priority: priority)
|
||||
{ priority: priority }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/workflow', workflow: @workflow
|
||||
@@ -0,0 +1,14 @@
|
||||
json.payload do
|
||||
json.array! @executions do |execution|
|
||||
json.id execution.id
|
||||
json.workflow_id execution.workflow_id
|
||||
json.conversation_id execution.conversation_id
|
||||
json.contact_id execution.contact_id
|
||||
json.status execution.status
|
||||
json.started_at execution.started_at
|
||||
json.completed_at execution.completed_at
|
||||
json.error_message execution.error_message
|
||||
json.execution_log execution.execution_log
|
||||
json.created_at execution.created_at
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,10 @@
|
||||
json.payload do
|
||||
json.array! @workflows do |workflow|
|
||||
json.partial! 'api/v1/models/captain/workflow', workflow: workflow
|
||||
end
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.total_count @workflows.count
|
||||
json.page 1
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/workflow', workflow: @workflow
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/captain/workflow', workflow: @workflow
|
||||
@@ -0,0 +1,12 @@
|
||||
json.id workflow.id
|
||||
json.name workflow.name
|
||||
json.description workflow.description
|
||||
json.trigger_event workflow.trigger_event
|
||||
json.trigger_conditions workflow.trigger_conditions
|
||||
json.nodes workflow.nodes
|
||||
json.edges workflow.edges
|
||||
json.enabled workflow.enabled
|
||||
json.assistant_id workflow.assistant_id
|
||||
json.account_id workflow.account_id
|
||||
json.created_at workflow.created_at
|
||||
json.updated_at workflow.updated_at
|
||||
@@ -53,6 +53,7 @@
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"@twilio/voice-sdk": "^2.12.4",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vue-flow/core": "^1.48.2",
|
||||
"@vue/compiler-sfc": "^3.5.8",
|
||||
"@vuelidate/core": "^2.0.3",
|
||||
"@vuelidate/validators": "^2.0.4",
|
||||
|
||||
Generated
+136
@@ -79,6 +79,9 @@ importers:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(vite@5.4.21(@types/node@22.7.0)(sass@1.79.3)(terser@5.33.0))(vue@3.5.12(typescript@5.6.2))
|
||||
'@vue-flow/core':
|
||||
specifier: ^1.48.2
|
||||
version: 1.48.2(vue@3.5.12(typescript@5.6.2))
|
||||
'@vue/compiler-sfc':
|
||||
specifier: ^3.5.8
|
||||
version: 3.5.8
|
||||
@@ -1418,6 +1421,11 @@ packages:
|
||||
'@vitest/utils@3.0.5':
|
||||
resolution: {integrity: sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==}
|
||||
|
||||
'@vue-flow/core@1.48.2':
|
||||
resolution: {integrity: sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==}
|
||||
peerDependencies:
|
||||
vue: ^3.3.0
|
||||
|
||||
'@vue/compiler-core@3.5.12':
|
||||
resolution: {integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==}
|
||||
|
||||
@@ -1530,12 +1538,21 @@ packages:
|
||||
'@vueuse/components@12.0.0':
|
||||
resolution: {integrity: sha512-XpOoBXYRuFuUiiq+HsMX6rGzqvcHdKnbT4sbR0FHYxwSGBHO3Zli8pPTZoLRNBGp4CGov7BRCnANEK/1Ch/6tQ==}
|
||||
|
||||
'@vueuse/core@10.11.1':
|
||||
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
|
||||
|
||||
'@vueuse/core@12.0.0':
|
||||
resolution: {integrity: sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==}
|
||||
|
||||
'@vueuse/metadata@10.11.1':
|
||||
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
|
||||
|
||||
'@vueuse/metadata@12.0.0':
|
||||
resolution: {integrity: sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==}
|
||||
|
||||
'@vueuse/shared@10.11.1':
|
||||
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
|
||||
|
||||
'@vueuse/shared@12.0.0':
|
||||
resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==}
|
||||
|
||||
@@ -2009,6 +2026,44 @@ packages:
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-dispatch@3.0.1:
|
||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-selection@3.0.0:
|
||||
resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-transition@3.0.1:
|
||||
resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
d3-selection: 2 - 3
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
data-urls@3.0.2:
|
||||
resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4622,6 +4677,17 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.0.0-rc.1
|
||||
vue: ^3.0.0-0 || ^2.6.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-dompurify-html@5.1.0:
|
||||
resolution: {integrity: sha512-616o2/PBdOLM2bwlRWLdzeEC9NerLkwiudqNgaIJ5vBQWXec+u7Kuzh+45DtQQrids67s4pHnTnJZLVfyPMxbA==}
|
||||
peerDependencies:
|
||||
@@ -5999,6 +6065,17 @@ snapshots:
|
||||
loupe: 3.1.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vue-flow/core@1.48.2(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
'@vueuse/core': 10.11.1(vue@3.5.12(typescript@5.6.2))
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
|
||||
'@vue/compiler-core@3.5.12':
|
||||
dependencies:
|
||||
'@babel/parser': 7.26.2
|
||||
@@ -6184,6 +6261,16 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/core@10.11.1(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
'@vueuse/metadata': 10.11.1
|
||||
'@vueuse/shared': 10.11.1(vue@3.5.12(typescript@5.6.2))
|
||||
vue-demi: 0.14.10(vue@3.5.12(typescript@5.6.2))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/core@12.0.0(typescript@5.6.2)':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.20
|
||||
@@ -6193,8 +6280,17 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
'@vueuse/metadata@10.11.1': {}
|
||||
|
||||
'@vueuse/metadata@12.0.0': {}
|
||||
|
||||
'@vueuse/shared@10.11.1(vue@3.5.12(typescript@5.6.2))':
|
||||
dependencies:
|
||||
vue-demi: 0.14.10(vue@3.5.12(typescript@5.6.2))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@vueuse/shared@12.0.0(typescript@5.6.2)':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.6.2)
|
||||
@@ -6720,6 +6816,42 @@ snapshots:
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-dispatch@3.0.1: {}
|
||||
|
||||
d3-drag@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-selection@3.0.0: {}
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
d3-transition@3.0.1(d3-selection@3.0.0):
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
d3-dispatch: 3.0.1
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
d3-zoom@3.0.0:
|
||||
dependencies:
|
||||
d3-dispatch: 3.0.1
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-transition: 3.0.1(d3-selection@3.0.0)
|
||||
|
||||
data-urls@3.0.2:
|
||||
dependencies:
|
||||
abab: 2.0.6
|
||||
@@ -9672,6 +9804,10 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
vue-dompurify-html@5.1.0(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
dompurify: 3.2.4
|
||||
|
||||
Reference in New Issue
Block a user