// AGENTFORGE-IA — Editor visual const Builder = ({ data, editAgent, setRoute }) => { const initialNodes = React.useMemo(() => ([ { id: 'n1', x: 60, y: 80, kind: 'disparador', icon: 'webhook', name: 'Webhook · ticket.created', color: '#60a5fa', kv: [['canal', 'webhook'], ['filtro', 'prioridad>=3']], tags: ['disparador'] }, { id: 'n2', x: 360, y: 80, kind: 'herramienta', icon: 'database', name: 'Buscar cliente', color: '#34d399', kv: [['fuente', 'snowflake'], ['tabla', 'clientes']], tags: ['lectura'] }, { id: 'n3', x: 660, y: 80, kind: 'llm', icon: 'sparkles', name: 'Claude · clasificar + redactar', color: '#ff5b1f', kv: [['modelo', 'claude-3.5-sonnet'], ['temp', '0,2'], ['tokens', '2.400']], tags: ['LLM', 'razonamiento'] }, { id: 'n4', x: 960, y: 30, kind: 'rama', icon: 'fork', name: '¿gravedad ≥ P2?', color: '#a78bfa', kv: [['ruta_a', '→ escalar'], ['ruta_b', '→ auto-respuesta']], tags: ['rama'] }, { id: 'n5', x: 1240, y: -10, kind: 'herramienta', icon: 'plug', name: 'Notificar · alerta #cs-leads', color: '#f472b6', kv: [['canal', '#cs-leads'], ['mención', '@guardia']], tags: ['notificar'] }, { id: 'n6', x: 1240, y: 110, kind: 'herramienta', icon: 'send', name: 'Responder · publicar respuesta', color: '#60a5fa', kv: [['público', 'sí'], ['marcar_resuelto', 'auto']], tags: ['escritura'] }, { id: 'n7', x: 660, y: 270, kind: 'memoria', icon: 'memory', name: 'Memoria de cliente', color: '#fbbf24', kv: [['ámbito', 'por cliente'], ['ttl', '90 d']], tags: ['memoria'] }, ]), []); const initialEdges = React.useMemo(() => ([ { from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' }, { from: 'n3', to: 'n4' }, { from: 'n4', to: 'n5' }, { from: 'n4', to: 'n6' }, { from: 'n7', to: 'n3' }, ]), []); const notify = (msg) => window.afNotify && window.afNotify(msg); const LEGACY_STORAGE_KEY = 'af_builder_state_v24'; const SEED_KEY = 'af_builder_seed_v1'; const BROKEN_PROMPT_PATTERNS = [ /gimnasio de nivel 1/i, /clasifica el ticket en\s*\{facturación,\s*técnico,\s*cuenta,\s*abuso\}/i, /nunca prometas un reembolso/i, ]; const sanitizePrompt = React.useCallback((prompt, fallback = '') => { const raw = String(prompt || '').trim(); const isBroken = BROKEN_PROMPT_PATTERNS.some((rx) => rx.test(raw)); if (isBroken) { return ( String(fallback || '').trim() || 'Eres un asistente profesional. Responde de forma clara, precisa y sin inventar datos.' ); } return raw; }, []); const readCreationSeed = () => { try { const raw = window.localStorage.getItem(SEED_KEY); if (!raw) return null; const parsed = JSON.parse(raw); // Acepta seeds de Onboarding y de Library (Editar agente) return parsed && (parsed.fromOnboarding || parsed.fromLibrary) ? parsed : null; } catch (_) { return null; } }; const creationSeed = React.useMemo(() => readCreationSeed(), []); const hasExplicitAgentContext = !!( editAgent?.id || editAgent?.agent?.id || creationSeed?.fromLibrary || creationSeed?.fromOnboarding ); // Si venimos de "Editar" desde Library, el seed ya fue escrito por handleEditAgent. // isEditMode=true en seed indica que debemos mostrar el nombre del agente real, no demo. const isEditMode = !!(creationSeed && creationSeed.isEditMode && creationSeed.fromLibrary); const startBlank = !hasExplicitAgentContext || !!(creationSeed && creationSeed.blank); const seedAgentName = creationSeed?.agentName || null; const agentContextKey = React.useMemo( () => String(editAgent?.id || creationSeed?.agentId || creationSeed?.agentSlug || 'default'), [editAgent?.id, creationSeed?.agentId, creationSeed?.agentSlug] ); const STORAGE_KEY = React.useMemo( () => `${LEGACY_STORAGE_KEY}_${agentContextKey}`, [agentContextKey] ); /** * Carga estado del editor para el agente actual. * @returns {null|Record} */ const readScopedState = () => { try { const scoped = window.localStorage.getItem(STORAGE_KEY); if (scoped) { const parsed = JSON.parse(scoped); if (parsed && Array.isArray(parsed.nodes) && Array.isArray(parsed.edges)) return parsed; } return null; } catch (_) { return null; } }; const savedScoped = React.useMemo(() => readScopedState(), [STORAGE_KEY]); const seedFlowNodes = React.useMemo( () => (Array.isArray(creationSeed?.flowNodes) ? creationSeed.flowNodes : []), [creationSeed?.flowNodes] ); const seedFlowEdges = React.useMemo( () => (Array.isArray(creationSeed?.flowEdges) ? creationSeed.flowEdges : []), [creationSeed?.flowEdges] ); const usableSavedScoped = React.useMemo(() => { if (!savedScoped) return null; if (isEditMode && (!Array.isArray(savedScoped.nodes) || savedScoped.nodes.length === 0)) return null; return savedScoped; }, [isEditMode, savedScoped]); const starterNodes = React.useMemo(() => { if (startBlank) return []; if (seedFlowNodes.length > 0) return seedFlowNodes; if (!isEditMode || usableSavedScoped) return initialNodes; const displayName = editAgent?.agent?.name || seedAgentName || 'Agente'; return [ { id: 'n1', x: 80, y: 90, kind: 'disparador', icon: 'chat', name: `Entrada de ${displayName}`, color: '#60a5fa', kv: [['canal', 'web'], ['modo', 'conversacional']], tags: ['disparador'] }, { id: 'n2', x: 390, y: 90, kind: 'memoria', icon: 'memory', name: 'Contexto del negocio', color: '#fbbf24', kv: [['origen', 'personalización cliente'], ['prioridad', 'alta']], tags: ['memoria'] }, { id: 'n3', x: 700, y: 90, kind: 'llm', icon: 'sparkles', name: `${displayName} · razonamiento`, color: '#ff5b1f', kv: [['modelo', 'claude-3.5-sonnet'], ['temp', '0,2'], ['tokens', '2400']], tags: ['LLM'] }, { id: 'n4', x: 1010, y: 90, kind: 'salida', icon: 'send', name: 'Respuesta al cliente', color: '#34d399', kv: [['formato', 'natural'], ['tono', 'adaptativo']], tags: ['salida'] }, ]; }, [isEditMode, usableSavedScoped, startBlank, initialNodes, editAgent?.agent?.name, seedAgentName, seedFlowNodes]); const starterEdges = React.useMemo(() => { if (startBlank) return []; if (seedFlowEdges.length > 0) return seedFlowEdges; if (!isEditMode || usableSavedScoped) return initialEdges; return [ { from: 'n1', to: 'n2' }, { from: 'n2', to: 'n3' }, { from: 'n3', to: 'n4' }, ]; }, [isEditMode, usableSavedScoped, startBlank, initialEdges, seedFlowEdges]); const [agentName, setAgentName] = React.useState( editAgent?.agent?.name || seedAgentName || usableSavedScoped?.agentName || 'Nuevo agente' ); const [agentTagline, setAgentTagline] = React.useState( editAgent?.agent?.tagline || creationSeed?.agentTagline || usableSavedScoped?.agentTagline || '' ); const [agentCategory, setAgentCategory] = React.useState( editAgent?.agent?.cat || editAgent?.agent?.category || creationSeed?.category || usableSavedScoped?.agentCategory || 'ops' ); const [nodes, setNodes] = React.useState(usableSavedScoped?.nodes || starterNodes); const [edges, setEdges] = React.useState(usableSavedScoped?.edges || starterEdges); const [selected, setSelected] = React.useState(usableSavedScoped?.selected || (starterNodes[2]?.id || starterNodes[0]?.id || null)); const [running, setRunning] = React.useState(false); const [zoom, setZoom] = React.useState(usableSavedScoped?.zoom || 100); const [tab, setTab] = React.useState(usableSavedScoped?.tab || 'diseño'); const [mode, setMode] = React.useState(usableSavedScoped?.mode || 'puntero'); const [showMiniMap, setShowMiniMap] = React.useState(false); const [showVars, setShowVars] = React.useState(false); const [showNodeMenu, setShowNodeMenu] = React.useState(false); const [showToolPicker, setShowToolPicker] = React.useState(false); const [showExampleFlow, setShowExampleFlow] = React.useState(false); const [showGuide, setShowGuide] = React.useState(true); const [publishedAgent, setPublishedAgent] = React.useState(null); const [toolConfigIdx, setToolConfigIdx] = React.useState(null); const [palQuery, setPalQuery] = React.useState(''); const [linkFrom, setLinkFrom] = React.useState(null); const [selectedEdge, setSelectedEdge] = React.useState(null); const [leftWidth, setLeftWidth] = React.useState(savedScoped?.leftWidth || 240); const [rightWidth, setRightWidth] = React.useState(savedScoped?.rightWidth || 320); const [consoleHeight, setConsoleHeight] = React.useState(savedScoped?.consoleHeight || 170); const [versionNotes, setVersionNotes] = React.useState(savedScoped?.versionNotes || 'Ajustes iniciales del flujo'); const [webhookOutUrl, setWebhookOutUrl] = React.useState(savedScoped?.webhookOutUrl || 'https://webhook.site/your-test-url'); const [webhookOutMethod, setWebhookOutMethod] = React.useState(savedScoped?.webhookOutMethod || 'POST'); const [webhookOutBody, setWebhookOutBody] = React.useState(savedScoped?.webhookOutBody || '{"event":"agentforge.flow.updated","source":"editor"}'); const [webhookOutSecret, setWebhookOutSecret] = React.useState(savedScoped?.webhookOutSecret || ''); const [knowledgeUrls, setKnowledgeUrls] = React.useState( savedScoped?.knowledgeUrls || (Array.isArray(creationSeed?.contextUrls) ? creationSeed.contextUrls : []) ); const [knowledgeDocs, setKnowledgeDocs] = React.useState( savedScoped?.knowledgeDocs || (Array.isArray(creationSeed?.contextFiles) ? creationSeed.contextFiles.map((f) => ({ name: f.name || 'documento', size: Number(f.size || 0), type: f.type || '', })) : []) ); const [kbUrlInput, setKbUrlInput] = React.useState(''); const dragRef = React.useRef(null); const resizeRef = React.useRef(null); const [temperature, setTemperature] = React.useState(savedScoped?.temperature ?? 0.2); const [maxTokens, setMaxTokens] = React.useState(savedScoped?.maxTokens ?? 2400); const [systemPrompt, setSystemPrompt] = React.useState( (creationSeed?.contextNotes ? `${creationSeed?.systemPrompt || ''}\n\nContexto adicional aportado por el usuario:\n${creationSeed.contextNotes}`.trim() : creationSeed?.systemPrompt) || savedScoped?.systemPrompt || 'Eres un asistente profesional. Responde de forma clara, precisa y sin inventar datos.' ); React.useEffect(() => { setSystemPrompt((prev) => sanitizePrompt( prev, creationSeed?.systemPrompt || savedScoped?.systemPrompt || 'Eres un asistente profesional. Responde de forma clara, precisa y sin inventar datos.' ) ); }, [sanitizePrompt, creationSeed?.systemPrompt, savedScoped?.systemPrompt]); const [safeguards, setSafeguards] = React.useState(savedScoped?.safeguards || { pii: true, humanApproval: true, strictJson: false }); const lastCommittedRef = React.useRef(''); const snapshot = React.useCallback( () => JSON.stringify({ nodes, edges, selected, zoom, tab, mode, agentName, agentTagline, agentCategory, temperature, maxTokens, systemPrompt, safeguards, tools, vars, leftWidth, rightWidth, consoleHeight, versionNotes, webhookOutUrl, webhookOutMethod, webhookOutBody, webhookOutSecret, knowledgeUrls, knowledgeDocs, }), [ nodes, edges, selected, zoom, tab, mode, agentName, agentTagline, agentCategory, temperature, maxTokens, systemPrompt, safeguards, tools, vars, leftWidth, rightWidth, consoleHeight, versionNotes, webhookOutUrl, webhookOutMethod, webhookOutBody, webhookOutSecret, knowledgeUrls, knowledgeDocs, ] ); const isDirty = lastCommittedRef.current && lastCommittedRef.current !== snapshot(); const [tools, setTools] = React.useState(savedScoped?.tools || [ { n: 'snowflake.query', d: 'solo lectura · limitado al esquema clientes', c: '#34d399', enabled: true }, { n: 'hubspot.contact', d: 'alta/actualización de contacto comercial', c: '#ff7a59', enabled: true }, { n: 'memory.recall', d: 'ámbito por cliente, TTL 90 d', c: '#fbbf24', enabled: true }, { n: 'webhook.out', d: 'llamada HTTP saliente con payload JSON', c: '#a78bfa', enabled: true }, ]); const [vars, setVars] = React.useState(savedScoped?.vars || [ { k: 'workspace', v: 'prod-es' }, { k: 'owner', v: 'Naomi M.' }, { k: 'approval_threshold', v: '200' }, ]); const [consoleLines, setConsoleLines] = React.useState([ '[disparador] webhook.ticket.created id=#48207 prioridad=P2', '[búsqueda] snowflake.clientes fila=842 ms=124', '[claude] clasificar(categoría="facturación.reembolso") confianza=0,94', '[claude] borrador_respuesta(tono="empático", tokens=312)', '[rama] gravedad=P2 → ruta_b (auto-respuesta)', ]); const [deployMeta, setDeployMeta] = React.useState({ status: 'borrador', version: 'v2.4-beta', updatedAgo: 'hace 12 s', }); React.useEffect(() => { if (!lastCommittedRef.current) { lastCommittedRef.current = snapshot(); } }, [snapshot]); React.useEffect(() => { // Evita reutilizar un seed antiguo al volver al editor directamente. try { window.localStorage.removeItem(SEED_KEY); } catch (_) {} // Limpia también estado legado global para evitar contaminación entre agentes. try { window.localStorage.removeItem(LEGACY_STORAGE_KEY); } catch (_) {} }, []); const persistAgentIdentity = async () => { const token = sessionStorage.getItem('af_token') || ''; const targetAgentId = editAgent?.agentDbId || editAgent?.agent?.agentDbId || creationSeed?.agentId || publishedAgent?.id || null; const targetAgentSlug = editAgent?.id || editAgent?.agent?.id || editAgent?.agent?.slug || creationSeed?.agentSlug || publishedAgent?.slug || ''; const cleanName = agentName.trim(); if (!cleanName) return false; const persistLocalAgentName = (agentId, agentSlug, updatedName, updatedTagline) => { const nextName = updatedName || cleanName; const nextTagline = updatedTagline || agentTagline || ''; const matchesAgent = (agent) => ( String(agent?.id || '') === String(agentSlug || '') || String(agent?.slug || '') === String(agentSlug || '') || String(agent?.agentDbId || '') === String(agentId || '') || String(agent?.dbId || '') === String(agentId || '') ); try { const raw = localStorage.getItem('af_custom_agents_v1') || '[]'; const list = JSON.parse(raw); if (Array.isArray(list)) { const next = list.map((agent) => matchesAgent(agent) ? { ...agent, name: nextName, tagline: nextTagline || agent.tagline || '' } : agent); localStorage.setItem('af_custom_agents_v1', JSON.stringify(next)); } } catch (_) {} try { const latest = JSON.parse(localStorage.getItem('af_last_created_agent') || 'null'); if (latest && matchesAgent(latest)) { localStorage.setItem('af_last_created_agent', JSON.stringify({ ...latest, name: nextName, tagline: nextTagline || latest.tagline || '', })); } } catch (_) {} }; const dispatchRename = (agentId, agentSlug, updatedName, updatedTagline) => { persistLocalAgentName(agentId, agentSlug, updatedName, updatedTagline); const detail = { agentId: String(agentId || ''), agentSlug: String(agentSlug || ''), agentName: updatedName || cleanName, agentTagline: updatedTagline || agentTagline, }; try { window.dispatchEvent(new CustomEvent('af:agent:renamed', { detail })); } catch (_) {} try { window.dispatchEvent(new CustomEvent('af:library:refresh')); } catch (_) {} try { window.dispatchEvent(new CustomEvent('af:catalog:refresh')); } catch (_) {} }; if (!token || !targetAgentId) { if (!targetAgentSlug) return false; dispatchRename('', targetAgentSlug, cleanName, agentTagline); pushLog('[metadata] nombre visible actualizado'); return true; } try { let res = await fetch('/api/v1/purchases/' + targetAgentId, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ display_name: cleanName, display_tagline: agentTagline.trim() || null, }), }); if (!res.ok && (res.status === 403 || res.status === 404)) { res = await fetch('/api/v1/agents/' + targetAgentId, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: cleanName, tagline: agentTagline.trim() || null, }), }); } if (!res.ok) { let reason = `HTTP ${res.status}`; try { const payload = await res.json(); reason = payload?.detail || reason; } catch (_) {} pushLog(`[metadata] no se pudo guardar nombre: ${reason}`); return false; } const updated = await res.json().catch(() => null); const updatedName = updated?.agent_name || updated?.name; const updatedTagline = updated?.agent_tagline || updated?.tagline || agentTagline; if (updatedName) { try { localStorage.setItem(SEED_KEY, JSON.stringify({ ...(creationSeed || {}), agentName: updatedName, agentTagline: updatedTagline, agentId: targetAgentId, agentSlug: targetAgentSlug, })); } catch (_) {} } pushLog('[metadata] nombre visible actualizado'); dispatchRename(targetAgentId, targetAgentSlug, updatedName, updatedTagline); return true; } catch (_) { return false; } }; const sel = nodes.find((n) => n.id === selected) || nodes[0] || null; const guideSteps = [ '1) Izquierda: elige bloques (entrada, herramientas, salida).', '2) Centro: conecta bloques para definir el flujo.', '3) Derecha: configura modelo, prompt y conocimiento.', ]; React.useEffect(() => { if (!running) return; const t = setTimeout(() => { setRunning(false); setConsoleLines((prev) => [...prev.slice(-8), '[ok] ejecución completada sin errores']); }, 2500); return () => clearTimeout(t); }, [running]); const portPos = (n, side) => ({ x: n.x + (side === 'out' ? 220 : 0), y: n.y + 36 }); const pushLog = (line) => { setConsoleLines((prev) => [...prev.slice(-10), line]); }; const runTest = () => { setRunning(true); pushLog('[test] suite rápida iniciada en sandbox del flujo'); notify('Prueba rápida en curso'); }; const runWebhookOut = async () => { const tool = tools.find((t) => t.n === 'webhook.out'); if (!tool || !tool.enabled) { notify('Habilita webhook.out antes de ejecutar'); return; } let parsedBody = {}; try { parsedBody = webhookOutBody.trim() ? JSON.parse(webhookOutBody) : {}; } catch (_) { notify('JSON inválido en body del webhook'); return; } try { pushLog(`[webhook] ${webhookOutMethod} ${webhookOutUrl} ...`); const res = await fetch('/api/v1/tools/webhook/out', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: webhookOutUrl, method: webhookOutMethod, body: parsedBody, secret: webhookOutSecret || null, }), }); const data = await res.json(); if (!res.ok) { const detail = data?.detail || `Error ${res.status}`; pushLog(`[webhook] error: ${detail}`); notify('Webhook salida falló'); return; } pushLog(`[webhook] OK status=${data.status_code} ${data.latency_ms}ms`); notify('Webhook salida ejecutado'); } catch (err) { pushLog(`[webhook] error de red: ${err?.message || err}`); notify('Error de red en webhook salida'); } }; const publishAgent = async (options = {}) => { const goToDeployment = !!options.goToDeployment; const token = sessionStorage.getItem('af_token'); if (!token) { notify('Inicia sesión para publicar'); return; } if (isEditMode) { const savedIdentity = await persistAgentIdentity(); const targetAgentId = editAgent?.agentDbId || editAgent?.agent?.agentDbId || creationSeed?.agentId || publishedAgent?.id || null; if (!goToDeployment || targetAgentId) { notify(savedIdentity ? 'Agente actualizado' : 'Cambios locales guardados'); } if (goToDeployment && targetAgentId) { try { sessionStorage.setItem('af_deploy_agent', JSON.stringify({ agentId: targetAgentId, agentSlug: publishedAgent?.slug || editAgent?.id || editAgent?.agent?.id || creationSeed?.agentSlug || '', agentName: publishedAgent?.name || agentName || 'Agente', })); } catch (_) {} pushLog('[deploy] enviado a Implantación para elegir canal real'); notify('Elige canal en Implantación: Web, WhatsApp o Telegram'); if (setRoute) setRoute('deployment'); } if (!goToDeployment || targetAgentId) return; } const toolNames = tools.filter((t) => t.enabled).map((t) => t.n); try { pushLog('[publish] enviando agente al servidor...'); const res = await fetch('/api/v1/agents/create', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: agentName, category: agentCategory || 'ops', tagline: agentTagline || agentName, system_prompt: systemPrompt, flow_nodes: nodes, flow_edges: edges, tools: toolNames, tags: [], color: '#ff5b1f', knowledge_urls: knowledgeUrls, knowledge_docs: knowledgeDocs.map((d) => d.name), }), }); const data = await res.json(); if (!res.ok) { notify(data?.detail || 'Error al publicar'); pushLog(`[publish] error: ${data?.detail || res.status}`); return; } const published = { id: data.id, slug: data.slug, name: data.name || agentName }; setPublishedAgent(published); try { window.dispatchEvent(new CustomEvent('af:catalog:refresh')); } catch (_) {} pushLog(`[publish] agente publicado: ${data.slug}`); pushLog('[publish] catálogo refrescado (Marketplace/Mis agentes)'); notify(goToDeployment ? 'Agente guardado. Abriendo Implantación' : 'Agente publicado y catálogo actualizado'); if (goToDeployment) { try { sessionStorage.setItem('af_deploy_agent', JSON.stringify({ agentId: published.id, agentSlug: published.slug, agentName: published.name || agentName || 'Agente', })); } catch (_) {} if (setRoute) setRoute('deployment'); } } catch (err) { pushLog(`[publish] error de red: ${err?.message || err}`); notify('Error de red al publicar'); } }; const deployFlow = async () => { const token = sessionStorage.getItem('af_token'); if (!token) { notify('Inicia sesión para desplegar'); return; } const targetAgentId = publishedAgent?.id || editAgent?.agentDbId || editAgent?.agent?.agentDbId || creationSeed?.agentId || null; if (!targetAgentId) { pushLog('[deploy] publicando agente antes de implantar'); await publishAgent({ goToDeployment: true }); return; } try { sessionStorage.setItem('af_deploy_agent', JSON.stringify({ agentId: targetAgentId, agentSlug: publishedAgent?.slug || editAgent?.id || editAgent?.agent?.id || creationSeed?.agentSlug || '', agentName: publishedAgent?.name || agentName || 'Agente', })); } catch (_) {} pushLog('[deploy] enviado a Implantación para elegir canal real'); notify('Elige canal en Implantación: Web, WhatsApp o Telegram'); if (setRoute) { setRoute('deployment'); } }; const duplicateSelectedNode = () => { if (!sel) return; const next = `n${Date.now().toString().slice(-4)}`; const clone = { ...sel, id: next, x: sel.x + 40, y: sel.y + 90, name: `${sel.name} (copia)` }; setNodes((prev) => [...prev, clone]); setSelected(next); pushLog(`[edición] nodo duplicado: ${sel.id} -> ${next}`); notify('Nodo duplicado'); setShowNodeMenu(false); }; const removeSelectedNode = () => { if (!sel || nodes.length <= 1) return; const idx = nodes.findIndex((n) => n.id === sel.id); const fallback = nodes[Math.max(0, idx - 1)]?.id; setNodes((prev) => prev.filter((n) => n.id !== sel.id)); setEdges((prev) => prev.filter((e) => e.from !== sel.id && e.to !== sel.id)); setSelected(fallback); pushLog(`[edición] nodo eliminado: ${sel.id}`); notify('Nodo eliminado'); setShowNodeMenu(false); }; const saveInspector = () => { if (!sel) { notify('Añade o selecciona un nodo'); return; } setNodes((prev) => prev.map((n) => { if (n.id !== sel.id) return n; const nextKv = n.kv.map(([k, v]) => { if (k === 'temp') return [k, temperature.toFixed(2).replace('.', ',')]; if (k === 'tokens') return [k, String(maxTokens)]; if (k === 'modelo') return [k, n.kind === 'llm' ? (document.getElementById('builder-model')?.value || v) : v]; return [k, v]; }); return { ...n, kv: nextKv }; })); pushLog('[config] configuración del nodo guardada'); notify('Configuración guardada'); }; const addTool = (tool) => { setTools((prev) => [...prev, tool]); setShowToolPicker(false); pushLog(`[tools] herramienta añadida: ${tool.n}`); notify('Herramienta adjuntada'); }; const toggleTool = (idx) => { setTools((prev) => prev.map((t, i) => (i === idx ? { ...t, enabled: !t.enabled } : t))); }; const selectedTool = toolConfigIdx != null ? tools[toolConfigIdx] : null; const beginDrag = (e, nodeId) => { if (!(mode === 'mover' || mode == 'puntero')) return; if (e.target.classList.contains('flow-port')) return; e.preventDefault(); e.stopPropagation(); const node = nodes.find((n) => n.id === nodeId); if (!node) return; dragRef.current = { nodeId, dx: e.clientX - node.x, dy: e.clientY - node.y }; }; React.useEffect(() => { const onMove = (e) => { if (!dragRef.current) return; const { nodeId, dx, dy } = dragRef.current; setNodes((prev) => prev.map((n) => n.id === nodeId ? { ...n, x: Math.max(20, e.clientX - dx), y: Math.max(20, e.clientY - dy) } : n)); }; const onUp = () => { if (dragRef.current) { const gid = dragRef.current.nodeId; setNodes((prev) => prev.map((n) => n.id === gid ? { ...n, x: Math.round(n.x / 10) * 10, y: Math.round(n.y / 10) * 10 } : n)); pushLog('[edición] nodo reposicionado'); } dragRef.current = null; }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [mode]); const startLink = (e, nodeId) => { e.stopPropagation(); setLinkFrom(nodeId); notify('Selecciona nodo destino para enlazar'); }; const finishLink = (e, toId) => { e.stopPropagation(); if (!linkFrom || linkFrom === toId) return; setEdges((prev) => { const exists = prev.some((ed) => ed.from === linkFrom && ed.to === toId); if (exists) return prev; return [...prev, { from: linkFrom, to: toId }]; }); pushLog(`[flujo] enlace creado: ${linkFrom} -> ${toId}`); setSelectedEdge(`${linkFrom}->${toId}`); setLinkFrom(null); }; const removeSelectedEdge = () => { if (!selectedEdge) return; const [from, to] = selectedEdge.split('->'); setEdges((prev) => prev.filter((e) => !(e.from === from && e.to === to))); pushLog(`[flujo] enlace eliminado: ${selectedEdge}`); setSelectedEdge(null); }; React.useEffect(() => { const onKeyDown = (e) => { const tag = (e.target && e.target.tagName ? e.target.tagName.toLowerCase() : ''); const inInput = tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable); if (inInput) return; if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedEdge) { e.preventDefault(); removeSelectedEdge(); return; } if (selected) { e.preventDefault(); removeSelectedNode(); } } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [selected, selectedEdge, nodes, edges]); const startResize = (type, e) => { e.preventDefault(); resizeRef.current = { type, x: e.clientX, y: e.clientY, leftWidth, rightWidth, consoleHeight }; }; React.useEffect(() => { const onMove = (e) => { if (!resizeRef.current) return; const r = resizeRef.current; if (r.type == 'left') { setLeftWidth(Math.max(200, Math.min(420, r.leftWidth + (e.clientX - r.x)))); } else if (r.type == 'right') { setRightWidth(Math.max(280, Math.min(520, r.rightWidth - (e.clientX - r.x)))); } else if (r.type == 'bottom') { setConsoleHeight(Math.max(120, Math.min(360, r.consoleHeight - (e.clientY - r.y)))); } }; const onUp = () => { resizeRef.current = null; }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); return () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; }, [leftWidth, rightWidth, consoleHeight]); const paletteGroups = React.useMemo(() => ([ { title: 'Disparadores', kind: 'disparador', color: '#60a5fa', items: [['Webhook', 'webhook', 'HTTP'], ['Programado', 'clock', 'cron'], ['Email entrante', 'send', 'SMTP'], ['Evento externo', 'plug', 'evento']] }, { title: 'Razonamiento', kind: 'llm', color: '#ff5b1f', items: [['Claude · pensar', 'sparkles', 'LLM'], ['Planificar', 'flow', 'agente'], ['Reflexionar', 'eye', 'agente'], ['Decidir', 'fork', 'rama']] }, { title: 'Herramientas', kind: 'herramienta', color: '#34d399', items: [['Snowflake', 'database', 'SQL'], ['HubSpot', 'users', 'API'], ['Linear', 'grid', 'API'], ['Búsqueda web', 'globe', 'HTTP'], ['Ejecutar código', 'terminal', 'exec']] }, { title: 'Memoria y estado', kind: 'memoria', color: '#fbbf24', items: [['Vector store', 'memory', 'recuperar'], ['Caché KV', 'database', 'caché'], ['Sesión', 'clock', 'ctx']] }, { title: 'Salida', kind: 'salida', color: '#a78bfa', items: [['Responder', 'send', 'msg'], ['Webhook salida', 'webhook', 'HTTP'], ['Notificar', 'bell', 'canal']] }, ]), []); const addNodeFromPalette = (name, icon, meta, kind, color) => { const id = `n${Date.now().toString().slice(-5)}`; const newNode = { id, x: 180 + (nodes.length % 4) * 280, y: 120 + (nodes.length % 3) * 120, kind, icon, name, color, kv: [['origen', meta], ['estado', 'nuevo']], tags: [kind], }; setNodes((prev) => [...prev, newNode]); setSelected(id); pushLog(`[paleta] nodo añadido: ${name}`); notify(`Bloque añadido: ${name}`); }; React.useEffect(() => { pushLog(`[vista] pestaña activa: ${tab}`); }, [tab]); React.useEffect(() => { const payload = { nodes, edges, selected, zoom, tab, mode, agentName, agentTagline, agentCategory, temperature, maxTokens, systemPrompt, safeguards, tools, vars, leftWidth, rightWidth, consoleHeight, versionNotes, webhookOutUrl, webhookOutMethod, webhookOutBody, webhookOutSecret, knowledgeUrls, knowledgeDocs, savedAt: Date.now(), }; const t = setTimeout(() => { try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)); } catch (_) {} }, 180); return () => clearTimeout(t); }, [nodes, edges, selected, zoom, tab, mode, agentName, agentTagline, agentCategory, temperature, maxTokens, systemPrompt, safeguards, tools, vars, leftWidth, rightWidth, consoleHeight, versionNotes, webhookOutUrl, webhookOutMethod, webhookOutBody, webhookOutSecret, knowledgeUrls, knowledgeDocs]); const saveNow = async (navigateAfter = false) => { try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ nodes, edges, selected, zoom, tab, mode, agentName, agentTagline, agentCategory, temperature, maxTokens, systemPrompt, safeguards, tools, vars, leftWidth, rightWidth, consoleHeight, versionNotes, webhookOutUrl, webhookOutMethod, webhookOutBody, webhookOutSecret, knowledgeUrls, knowledgeDocs, savedAt: Date.now(), })); const backendSaved = await persistAgentIdentity(); lastCommittedRef.current = snapshot(); notify(backendSaved ? 'Nombre guardado' : 'Guardado local: no se pudo actualizar backend'); if (backendSaved && navigateAfter) { setRoute('library'); } } catch (_) { notify('No se pudo guardar en este navegador'); } }; const closeEditor = async () => { if (!isDirty) { setRoute && setRoute('library'); return; } const saveBeforeExit = window.confirm('Tienes cambios sin guardar. ¿Quieres guardarlos antes de salir?'); if (saveBeforeExit) { await saveNow(false); setRoute && setRoute('library'); return; } const discard = window.confirm('¿Salir sin guardar cambios?'); if (discard) { setRoute && setRoute('library'); } }; const resetFlow = () => { const blank = startBlank; setNodes(blank ? [] : starterNodes); setEdges(blank ? [] : starterEdges); setSelected(blank ? null : (starterNodes[2]?.id || starterNodes[0]?.id || null)); setLinkFrom(null); setSelectedEdge(null); notify(blank ? 'Lienzo limpio restablecido' : 'Flujo restablecido'); }; const loadExampleFlow = () => { setNodes(initialNodes); setEdges(initialEdges); setSelected('n3'); setTab('diseño'); setLinkFrom(null); setSelectedEdge(null); setShowExampleFlow(false); notify('Ejemplo cargado en el editor'); pushLog('[ejemplo] flujo de referencia cargado'); }; return (
{isEditMode && (
Editando agente. Cambia el nombre en el bloque Nombre visible del agente y pulsa Guardar nombre.
)}
setPalQuery(e.target.value)} />
{paletteGroups.map((g) => { const items = g.items.filter(([name, , meta]) => `${name} ${meta}`.toLowerCase().includes(palQuery.toLowerCase())); if (!items.length) return null; return (

{g.title}

{items.map((p, i) => ( ))}
); })}
startResize('left', e)} />
{agentName || 'Nuevo agente'} {deployMeta.version}
atención · {deployMeta.status} · guardado {deployMeta.updatedAgo}
{['diseño', 'prueba', 'versiones', 'desplegar'].map((t) => )}
Guardar/Publicar
Actualiza el agente y lo deja disponible en Mis agentes y Marketplace, sin desplegarlo aún.
Ir a Implantación
Abre el flujo operativo para activar el agente en Web del cliente, WhatsApp o Telegram.
{showGuide && (
Guía para usuarios no técnicos
{guideSteps.map((step, i) => (
{step}
))}
Recomendado: usa este orden básico: EntradaContextoRazonamientoRespuesta.
)}
{tab !== 'diseño' && (
{tab === 'prueba' && (

Prueba del flujo

Ejecuta validaciones con el estado actual del diagrama.

)} {tab === 'versiones' && (

Versiones