feat: Workflows v1

This commit is contained in:
Pranav
2026-02-27 21:05:26 -08:00
parent c08fa631a9
commit c0490794d1
52 changed files with 1903 additions and 33 deletions
@@ -0,0 +1,194 @@
<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 TriggerNode from 'dashboard/components-next/captain/workflows/nodes/TriggerNode.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';
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 = {
trigger_conversation_created: TriggerNode,
trigger_message_created: TriggerNode,
trigger_conversation_resolved: TriggerNode,
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, trigger_event, enabled }) => {
try {
await store.dispatch('captainWorkflows/update', {
id: workflowId.value,
assistantId: assistantId.value,
name,
description,
trigger_event,
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>
@@ -0,0 +1,170 @@
<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 TRIGGER_LABELS = {
conversation_created:
'CAPTAIN.ASSISTANTS.WORKFLOWS.TRIGGERS.CONVERSATION_CREATED',
message_created: 'CAPTAIN.ASSISTANTS.WORKFLOWS.TRIGGERS.MESSAGE_CREATED',
conversation_resolved:
'CAPTAIN.ASSISTANTS.WORKFLOWS.TRIGGERS.CONVERSATION_RESOLVED',
};
const createWorkflow = async () => {
try {
const workflow = await store.dispatch('captainWorkflows/create', {
assistantId: assistantId.value,
name: t('CAPTAIN.ASSISTANTS.WORKFLOWS.DEFAULT_NAME'),
trigger_event: 'conversation_created',
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>
<span class="text-xs text-n-slate-10">
{{ $t(TRIGGER_LABELS[workflow.trigger_event]) }}
</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