feat(phase2): add Flow Builder UI and internal flow routes
- Add FlowBuilder.tsx with React Flow visual editor - Add FlowList.tsx for flow management - Add /internal/flow/send endpoint for flow-engine messaging - Add reactflow dependency to frontend Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,8 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"zustand": "^5.0.3",
|
"zustand": "^5.0.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"dayjs": "^1.11.13"
|
"dayjs": "^1.11.13",
|
||||||
|
"reactflow": "^11.11.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
|
|||||||
159
frontend/src/pages/FlowBuilder.tsx
Normal file
159
frontend/src/pages/FlowBuilder.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import ReactFlow, {
|
||||||
|
Node,
|
||||||
|
Edge,
|
||||||
|
Controls,
|
||||||
|
Background,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
addEdge,
|
||||||
|
Connection,
|
||||||
|
NodeTypes,
|
||||||
|
} from 'reactflow';
|
||||||
|
import 'reactflow/dist/style.css';
|
||||||
|
import { Button, message, Space } from 'antd';
|
||||||
|
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
|
||||||
|
const TriggerNode = ({ data }: { data: any }) => (
|
||||||
|
<div style={{ padding: 10, border: '2px solid #52c41a', borderRadius: 8, background: '#f6ffed' }}>
|
||||||
|
<strong>🚀 {data.label}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const MessageNode = ({ data }: { data: any }) => (
|
||||||
|
<div style={{ padding: 10, border: '2px solid #1890ff', borderRadius: 8, background: '#e6f7ff', minWidth: 150 }}>
|
||||||
|
<strong>💬 Mensaje</strong>
|
||||||
|
<div style={{ fontSize: 12, marginTop: 4 }}>{data.config?.text?.slice(0, 50) || 'Sin texto'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ConditionNode = ({ data }: { data: any }) => (
|
||||||
|
<div style={{ padding: 10, border: '2px solid #faad14', borderRadius: 8, background: '#fffbe6' }}>
|
||||||
|
<strong>❓ Condición</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const WaitInputNode = ({ data }: { data: any }) => (
|
||||||
|
<div style={{ padding: 10, border: '2px solid #722ed1', borderRadius: 8, background: '#f9f0ff' }}>
|
||||||
|
<strong>⏳ Esperar Input</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeTypes: NodeTypes = {
|
||||||
|
trigger: TriggerNode,
|
||||||
|
message: MessageNode,
|
||||||
|
condition: ConditionNode,
|
||||||
|
wait_input: WaitInputNode,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Flow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodes: Node[];
|
||||||
|
edges: Edge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FlowBuilder() {
|
||||||
|
const { flowId } = useParams<{ flowId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
|
|
||||||
|
const { data: flow, isLoading } = useQuery({
|
||||||
|
queryKey: ['flow', flowId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await apiClient.get<Flow>(`/api/flows/${flowId}`);
|
||||||
|
setNodes(data.nodes || []);
|
||||||
|
setEdges(data.edges || []);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!flowId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await apiClient.put(`/api/flows/${flowId}`, {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('Flujo guardado');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flow', flowId] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error('Error al guardar');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||||
|
[setEdges]
|
||||||
|
);
|
||||||
|
|
||||||
|
const addNode = (type: string) => {
|
||||||
|
const newNode: Node = {
|
||||||
|
id: `${type}-${Date.now()}`,
|
||||||
|
type,
|
||||||
|
position: { x: 250, y: nodes.length * 100 + 50 },
|
||||||
|
data: {
|
||||||
|
label: type === 'trigger' ? 'Inicio' : type,
|
||||||
|
type,
|
||||||
|
config: type === 'message' ? { text: 'Hola {{contact.name}}!' } : {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setNodes((nds) => [...nds, newNode]);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div>Cargando...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: 'calc(100vh - 140px)' }}>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/flows')}>
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 'bold' }}>{flow?.name}</span>
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => addNode('trigger')}>+ Trigger</Button>
|
||||||
|
<Button onClick={() => addNode('message')}>+ Mensaje</Button>
|
||||||
|
<Button onClick={() => addNode('condition')}>+ Condición</Button>
|
||||||
|
<Button onClick={() => addNode('wait_input')}>+ Esperar Input</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
style={{ background: '#25D366' }}
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 'calc(100% - 50px)', border: '1px solid #d9d9d9', borderRadius: 8 }}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
fitView
|
||||||
|
>
|
||||||
|
<Controls />
|
||||||
|
<Background />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
239
frontend/src/pages/FlowList.tsx
Normal file
239
frontend/src/pages/FlowList.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Modal,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
message,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
PauseCircleOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '../api/client';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
interface Flow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
trigger_type: string;
|
||||||
|
is_active: boolean;
|
||||||
|
version: number;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerLabels: Record<string, string> = {
|
||||||
|
welcome: 'Bienvenida',
|
||||||
|
keyword: 'Palabra clave',
|
||||||
|
fallback: 'Fallback',
|
||||||
|
event: 'Evento',
|
||||||
|
manual: 'Manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FlowList() {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: flows, isLoading } = useQuery({
|
||||||
|
queryKey: ['flows'],
|
||||||
|
queryFn: () => apiClient.get<Flow[]>('/api/flows'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => apiClient.post<Flow>('/api/flows', data),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
message.success('Flujo creado');
|
||||||
|
setIsModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flows'] });
|
||||||
|
navigate(`/flows/${data.id}`);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
message.error('Error al crear flujo');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiClient.delete(`/api/flows/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('Flujo eliminado');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flows'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleActiveMutation = useMutation({
|
||||||
|
mutationFn: async ({ id, active }: { id: string; active: boolean }) => {
|
||||||
|
const endpoint = active ? 'activate' : 'deactivate';
|
||||||
|
await apiClient.post(`/api/flows/${id}/${endpoint}`, {});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['flows'] });
|
||||||
|
message.success('Estado actualizado');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Nombre',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Trigger',
|
||||||
|
dataIndex: 'trigger_type',
|
||||||
|
key: 'trigger_type',
|
||||||
|
render: (type: string) => triggerLabels[type] || type,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Estado',
|
||||||
|
dataIndex: 'is_active',
|
||||||
|
key: 'is_active',
|
||||||
|
render: (active: boolean) => (
|
||||||
|
<Tag color={active ? 'green' : 'default'}>
|
||||||
|
{active ? 'Activo' : 'Inactivo'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Versión',
|
||||||
|
dataIndex: 'version',
|
||||||
|
key: 'version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Acciones',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: any, record: Flow) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => navigate(`/flows/${record.id}`)}
|
||||||
|
>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={record.is_active ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||||
|
onClick={() =>
|
||||||
|
toggleActiveMutation.mutate({ id: record.id, active: !record.is_active })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{record.is_active ? 'Desactivar' : 'Activar'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '¿Eliminar flujo?',
|
||||||
|
onOk: () => deleteMutation.mutate(record.id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>Flujos de Chatbot</Title>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
style={{ background: '#25D366' }}
|
||||||
|
>
|
||||||
|
Crear Flujo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Table
|
||||||
|
dataSource={flows}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Crear nuevo flujo"
|
||||||
|
open={isModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(values) => createMutation.mutate(values)}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="Nombre"
|
||||||
|
rules={[{ required: true, message: 'Ingresa un nombre' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="Ej: Bienvenida Principal" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="trigger_type"
|
||||||
|
label="Tipo de Trigger"
|
||||||
|
rules={[{ required: true, message: 'Selecciona un trigger' }]}
|
||||||
|
>
|
||||||
|
<Select placeholder="Selecciona...">
|
||||||
|
<Select.Option value="welcome">Bienvenida (primer mensaje)</Select.Option>
|
||||||
|
<Select.Option value="keyword">Palabra clave</Select.Option>
|
||||||
|
<Select.Option value="fallback">Fallback (sin coincidencia)</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
noStyle
|
||||||
|
shouldUpdate={(prev, curr) => prev.trigger_type !== curr.trigger_type}
|
||||||
|
>
|
||||||
|
{({ getFieldValue }) =>
|
||||||
|
getFieldValue('trigger_type') === 'keyword' && (
|
||||||
|
<Form.Item
|
||||||
|
name="trigger_value"
|
||||||
|
label="Palabras clave"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="hola, menu, ayuda (separadas por coma)" />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={createMutation.isPending}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Crear
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
|
|||||||
from typing import List
|
from typing import List
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import httpx
|
import httpx
|
||||||
|
from pydantic import BaseModel
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.security import get_current_user
|
from app.core.security import get_current_user
|
||||||
@@ -273,3 +274,59 @@ async def handle_whatsapp_event(
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
class FlowSendRequest(BaseModel):
|
||||||
|
conversation_id: str
|
||||||
|
content: str
|
||||||
|
type: str = "text"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/internal/flow/send")
|
||||||
|
async def flow_send_message(
|
||||||
|
request: FlowSendRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Internal endpoint for flow engine to send messages"""
|
||||||
|
conversation = db.query(Conversation).filter(
|
||||||
|
Conversation.id == request.conversation_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=404, detail="Conversation not found")
|
||||||
|
|
||||||
|
# Create message in DB
|
||||||
|
message = Message(
|
||||||
|
conversation_id=conversation.id,
|
||||||
|
direction=MessageDirection.OUTBOUND,
|
||||||
|
type=MessageType.TEXT,
|
||||||
|
content=request.content,
|
||||||
|
status=MessageStatus.PENDING,
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(message)
|
||||||
|
|
||||||
|
# Send via WhatsApp Core
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{conversation.whatsapp_account_id}/messages",
|
||||||
|
json={
|
||||||
|
"to": conversation.contact.phone_number,
|
||||||
|
"type": "text",
|
||||||
|
"content": {"text": request.content},
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
message.whatsapp_message_id = data.get("messageId")
|
||||||
|
message.status = MessageStatus.SENT
|
||||||
|
else:
|
||||||
|
message.status = MessageStatus.FAILED
|
||||||
|
except Exception:
|
||||||
|
message.status = MessageStatus.FAILED
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"success": True, "message_id": str(message.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user