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:
Claude AI
2026-01-29 10:23:02 +00:00
parent 14a579d5ca
commit c97d380635
4 changed files with 457 additions and 1 deletions

View File

@@ -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",

View 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>
);
}

View 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>
);
}

View File

@@ -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)}