Compare commits

...
Author SHA1 Message Date
Pranav d7db152f7b Add workflows inside captain 2026-02-28 09:02:20 +03:00
Pranav c0490794d1 feat: Workflows v1 2026-02-27 21:05:26 -08:00
59 changed files with 2110 additions and 39 deletions
@@ -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);
}
},
}),
});
+2
View File
@@ -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,
});
+1
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
+1
View File
@@ -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",
+136
View File
@@ -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