Melhorias no frontend, pipeline e executor

- Estilos CSS expandidos com novos componentes visuais
- Editor de fluxo visual para pipelines (flow-editor.js)
- Melhorias na UI de agentes e pipelines
- Sumarização automática entre steps de pipeline
- Retry com backoff no executor
- Utilitários adicionais no frontend
This commit is contained in:
Frederico Castro
2026-02-27 22:39:23 -03:00
parent 0b5a81c3e6
commit 972ae92291
10 changed files with 1563 additions and 47 deletions

View File

@@ -542,6 +542,84 @@ textarea {
margin-left: auto;
}
.agent-card--leader {
--leader-gold: #f59e0b;
--leader-gold-light: #fbbf24;
--leader-gold-dark: #d97706;
--leader-glow: rgba(245, 158, 11, 0.15);
--leader-glow-strong: rgba(245, 158, 11, 0.25);
border: 1px solid var(--leader-gold-dark);
background:
linear-gradient(90deg, var(--leader-gold), var(--leader-gold-light), var(--leader-gold)) 0 0 / 200% 2px no-repeat,
var(--bg-card);
background-origin: border-box;
position: relative;
}
.agent-card--leader::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--leader-gold-light), transparent);
animation: leaderShimmer 3s ease-in-out infinite;
}
@keyframes leaderShimmer {
0%, 100% { opacity: 0.4; transform: translateX(-50%); }
50% { opacity: 1; transform: translateX(50%); }
}
.agent-card--leader:hover {
border-color: var(--leader-gold);
box-shadow: 0 8px 32px var(--leader-glow-strong);
}
.agent-card--leader .agent-avatar {
box-shadow: 0 0 0 2px var(--leader-gold), 0 0 12px var(--leader-glow);
}
.agent-leader-icon {
width: 14px;
height: 14px;
color: var(--warning);
vertical-align: -1px;
margin-right: 4px;
flex-shrink: 0;
filter: drop-shadow(0 0 3px rgba(245, 158, 11, 0.4));
}
.agent-card--po {
--po-cyan: #06b6d4;
--po-cyan-dark: #0891b2;
--po-glow: rgba(6, 182, 212, 0.1);
border: 1px solid var(--po-cyan-dark);
background:
linear-gradient(90deg, var(--po-cyan-dark), var(--po-cyan), var(--po-cyan-dark)) 0 0 / 100% 1px no-repeat,
var(--bg-card);
background-origin: border-box;
}
.agent-card--po:hover {
border-color: var(--po-cyan);
box-shadow: 0 8px 24px var(--po-glow);
}
.agent-card--po .agent-avatar {
box-shadow: 0 0 0 2px var(--po-cyan-dark), 0 0 8px var(--po-glow);
}
.agent-po-icon {
width: 14px;
height: 14px;
color: var(--po-cyan);
vertical-align: -1px;
margin-right: 4px;
flex-shrink: 0;
}
.badge {
display: inline-flex;
align-items: center;
@@ -3413,9 +3491,9 @@ tbody tr:hover td {
.pipeline-flow {
display: flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
gap: 4px;
flex-wrap: wrap;
padding: 8px 0;
}
@@ -3451,6 +3529,61 @@ tbody tr:hover td {
flex-shrink: 0;
}
.pipeline-step-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.pipeline-mode-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
background: none;
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-muted);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.pipeline-mode-toggle:hover {
color: var(--text-secondary);
border-color: var(--border-secondary);
background-color: var(--bg-secondary);
}
.pipeline-step-hints {
display: flex;
flex-direction: column;
gap: 4px;
}
.pipeline-step-hint {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
padding: 0 2px;
line-height: 1.4;
}
.pipeline-step-hint code {
background-color: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 3px;
padding: 0 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-secondary);
white-space: nowrap;
}
.pipeline-step-row {
display: flex;
align-items: flex-start;
@@ -4622,3 +4755,390 @@ body, .sidebar, .header, .card, .modal-content, .input, .select, textarea, .metr
gap: 4px;
}
}
/* ─── Flow Editor ─── */
.flow-editor-overlay {
position: fixed;
inset: 0;
background-color: var(--bg-primary);
z-index: 2000;
display: flex;
flex-direction: column;
opacity: 0;
transition: opacity 0.25s ease;
}
.flow-editor-overlay.active {
opacity: 1;
}
.flow-editor {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.flow-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border-primary);
background-color: var(--bg-secondary);
flex-shrink: 0;
gap: 16px;
z-index: 10;
}
.flow-editor-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.flow-editor-title-group {
display: flex;
flex-direction: column;
}
.flow-editor-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
.flow-editor-subtitle {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.flow-editor-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.flow-editor-zoom {
display: flex;
align-items: center;
gap: 4px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 2px 4px;
}
.flow-zoom-label {
font-size: 12px;
color: var(--text-secondary);
min-width: 40px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.flow-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
font-family: 'Inter', sans-serif;
white-space: nowrap;
}
.flow-btn--ghost {
background: transparent;
color: var(--text-secondary);
border-color: transparent;
}
.flow-btn--ghost:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.flow-btn--primary {
background: var(--gradient-primary);
color: #fff;
border-color: transparent;
}
.flow-btn--primary:hover {
filter: brightness(1.15);
}
.flow-btn--danger {
background: transparent;
color: var(--error);
border-color: rgba(239, 68, 68, 0.3);
}
.flow-btn--danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.flow-btn--sm {
padding: 5px 10px;
font-size: 12px;
}
.flow-btn--full {
width: 100%;
justify-content: center;
}
.flow-btn--disabled {
opacity: 0.4;
pointer-events: none;
}
.flow-editor-body {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
.flow-editor-canvas-wrap {
flex: 1;
position: relative;
overflow: hidden;
cursor: grab;
background-color: var(--bg-primary);
}
.flow-editor-canvas-wrap:active {
cursor: grabbing;
}
#flow-editor-canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.flow-editor-nodes {
position: absolute;
inset: 0;
pointer-events: none;
}
.flow-node {
position: absolute;
top: 0;
left: 0;
transform-origin: 0 0;
pointer-events: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 0;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
overflow: hidden;
display: flex;
flex-direction: column;
user-select: none;
}
.flow-node:hover {
border-color: var(--border-secondary);
box-shadow: 0 0 0 1px var(--border-secondary);
}
.flow-node--selected {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3), 0 4px 20px rgba(99, 102, 241, 0.1);
}
.flow-node-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px 6px;
}
.flow-node-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--gradient-primary);
color: #fff;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.flow-node-name {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.flow-node-approval {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
font-weight: 600;
flex-shrink: 0;
}
.flow-node-sub {
padding: 0 14px 10px;
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.flow-node-placeholder {
font-style: italic;
color: var(--text-muted);
opacity: 0.6;
}
/* ─── Flow Panel (sidebar de edição) ─── */
.flow-editor-panel {
width: 320px;
background: var(--bg-secondary);
border-left: 1px solid var(--border-primary);
display: flex;
flex-direction: column;
flex-shrink: 0;
overflow-y: auto;
animation: slideInFromRight 0.2s ease;
}
@keyframes slideInFromRight {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.flow-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px;
border-bottom: 1px solid var(--border-primary);
}
.flow-panel-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.flow-panel-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.flow-panel-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.flow-panel-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.flow-panel-select {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-primary);
background: var(--bg-input);
color: var(--text-primary);
font-size: 13px;
font-family: 'Inter', sans-serif;
outline: none;
transition: border-color 0.15s;
}
.flow-panel-select:focus {
border-color: var(--border-focus);
}
.flow-panel-textarea {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-primary);
background: var(--bg-input);
color: var(--text-primary);
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
resize: vertical;
min-height: 80px;
outline: none;
transition: border-color 0.15s;
}
.flow-panel-textarea:focus {
border-color: var(--border-focus);
}
.flow-panel-hint {
font-size: 11px;
color: var(--text-muted);
}
.flow-panel-hint code {
background: var(--bg-tertiary);
padding: 1px 5px;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--accent);
}
.flow-panel-checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
}
.flow-panel-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.flow-panel-actions-group {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--border-primary);
}

View File

@@ -1169,23 +1169,12 @@
</div>
<div class="modal-body">
<input type="hidden" id="pipeline-execute-id">
<div class="form-group">
<label class="form-label" for="pipeline-execute-workdir">Diretório de Trabalho</label>
<input
type="text"
class="input"
id="pipeline-execute-workdir"
placeholder="/home/fred/projetos/meu-projeto"
autocomplete="off"
/>
<p class="form-hint">Todos os agentes da pipeline vão trabalhar neste diretório. Se vazio, cada agente usa seu próprio.</p>
</div>
<div class="form-group">
<label class="form-label" for="pipeline-execute-input">
Input Inicial
<span class="form-required" aria-hidden="true">*</span>
</label>
<textarea class="textarea" id="pipeline-execute-input" rows="4" placeholder="Descreva a tarefa inicial para o pipeline..."></textarea>
<textarea class="textarea" id="pipeline-execute-input" rows="4" placeholder="Descreva a tarefa inicial para o pipeline..." autofocus></textarea>
</div>
<div class="form-group">
<label class="form-label">Arquivos de Contexto</label>
@@ -1199,6 +1188,17 @@
<ul class="dropzone-list" id="pipeline-execute-file-list"></ul>
</div>
</div>
<div class="form-group">
<label class="form-label" for="pipeline-execute-workdir">Diretório de Trabalho (opcional)</label>
<input
type="text"
class="input"
id="pipeline-execute-workdir"
placeholder="/home/fred/projetos/meu-projeto"
autocomplete="off"
/>
<p class="form-hint">Se vazio, cada agente usa seu próprio diretório configurado.</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn--ghost" type="button" data-modal-close="pipeline-execute-modal-overlay">Cancelar</button>
@@ -1360,6 +1360,7 @@
<script src="js/components/tasks.js"></script>
<script src="js/components/schedules.js"></script>
<script src="js/components/pipelines.js"></script>
<script src="js/components/flow-editor.js"></script>
<script src="js/components/settings.js"></script>
<script src="js/components/history.js"></script>
<script src="js/components/webhooks.js"></script>

View File

@@ -289,6 +289,9 @@ const App = {
case 'pipeline_complete':
Terminal.stopProcessing();
Terminal.addLine('Pipeline concluído com sucesso.', 'success');
if (data.lastSessionId && data.lastAgentId) {
Terminal.enableChat(data.lastAgentId, data.lastAgentName || 'Agente', data.lastSessionId);
}
Toast.success('Pipeline concluído');
App.refreshCurrentSection();
break;
@@ -716,6 +719,7 @@ const App = {
switch (action) {
case 'execute-pipeline': PipelinesUI.execute(id); break;
case 'edit-pipeline': PipelinesUI.openEditModal(id); break;
case 'flow-pipeline': FlowEditor.open(id); break;
case 'delete-pipeline': PipelinesUI.delete(id); break;
}
});
@@ -756,6 +760,7 @@ const App = {
case 'move-up': PipelinesUI.moveStep(stepIndex, -1); break;
case 'move-down': PipelinesUI.moveStep(stepIndex, 1); break;
case 'remove': PipelinesUI.removeStep(stepIndex); break;
case 'toggle-mode': PipelinesUI.toggleMode(stepIndex); break;
}
});

View File

@@ -39,8 +39,17 @@ const AgentsUI = {
if (empty) empty.style.display = 'none';
const sorted = [...agents].sort((a, b) => {
const rank = (tags) => {
if ((tags || []).some((t) => t.toLowerCase() === 'lider')) return 0;
if ((tags || []).some((t) => t.toLowerCase() === 'po' || t.toLowerCase() === 'product-owner')) return 1;
return 2;
};
return rank(a.tags) - rank(b.tags);
});
const fragment = document.createDocumentFragment();
agents.forEach((agent) => {
sorted.forEach((agent) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = AgentsUI.renderCard(agent);
fragment.appendChild(wrapper.firstElementChild);
@@ -78,16 +87,24 @@ const AgentsUI = {
const tags = Array.isArray(agent.tags) && agent.tags.length > 0
? `<div class="agent-tags">${agent.tags.map((t) => `<span class="tag-chip tag-chip--sm">${Utils.escapeHtml(t)}</span>`).join('')}</div>`
: '';
const isLeader = Array.isArray(agent.tags) && agent.tags.some((t) => t.toLowerCase() === 'lider');
const isPO = !isLeader && Array.isArray(agent.tags) && agent.tags.some((t) => t.toLowerCase() === 'po' || t.toLowerCase() === 'product-owner');
const roleClass = isLeader ? ' agent-card--leader' : isPO ? ' agent-card--po' : '';
const roleBadge = isLeader
? '<i data-lucide="crown" class="agent-leader-icon"></i>'
: isPO
? '<i data-lucide="shield-check" class="agent-po-icon"></i>'
: '';
return `
<div class="agent-card" data-agent-id="${agent.id}">
<div class="agent-card${roleClass}" data-agent-id="${agent.id}">
<div class="agent-card-body">
<div class="agent-card-top">
<div class="agent-avatar" style="background-color: ${color}" aria-hidden="true">
<span>${initials}</span>
</div>
<div class="agent-info">
<h3 class="agent-name">${Utils.escapeHtml(name)}</h3>
<h3 class="agent-name">${roleBadge}${Utils.escapeHtml(name)}</h3>
<span class="badge ${statusClass}">${statusLabel}</span>
</div>
</div>

View File

@@ -0,0 +1,761 @@
const FlowEditor = {
_overlay: null,
_canvas: null,
_ctx: null,
_nodesContainer: null,
_pipelineId: null,
_pipeline: null,
_agents: [],
_nodes: [],
_dragState: null,
_panOffset: { x: 0, y: 0 },
_panStart: null,
_scale: 1,
_selectedNode: null,
_editingNode: null,
_resizeObserver: null,
_animFrame: null,
_dirty: false,
NODE_WIDTH: 240,
NODE_HEIGHT: 72,
NODE_GAP_Y: 100,
START_X: 0,
START_Y: 60,
async open(pipelineId) {
try {
const [pipeline, agents] = await Promise.all([
API.pipelines.get(pipelineId),
API.agents.list(),
]);
FlowEditor._pipelineId = pipelineId;
FlowEditor._pipeline = pipeline;
FlowEditor._agents = Array.isArray(agents) ? agents : [];
FlowEditor._selectedNode = null;
FlowEditor._editingNode = null;
FlowEditor._panOffset = { x: 0, y: 0 };
FlowEditor._scale = 1;
FlowEditor._dirty = false;
FlowEditor._buildNodes();
FlowEditor._show();
FlowEditor._centerView();
FlowEditor._render();
} catch (err) {
Toast.error('Erro ao abrir editor de fluxo: ' + err.message);
}
},
_buildNodes() {
const steps = Array.isArray(FlowEditor._pipeline.steps) ? FlowEditor._pipeline.steps : [];
FlowEditor._nodes = steps.map((step, i) => {
const agent = FlowEditor._agents.find((a) => a.id === step.agentId);
return {
id: step.id || 'step-' + i,
index: i,
x: 0,
y: i * (FlowEditor.NODE_HEIGHT + FlowEditor.NODE_GAP_Y),
agentId: step.agentId || '',
agentName: agent ? (agent.agent_name || agent.name) : (step.agentName || 'Agente'),
inputTemplate: step.inputTemplate || '',
requiresApproval: !!step.requiresApproval,
description: step.description || '',
};
});
},
_show() {
let overlay = document.getElementById('flow-editor-overlay');
if (!overlay) {
FlowEditor._createDOM();
overlay = document.getElementById('flow-editor-overlay');
}
FlowEditor._overlay = overlay;
FlowEditor._canvas = document.getElementById('flow-editor-canvas');
FlowEditor._ctx = FlowEditor._canvas.getContext('2d');
FlowEditor._nodesContainer = document.getElementById('flow-editor-nodes');
const titleEl = document.getElementById('flow-editor-title');
if (titleEl) titleEl.textContent = FlowEditor._pipeline.name || 'Pipeline';
const saveBtn = document.getElementById('flow-editor-save-btn');
if (saveBtn) saveBtn.classList.toggle('flow-btn--disabled', true);
overlay.hidden = false;
requestAnimationFrame(() => overlay.classList.add('active'));
FlowEditor._setupEvents();
FlowEditor._resizeCanvas();
if (!FlowEditor._resizeObserver) {
FlowEditor._resizeObserver = new ResizeObserver(() => {
FlowEditor._resizeCanvas();
FlowEditor._render();
});
}
FlowEditor._resizeObserver.observe(FlowEditor._canvas.parentElement);
},
_createDOM() {
const div = document.createElement('div');
div.innerHTML = `
<div class="flow-editor-overlay" id="flow-editor-overlay" hidden>
<div class="flow-editor">
<div class="flow-editor-header">
<div class="flow-editor-header-left">
<button class="flow-btn flow-btn--ghost" id="flow-editor-close-btn" title="Voltar">
<i data-lucide="arrow-left" style="width:18px;height:18px"></i>
</button>
<div class="flow-editor-title-group">
<h2 class="flow-editor-title" id="flow-editor-title">Pipeline</h2>
<span class="flow-editor-subtitle">Editor de Fluxo</span>
</div>
</div>
<div class="flow-editor-header-actions">
<div class="flow-editor-zoom">
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-out" title="Diminuir zoom">
<i data-lucide="minus" style="width:14px;height:14px"></i>
</button>
<span class="flow-zoom-label" id="flow-zoom-label">100%</span>
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-in" title="Aumentar zoom">
<i data-lucide="plus" style="width:14px;height:14px"></i>
</button>
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-zoom-fit" title="Centralizar">
<i data-lucide="maximize-2" style="width:14px;height:14px"></i>
</button>
</div>
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-add-node-btn" title="Adicionar passo">
<i data-lucide="plus-circle" style="width:16px;height:16px"></i>
<span>Passo</span>
</button>
<button class="flow-btn flow-btn--primary flow-btn--disabled" id="flow-editor-save-btn">
<i data-lucide="save" style="width:14px;height:14px"></i>
<span>Salvar</span>
</button>
</div>
</div>
<div class="flow-editor-body">
<div class="flow-editor-canvas-wrap" id="flow-editor-canvas-wrap">
<canvas id="flow-editor-canvas"></canvas>
<div class="flow-editor-nodes" id="flow-editor-nodes"></div>
</div>
<div class="flow-editor-panel" id="flow-editor-panel" hidden>
<div class="flow-panel-header">
<h3 class="flow-panel-title" id="flow-panel-title">Configuração</h3>
<button class="flow-btn flow-btn--ghost flow-btn--sm" id="flow-panel-close" title="Fechar painel">
<i data-lucide="x" style="width:14px;height:14px"></i>
</button>
</div>
<div class="flow-panel-body" id="flow-panel-body"></div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(div.firstElementChild);
},
_setupEvents() {
const wrap = document.getElementById('flow-editor-canvas-wrap');
if (!wrap || wrap._flowBound) return;
wrap._flowBound = true;
wrap.addEventListener('pointerdown', FlowEditor._onPointerDown);
wrap.addEventListener('pointermove', FlowEditor._onPointerMove);
wrap.addEventListener('pointerup', FlowEditor._onPointerUp);
wrap.addEventListener('wheel', FlowEditor._onWheel, { passive: false });
document.getElementById('flow-editor-close-btn')?.addEventListener('click', FlowEditor._close);
document.getElementById('flow-editor-save-btn')?.addEventListener('click', FlowEditor._save);
document.getElementById('flow-add-node-btn')?.addEventListener('click', FlowEditor._addNode);
document.getElementById('flow-zoom-in')?.addEventListener('click', () => FlowEditor._zoom(0.1));
document.getElementById('flow-zoom-out')?.addEventListener('click', () => FlowEditor._zoom(-0.1));
document.getElementById('flow-zoom-fit')?.addEventListener('click', () => FlowEditor._centerView());
document.getElementById('flow-panel-close')?.addEventListener('click', FlowEditor._closePanel);
document.addEventListener('keydown', FlowEditor._onKeyDown);
},
_resizeCanvas() {
const wrap = document.getElementById('flow-editor-canvas-wrap');
const canvas = FlowEditor._canvas;
if (!wrap || !canvas) return;
const dpr = window.devicePixelRatio || 1;
const rect = wrap.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
FlowEditor._ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
},
_render() {
if (FlowEditor._animFrame) cancelAnimationFrame(FlowEditor._animFrame);
FlowEditor._animFrame = requestAnimationFrame(FlowEditor._draw);
},
_draw() {
const ctx = FlowEditor._ctx;
const canvas = FlowEditor._canvas;
if (!ctx || !canvas) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.translate(FlowEditor._panOffset.x, FlowEditor._panOffset.y);
ctx.scale(FlowEditor._scale, FlowEditor._scale);
FlowEditor._drawGrid(ctx, w, h);
FlowEditor._drawConnections(ctx);
ctx.restore();
FlowEditor._renderNodes();
},
_drawGrid(ctx, w, h) {
const scale = FlowEditor._scale;
const ox = FlowEditor._panOffset.x;
const oy = FlowEditor._panOffset.y;
const gridSize = 24;
ctx.strokeStyle = 'rgba(255,255,255,0.03)';
ctx.lineWidth = 1 / scale;
const startX = Math.floor(-ox / scale / gridSize) * gridSize;
const startY = Math.floor(-oy / scale / gridSize) * gridSize;
const endX = startX + w / scale + gridSize * 2;
const endY = startY + h / scale + gridSize * 2;
ctx.beginPath();
for (let x = startX; x < endX; x += gridSize) {
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
}
for (let y = startY; y < endY; y += gridSize) {
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
}
ctx.stroke();
},
_drawConnections(ctx) {
const nodes = FlowEditor._nodes;
const nw = FlowEditor.NODE_WIDTH;
const nh = FlowEditor.NODE_HEIGHT;
for (let i = 0; i < nodes.length - 1; i++) {
const a = nodes[i];
const b = nodes[i + 1];
const ax = a.x + nw / 2;
const ay = a.y + nh;
const bx = b.x + nw / 2;
const by = b.y;
const midY = (ay + by) / 2;
const grad = ctx.createLinearGradient(ax, ay, bx, by);
grad.addColorStop(0, 'rgba(99,102,241,0.6)');
grad.addColorStop(1, 'rgba(139,92,246,0.6)');
ctx.strokeStyle = grad;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.bezierCurveTo(ax, midY, bx, midY, bx, by);
ctx.stroke();
const arrowSize = 6;
const angle = Math.atan2(by - midY, bx - bx) || Math.PI / 2;
ctx.fillStyle = 'rgba(139,92,246,0.8)';
ctx.beginPath();
ctx.moveTo(bx, by);
ctx.lineTo(bx - arrowSize * Math.cos(angle - 0.4), by - arrowSize * Math.sin(angle - 0.4));
ctx.lineTo(bx - arrowSize * Math.cos(angle + 0.4), by - arrowSize * Math.sin(angle + 0.4));
ctx.closePath();
ctx.fill();
if (b.requiresApproval) {
const iconX = (ax + bx) / 2;
const iconY = midY;
ctx.fillStyle = '#0a0a0f';
ctx.beginPath();
ctx.arc(iconX, iconY, 10, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(245,158,11,0.8)';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.fillStyle = '#f59e0b';
ctx.font = 'bold 10px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('!', iconX, iconY);
}
}
},
_renderNodes() {
const container = FlowEditor._nodesContainer;
if (!container) return;
const ox = FlowEditor._panOffset.x;
const oy = FlowEditor._panOffset.y;
const scale = FlowEditor._scale;
let existingEls = container.querySelectorAll('.flow-node');
const existingMap = {};
existingEls.forEach((el) => { existingMap[el.dataset.nodeId] = el; });
FlowEditor._nodes.forEach((node, i) => {
const screenX = node.x * scale + ox;
const screenY = node.y * scale + oy;
const isSelected = FlowEditor._selectedNode === i;
let el = existingMap[node.id];
if (!el) {
el = document.createElement('div');
el.className = 'flow-node';
el.dataset.nodeId = node.id;
el.dataset.nodeIndex = i;
container.appendChild(el);
}
el.dataset.nodeIndex = i;
el.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
el.style.width = FlowEditor.NODE_WIDTH + 'px';
el.style.height = FlowEditor.NODE_HEIGHT + 'px';
el.classList.toggle('flow-node--selected', isSelected);
const stepNum = i + 1;
const name = Utils.escapeHtml(node.agentName || 'Selecionar agente...');
const approvalBadge = node.requiresApproval && i > 0
? '<span class="flow-node-approval">Aprovação</span>'
: '';
el.innerHTML = `
<div class="flow-node-header">
<span class="flow-node-number">${stepNum}</span>
<span class="flow-node-name" title="${name}">${name}</span>
${approvalBadge}
</div>
<div class="flow-node-sub">
${node.inputTemplate ? Utils.escapeHtml(Utils.truncate(node.inputTemplate, 40)) : '<span class="flow-node-placeholder">Sem template de input</span>'}
</div>
`;
delete existingMap[node.id];
});
Object.values(existingMap).forEach((el) => el.remove());
},
_centerView() {
const canvas = FlowEditor._canvas;
if (!canvas || FlowEditor._nodes.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.width / dpr;
const h = canvas.height / dpr;
const nw = FlowEditor.NODE_WIDTH;
const nh = FlowEditor.NODE_HEIGHT;
const nodes = FlowEditor._nodes;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodes.forEach((n) => {
minX = Math.min(minX, n.x);
minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x + nw);
maxY = Math.max(maxY, n.y + nh);
});
const contentW = maxX - minX;
const contentH = maxY - minY;
const padding = 80;
const scaleX = (w - padding * 2) / contentW;
const scaleY = (h - padding * 2) / contentH;
const scale = Math.min(Math.max(Math.min(scaleX, scaleY), 0.3), 1.5);
FlowEditor._scale = scale;
FlowEditor._panOffset = {
x: (w - contentW * scale) / 2 - minX * scale,
y: (h - contentH * scale) / 2 - minY * scale,
};
FlowEditor._updateZoomLabel();
FlowEditor._render();
},
_zoom(delta) {
const oldScale = FlowEditor._scale;
FlowEditor._scale = Math.min(Math.max(oldScale + delta, 0.2), 2.5);
FlowEditor._updateZoomLabel();
FlowEditor._render();
},
_updateZoomLabel() {
const el = document.getElementById('flow-zoom-label');
if (el) el.textContent = Math.round(FlowEditor._scale * 100) + '%';
},
_onPointerDown(e) {
const nodeEl = e.target.closest('.flow-node');
if (nodeEl) {
const idx = parseInt(nodeEl.dataset.nodeIndex, 10);
FlowEditor._selectedNode = idx;
if (e.detail === 2) {
FlowEditor._openNodePanel(idx);
FlowEditor._render();
return;
}
const node = FlowEditor._nodes[idx];
FlowEditor._dragState = {
type: 'node',
index: idx,
startX: e.clientX,
startY: e.clientY,
origX: node.x,
origY: node.y,
moved: false,
};
nodeEl.setPointerCapture(e.pointerId);
FlowEditor._render();
return;
}
if (e.target.closest('.flow-editor-panel') || e.target.closest('.flow-editor-header')) return;
FlowEditor._selectedNode = null;
FlowEditor._panStart = {
x: e.clientX - FlowEditor._panOffset.x,
y: e.clientY - FlowEditor._panOffset.y,
};
FlowEditor._render();
},
_onPointerMove(e) {
if (FlowEditor._dragState) {
const ds = FlowEditor._dragState;
const dx = (e.clientX - ds.startX) / FlowEditor._scale;
const dy = (e.clientY - ds.startY) / FlowEditor._scale;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) ds.moved = true;
FlowEditor._nodes[ds.index].x = ds.origX + dx;
FlowEditor._nodes[ds.index].y = ds.origY + dy;
FlowEditor._render();
return;
}
if (FlowEditor._panStart) {
FlowEditor._panOffset.x = e.clientX - FlowEditor._panStart.x;
FlowEditor._panOffset.y = e.clientY - FlowEditor._panStart.y;
FlowEditor._render();
}
},
_onPointerUp(e) {
if (FlowEditor._dragState) {
const ds = FlowEditor._dragState;
if (!ds.moved) {
FlowEditor._openNodePanel(ds.index);
} else {
FlowEditor._markDirty();
}
FlowEditor._dragState = null;
FlowEditor._render();
return;
}
FlowEditor._panStart = null;
},
_onWheel(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.08 : 0.08;
const oldScale = FlowEditor._scale;
const newScale = Math.min(Math.max(oldScale + delta, 0.2), 2.5);
const rect = FlowEditor._canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
FlowEditor._panOffset.x = mx - (mx - FlowEditor._panOffset.x) * (newScale / oldScale);
FlowEditor._panOffset.y = my - (my - FlowEditor._panOffset.y) * (newScale / oldScale);
FlowEditor._scale = newScale;
FlowEditor._updateZoomLabel();
FlowEditor._render();
},
_onKeyDown(e) {
if (!FlowEditor._overlay || FlowEditor._overlay.hidden) return;
if (e.key === 'Escape') {
if (FlowEditor._editingNode !== null) {
FlowEditor._closePanel();
} else {
FlowEditor._close();
}
e.stopPropagation();
return;
}
if (e.key === 'Delete' && FlowEditor._selectedNode !== null && FlowEditor._editingNode === null) {
FlowEditor._removeNode(FlowEditor._selectedNode);
}
},
_openNodePanel(index) {
const node = FlowEditor._nodes[index];
if (!node) return;
FlowEditor._editingNode = index;
FlowEditor._selectedNode = index;
const panel = document.getElementById('flow-editor-panel');
const title = document.getElementById('flow-panel-title');
const body = document.getElementById('flow-panel-body');
if (!panel || !body) return;
if (title) title.textContent = `Passo ${index + 1}`;
panel.hidden = false;
const agentOptions = FlowEditor._agents
.map((a) => {
const aName = Utils.escapeHtml(a.agent_name || a.name);
const selected = a.id === node.agentId ? 'selected' : '';
return `<option value="${a.id}" ${selected}>${aName}</option>`;
})
.join('');
const approvalChecked = node.requiresApproval ? 'checked' : '';
const showApproval = index > 0;
body.innerHTML = `
<div class="flow-panel-field">
<label class="flow-panel-label">Agente</label>
<select class="flow-panel-select" id="flow-panel-agent">
<option value="">Selecionar agente...</option>
${agentOptions}
</select>
</div>
<div class="flow-panel-field">
<label class="flow-panel-label">Template de Input</label>
<textarea class="flow-panel-textarea" id="flow-panel-template" rows="4" placeholder="{{input}} será substituído pelo output anterior">${Utils.escapeHtml(node.inputTemplate || '')}</textarea>
<span class="flow-panel-hint">Use <code>{{input}}</code> para referenciar o output do passo anterior</span>
</div>
${showApproval ? `
<div class="flow-panel-field">
<label class="flow-panel-checkbox">
<input type="checkbox" id="flow-panel-approval" ${approvalChecked} />
<span>Requer aprovação antes de executar</span>
</label>
</div>` : ''}
<div class="flow-panel-field flow-panel-actions-group">
<button class="flow-btn flow-btn--ghost flow-btn--sm flow-btn--full" id="flow-panel-move-up" ${index === 0 ? 'disabled' : ''}>
<i data-lucide="chevron-up" style="width:14px;height:14px"></i> Mover acima
</button>
<button class="flow-btn flow-btn--ghost flow-btn--sm flow-btn--full" id="flow-panel-move-down" ${index === FlowEditor._nodes.length - 1 ? 'disabled' : ''}>
<i data-lucide="chevron-down" style="width:14px;height:14px"></i> Mover abaixo
</button>
<button class="flow-btn flow-btn--danger flow-btn--sm flow-btn--full" id="flow-panel-delete">
<i data-lucide="trash-2" style="width:14px;height:14px"></i> Remover passo
</button>
</div>
`;
Utils.refreshIcons(body);
document.getElementById('flow-panel-agent')?.addEventListener('change', (ev) => {
const val = ev.target.value;
node.agentId = val;
const agent = FlowEditor._agents.find((a) => a.id === val);
node.agentName = agent ? (agent.agent_name || agent.name) : 'Selecionar agente...';
FlowEditor._markDirty();
FlowEditor._render();
});
document.getElementById('flow-panel-template')?.addEventListener('input', (ev) => {
node.inputTemplate = ev.target.value;
FlowEditor._markDirty();
FlowEditor._render();
});
document.getElementById('flow-panel-approval')?.addEventListener('change', (ev) => {
node.requiresApproval = ev.target.checked;
FlowEditor._markDirty();
FlowEditor._render();
});
document.getElementById('flow-panel-move-up')?.addEventListener('click', () => {
FlowEditor._swapNodes(index, index - 1);
});
document.getElementById('flow-panel-move-down')?.addEventListener('click', () => {
FlowEditor._swapNodes(index, index + 1);
});
document.getElementById('flow-panel-delete')?.addEventListener('click', () => {
FlowEditor._removeNode(index);
});
},
_closePanel() {
const panel = document.getElementById('flow-editor-panel');
if (panel) panel.hidden = true;
FlowEditor._editingNode = null;
},
_addNode() {
const lastNode = FlowEditor._nodes[FlowEditor._nodes.length - 1];
const newY = lastNode
? lastNode.y + FlowEditor.NODE_HEIGHT + FlowEditor.NODE_GAP_Y
: FlowEditor.START_Y;
const newX = lastNode ? lastNode.x : FlowEditor.START_X;
FlowEditor._nodes.push({
id: 'step-new-' + Date.now(),
index: FlowEditor._nodes.length,
x: newX,
y: newY,
agentId: '',
agentName: 'Selecionar agente...',
inputTemplate: '',
requiresApproval: false,
description: '',
});
FlowEditor._markDirty();
FlowEditor._render();
const newIdx = FlowEditor._nodes.length - 1;
FlowEditor._selectedNode = newIdx;
FlowEditor._openNodePanel(newIdx);
},
_removeNode(index) {
if (FlowEditor._nodes.length <= 2) {
Toast.warning('O pipeline precisa de pelo menos 2 passos');
return;
}
FlowEditor._nodes.splice(index, 1);
FlowEditor._nodes.forEach((n, i) => { n.index = i; });
if (FlowEditor._editingNode === index) FlowEditor._closePanel();
if (FlowEditor._selectedNode === index) FlowEditor._selectedNode = null;
FlowEditor._markDirty();
FlowEditor._render();
},
_swapNodes(a, b) {
if (b < 0 || b >= FlowEditor._nodes.length) return;
const tempX = FlowEditor._nodes[a].x;
const tempY = FlowEditor._nodes[a].y;
FlowEditor._nodes[a].x = FlowEditor._nodes[b].x;
FlowEditor._nodes[a].y = FlowEditor._nodes[b].y;
FlowEditor._nodes[b].x = tempX;
FlowEditor._nodes[b].y = tempY;
const temp = FlowEditor._nodes[a];
FlowEditor._nodes[a] = FlowEditor._nodes[b];
FlowEditor._nodes[b] = temp;
FlowEditor._nodes.forEach((n, i) => { n.index = i; });
FlowEditor._selectedNode = b;
FlowEditor._editingNode = b;
FlowEditor._markDirty();
FlowEditor._openNodePanel(b);
FlowEditor._render();
},
_markDirty() {
FlowEditor._dirty = true;
const btn = document.getElementById('flow-editor-save-btn');
if (btn) btn.classList.remove('flow-btn--disabled');
},
async _save() {
if (!FlowEditor._dirty) return;
const invalidNode = FlowEditor._nodes.find((n) => !n.agentId);
if (invalidNode) {
Toast.warning('Todos os passos devem ter um agente selecionado');
return;
}
if (FlowEditor._nodes.length < 2) {
Toast.warning('O pipeline precisa de pelo menos 2 passos');
return;
}
const steps = FlowEditor._nodes.map((n) => ({
agentId: n.agentId,
inputTemplate: n.inputTemplate || '',
requiresApproval: !!n.requiresApproval,
}));
try {
await API.pipelines.update(FlowEditor._pipelineId, {
name: FlowEditor._pipeline.name,
description: FlowEditor._pipeline.description,
steps,
});
FlowEditor._dirty = false;
const btn = document.getElementById('flow-editor-save-btn');
if (btn) btn.classList.add('flow-btn--disabled');
Toast.success('Pipeline atualizado com sucesso');
if (typeof PipelinesUI !== 'undefined') PipelinesUI.load();
} catch (err) {
Toast.error('Erro ao salvar: ' + err.message);
}
},
_close() {
if (FlowEditor._dirty) {
const leave = confirm('Existem alterações não salvas. Deseja sair mesmo assim?');
if (!leave) return;
}
const overlay = FlowEditor._overlay;
if (!overlay) return;
overlay.classList.remove('active');
setTimeout(() => { overlay.hidden = true; }, 200);
FlowEditor._closePanel();
if (FlowEditor._resizeObserver) {
FlowEditor._resizeObserver.disconnect();
}
document.removeEventListener('keydown', FlowEditor._onKeyDown);
FlowEditor._editingNode = null;
FlowEditor._selectedNode = null;
FlowEditor._dragState = null;
FlowEditor._panStart = null;
},
};
window.FlowEditor = FlowEditor;

View File

@@ -84,7 +84,6 @@ const PipelinesUI = {
const flowHtml = steps.map((step, index) => {
const agentName = Utils.escapeHtml(step.agentName || step.agentId || 'Agente');
const isLast = index === steps.length - 1;
const approvalIcon = step.requiresApproval && index > 0
? '<i data-lucide="shield-check" style="width:10px;height:10px;color:var(--warning)"></i> '
: '';
@@ -93,7 +92,6 @@ const PipelinesUI = {
<span class="pipeline-step-number">${index + 1}</span>
${approvalIcon}${agentName}
</span>
${!isLast ? '<span class="pipeline-flow-arrow">→</span>' : ''}
`;
}).join('');
@@ -120,6 +118,9 @@ const PipelinesUI = {
Executar
</button>
<div class="agent-actions-icons">
<button class="btn btn-ghost btn-icon btn-sm" data-action="flow-pipeline" data-id="${pipeline.id}" title="Editor de fluxo">
<i data-lucide="workflow"></i>
</button>
<button class="btn btn-ghost btn-icon btn-sm" data-action="edit-pipeline" data-id="${pipeline.id}" title="Editar pipeline">
<i data-lucide="pencil"></i>
</button>
@@ -135,8 +136,8 @@ const PipelinesUI = {
openCreateModal() {
PipelinesUI._editingId = null;
PipelinesUI._steps = [
{ agentId: '', inputTemplate: '', requiresApproval: false },
{ agentId: '', inputTemplate: '', requiresApproval: false },
{ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false },
{ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false },
];
const titleEl = document.getElementById('pipeline-modal-title');
@@ -161,7 +162,13 @@ const PipelinesUI = {
PipelinesUI._editingId = pipelineId;
PipelinesUI._steps = Array.isArray(pipeline.steps)
? pipeline.steps.map((s) => ({ agentId: s.agentId || '', inputTemplate: s.inputTemplate || '', requiresApproval: !!s.requiresApproval }))
? pipeline.steps.map((s) => ({
agentId: s.agentId || '',
inputTemplate: s.inputTemplate || '',
description: s.description || '',
promptMode: s.description ? 'simple' : 'advanced',
requiresApproval: !!s.requiresApproval,
}))
: [];
const titleEl = document.getElementById('pipeline-modal-title');
@@ -212,6 +219,46 @@ const PipelinesUI = {
</label>`
: '';
const isSimple = step.promptMode !== 'advanced';
const inputContext = isFirst
? 'O input inicial do pipeline'
: 'O resultado (sumarizado) do passo anterior';
const promptHtml = isSimple
? `<textarea
class="textarea"
rows="2"
placeholder="Ex: Analise os requisitos e crie um plano técnico detalhado"
data-step-field="description"
data-step-index="${index}"
>${Utils.escapeHtml(step.description || '')}</textarea>
<div class="pipeline-step-hints">
<span class="pipeline-step-hint">
<i data-lucide="info" style="width:11px;height:11px"></i>
${inputContext} será injetado via <code>{{input}}</code> automaticamente no final.
</span>
<span class="pipeline-step-hint">
<i data-lucide="lightbulb" style="width:11px;height:11px"></i>
Dica: use <code>&lt;tags&gt;</code> XML para organizar melhor. Ex: <code>&lt;contexto&gt;</code> <code>&lt;regras&gt;</code> <code>&lt;formato_saida&gt;</code>
</span>
</div>`
: `<textarea
class="textarea"
rows="3"
placeholder="Use {{input}} para posicionar o output do passo anterior. Estruture com <tags> XML."
data-step-field="inputTemplate"
data-step-index="${index}"
>${Utils.escapeHtml(step.inputTemplate || '')}</textarea>
<div class="pipeline-step-hints">
<span class="pipeline-step-hint">
<i data-lucide="lightbulb" style="width:11px;height:11px"></i>
Dica: use <code>&lt;tags&gt;</code> XML para organizar. Ex: <code>&lt;contexto&gt;{{input}}&lt;/contexto&gt;</code> <code>&lt;regras&gt;</code> <code>&lt;formato_saida&gt;</code>
</span>
</div>`;
const modeIcon = isSimple ? 'code' : 'text';
const modeLabel = isSimple ? 'Avançado' : 'Simples';
return `
<div class="pipeline-step-row" data-step-index="${index}">
<span class="pipeline-step-number-lg">${index + 1}</span>
@@ -220,14 +267,14 @@ const PipelinesUI = {
<option value="">Selecionar agente...</option>
${agentOptions}
</select>
<textarea
class="textarea"
rows="2"
placeholder="{{input}} será substituído pelo output anterior"
data-step-field="inputTemplate"
data-step-index="${index}"
>${Utils.escapeHtml(step.inputTemplate || '')}</textarea>
${promptHtml}
<div class="pipeline-step-footer">
${approvalHtml}
<button type="button" class="pipeline-mode-toggle" data-step-action="toggle-mode" data-step-index="${index}" title="Alternar entre modo simples e avançado">
<i data-lucide="${modeIcon}" style="width:12px;height:12px"></i>
${modeLabel}
</button>
</div>
</div>
<div class="pipeline-step-actions">
<button class="btn btn-ghost btn-icon btn-sm" type="button" data-step-action="move-up" data-step-index="${index}" title="Mover para cima" ${isFirst ? 'disabled' : ''}>
@@ -270,9 +317,41 @@ const PipelinesUI = {
});
},
_generateTemplate(description, stepIndex) {
if (!description) return '';
if (stepIndex === 0) {
return `${description}\n\n{{input}}`;
}
return `${description}\n\nResultado do passo anterior:\n{{input}}`;
},
toggleMode(index) {
PipelinesUI._syncStepsFromDOM();
const step = PipelinesUI._steps[index];
if (!step) return;
if (step.promptMode === 'advanced') {
step.promptMode = 'simple';
if (step.inputTemplate && !step.description) {
step.description = step.inputTemplate
.replace(/\{\{input\}\}/g, '')
.replace(/Resultado do passo anterior:\s*/g, '')
.replace(/Input:\s*/g, '')
.trim();
}
} else {
step.promptMode = 'advanced';
if (step.description && !step.inputTemplate) {
step.inputTemplate = PipelinesUI._generateTemplate(step.description, index);
}
}
PipelinesUI.renderSteps();
},
addStep() {
PipelinesUI._syncStepsFromDOM();
PipelinesUI._steps.push({ agentId: '', inputTemplate: '', requiresApproval: false });
PipelinesUI._steps.push({ agentId: '', inputTemplate: '', description: '', promptMode: 'simple', requiresApproval: false });
PipelinesUI.renderSteps();
},
@@ -315,11 +394,19 @@ const PipelinesUI = {
const data = {
name,
description: document.getElementById('pipeline-description')?.value.trim() || '',
steps: PipelinesUI._steps.map((s) => ({
steps: PipelinesUI._steps.map((s, index) => {
const isSimple = s.promptMode !== 'advanced';
const inputTemplate = isSimple
? PipelinesUI._generateTemplate(s.description, index)
: (s.inputTemplate || '');
return {
agentId: s.agentId,
inputTemplate: s.inputTemplate || '',
inputTemplate,
description: isSimple ? (s.description || '') : '',
requiresApproval: !!s.requiresApproval,
})),
};
}),
};
try {
@@ -385,6 +472,11 @@ const PipelinesUI = {
return;
}
if (workingDirectory && !workingDirectory.startsWith('/')) {
Toast.warning('O diretório de trabalho deve ser um caminho absoluto (começar com /)');
return;
}
try {
let contextFiles = null;
const dropzone = App._pipelineDropzone;

View File

@@ -73,6 +73,14 @@ const Utils = {
render();
}
const browseBtn = zone.querySelector('.dropzone-browse');
if (browseBtn) {
browseBtn.addEventListener('click', (e) => {
e.stopPropagation();
input.click();
});
}
zone.addEventListener('click', (e) => {
if (e.target.closest('.dropzone-file-remove')) {
const idx = parseInt(e.target.closest('.dropzone-file-remove').dataset.index);
@@ -80,6 +88,7 @@ const Utils = {
render();
return;
}
if (e.target.closest('.dropzone-browse')) return;
if (!e.target.closest('.dropzone-file')) input.click();
});

View File

@@ -1,9 +1,12 @@
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuidv4 } from 'uuid';
import { settingsStore } from '../store/db.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const AGENT_SETTINGS = path.resolve(__dirname, '..', '..', 'data', 'agent-settings.json');
const CLAUDE_BIN = resolveBin();
const activeExecutions = new Map();
const MAX_OUTPUT_SIZE = 512 * 1024;
@@ -48,9 +51,7 @@ function cleanEnv(agentSecrets) {
const env = { ...process.env };
delete env.CLAUDECODE;
delete env.ANTHROPIC_API_KEY;
if (!env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) {
env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = '128000';
}
if (agentSecrets && typeof agentSecrets === 'object') {
Object.assign(env, agentSecrets);
}
@@ -61,6 +62,10 @@ function buildArgs(agentConfig, prompt) {
const model = agentConfig.model || 'claude-sonnet-4-6';
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', '--model', model];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
if (agentConfig.systemPrompt) {
args.push('--system-prompt', agentConfig.systemPrompt);
}
@@ -358,6 +363,10 @@ export function resume(agentConfig, sessionId, message, callbacks = {}) {
'--permission-mode', agentConfig.permissionMode || 'bypassPermissions',
];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
if (agentConfig.maxTurns && agentConfig.maxTurns > 0) {
args.push('--max-turns', String(agentConfig.maxTurns));
}
@@ -412,6 +421,65 @@ export function getActiveExecutions() {
}));
}
export function summarize(text, threshold = 1500) {
return new Promise((resolve) => {
if (!text || text.length <= threshold) {
resolve(text);
return;
}
const prompt = `Resuma o conteúdo abaixo de forma estruturada e concisa. Preserve TODAS as informações críticas:
- Decisões técnicas e justificativas
- Trechos de código essenciais
- Dados, números e métricas
- Problemas encontrados e soluções
- Recomendações e próximos passos
Organize o resumo usando <tags> XML (ex: <decisoes>, <codigo>, <problemas>, <recomendacoes>).
NÃO omita informações que seriam necessárias para outro profissional continuar o trabalho.
<conteudo_para_resumir>
${text}
</conteudo_para_resumir>`;
const args = [
'-p', prompt,
'--output-format', 'text',
'--model', 'claude-haiku-4-5-20251001',
'--max-turns', '1',
'--permission-mode', 'bypassPermissions',
];
if (existsSync(AGENT_SETTINGS)) {
args.push('--settings', AGENT_SETTINGS);
}
const child = spawn(CLAUDE_BIN, args, {
env: cleanEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
const timer = setTimeout(() => {
child.kill('SIGTERM');
}, 120000);
child.stdout.on('data', (chunk) => { output += chunk.toString(); });
child.on('close', () => {
clearTimeout(timer);
const result = output.trim();
console.log(`[executor] Sumarização: ${text.length}${result.length} chars`);
resolve(result || text);
});
child.on('error', () => {
clearTimeout(timer);
resolve(text);
});
});
}
export function getBinPath() {
return CLAUDE_BIN;
}

View File

@@ -273,6 +273,23 @@ export function getRecentExecutions(limit = 20) {
return recentExecBuffer.slice(0, Math.min(limit, MAX_RECENT));
}
async function executeWithRetry(agentId, taskDescription, metadata, maxRetries = 10, baseDelay = 30000) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
executeTask(agentId, taskDescription, null, null, metadata);
return;
} catch (err) {
if (err.message.includes('Limite de execuções simultâneas') && attempt < maxRetries) {
const delay = baseDelay + Math.random() * 10000;
console.log(`[manager] Agendamento aguardando slot (tentativa ${attempt}/${maxRetries}), retry em ${(delay / 1000).toFixed(0)}s`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err;
}
}
}
export function scheduleTask(agentId, taskDescription, cronExpression, wsCallback) {
const agent = agentsStore.getById(agentId);
if (!agent) throw new Error(`Agente ${agentId} não encontrado`);
@@ -296,7 +313,9 @@ export function scheduleTask(agentId, taskDescription, cronExpression, wsCallbac
schedulesStore.save(items);
scheduler.schedule(scheduleId, cronExpression, () => {
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId });
executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
console.log(`[manager] Agendamento ${scheduleId} falhou após retries: ${err.message}`);
});
}, false);
return { scheduleId, agentId, agentName: agent.agent_name, taskDescription, cronExpression };
@@ -314,7 +333,9 @@ export function updateScheduleTask(scheduleId, data, wsCallback) {
const cronExpression = data.cronExpression || stored.cronExpression;
scheduler.updateSchedule(scheduleId, cronExpression, () => {
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId });
executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
console.log(`[manager] Agendamento ${scheduleId} falhou após retries: ${err.message}`);
});
});
schedulesStore.update(scheduleId, { agentId, agentName: agent.agent_name, taskDescription, cronExpression });
@@ -424,10 +445,8 @@ export function importAgent(data) {
export function restoreSchedules() {
scheduler.restoreSchedules((agentId, taskDescription, scheduleId) => {
try {
executeTask(agentId, taskDescription, null, null, { source: 'schedule', scheduleId });
} catch (err) {
executeWithRetry(agentId, taskDescription, { source: 'schedule', scheduleId }).catch(err => {
console.log(`[manager] Erro ao executar tarefa agendada: ${err.message}`);
}
});
});
}

View File

@@ -86,6 +86,7 @@ function executeStepAsPromise(agentConfig, prompt, pipelineState, wsCallback, pi
costUsd: result.costUsd || 0,
durationMs: result.durationMs || 0,
numTurns: result.numTurns || 0,
sessionId: result.sessionId || '',
});
},
}
@@ -237,7 +238,7 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
totalCost += stepResult.costUsd;
currentInput = stepResult.text;
results.push({ stepId: step.id, agentName: agent.agent_name, result: stepResult.text });
results.push({ stepId: step.id, agentId: step.agentId, agentName: agent.agent_name, result: stepResult.text, sessionId: stepResult.sessionId });
const current = executionsStore.getById(historyRecord.id);
const savedSteps = current ? (current.steps || []) : [];
@@ -266,6 +267,19 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
costUsd: stepResult.costUsd,
});
}
if (i < steps.length - 1 && !pipelineState.canceled) {
if (wsCallback) {
wsCallback({ type: 'pipeline_summarizing', pipelineId, stepIndex: i, originalLength: currentInput.length });
}
const summarized = await executor.summarize(currentInput);
if (summarized !== currentInput) {
if (wsCallback) {
wsCallback({ type: 'pipeline_summarized', pipelineId, stepIndex: i, originalLength: currentInput.length, summarizedLength: summarized.length });
}
currentInput = summarized;
}
}
}
activePipelines.delete(executionId);
@@ -285,7 +299,17 @@ export async function executePipeline(pipelineId, initialInput, wsCallback, opti
if (wsCallback) wsCallback({ type: 'report_generated', pipelineId, reportFile: report.filename });
}
} catch (e) { console.error('[pipeline] Erro ao gerar relatório:', e.message); }
if (wsCallback) wsCallback({ type: 'pipeline_complete', pipelineId, executionId, results, totalCostUsd: totalCost });
const lastResult = results.length > 0 ? results[results.length - 1] : null;
if (wsCallback) wsCallback({
type: 'pipeline_complete',
pipelineId,
executionId,
results,
totalCostUsd: totalCost,
lastAgentId: lastResult?.agentId || '',
lastAgentName: lastResult?.agentName || '',
lastSessionId: lastResult?.sessionId || '',
});
}
return { executionId, results };