Download MD no histórico, relatórios externos e service systemd
- Botão de download .md no modal de detalhe do histórico (agente e pipeline) - Relatórios de execução gravados também em ~/agent_reports/ (configurável via AGENT_REPORTS_DIR) - Service systemd (user) para iniciar o orchestrator no boot com auto-restart
This commit is contained in:
@@ -2813,6 +2813,118 @@ tbody tr:hover td {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border-secondary);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.dropzone:hover,
|
||||
.dropzone.dragover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(139, 92, 246, 0.05);
|
||||
}
|
||||
|
||||
.dropzone-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropzone-content i,
|
||||
.dropzone-content svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dropzone-content p {
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dropzone-browse {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dropzone-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dropzone-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dropzone-list:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropzone-list + .dropzone-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dropzone-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dropzone-file-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropzone-file-size {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropzone-file-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropzone-file-remove:hover {
|
||||
color: var(--error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -974,6 +974,19 @@
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Arquivos de Contexto</label>
|
||||
<div class="dropzone" id="execute-dropzone">
|
||||
<input type="file" id="execute-files" multiple hidden />
|
||||
<div class="dropzone-content">
|
||||
<i data-lucide="upload-cloud"></i>
|
||||
<p>Arraste arquivos aqui ou <button type="button" class="dropzone-browse">selecione</button></p>
|
||||
<span class="dropzone-hint">Até 20 arquivos, 10MB cada</span>
|
||||
</div>
|
||||
<ul class="dropzone-list" id="execute-file-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-templates">
|
||||
<p class="quick-templates-label">Templates rápidos</p>
|
||||
<div class="quick-templates-grid">
|
||||
@@ -1174,6 +1187,18 @@
|
||||
</label>
|
||||
<textarea class="textarea" id="pipeline-execute-input" rows="4" placeholder="Descreva a tarefa inicial para o pipeline..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Arquivos de Contexto</label>
|
||||
<div class="dropzone" id="pipeline-execute-dropzone">
|
||||
<input type="file" id="pipeline-execute-files" multiple hidden />
|
||||
<div class="dropzone-content">
|
||||
<i data-lucide="upload-cloud"></i>
|
||||
<p>Arraste arquivos aqui ou <button type="button" class="dropzone-browse">selecione</button></p>
|
||||
<span class="dropzone-hint">Até 20 arquivos, 10MB cada</span>
|
||||
</div>
|
||||
<ul class="dropzone-list" id="pipeline-execute-file-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn--ghost" type="button" data-modal-close="pipeline-execute-modal-overlay">Cancelar</button>
|
||||
|
||||
@@ -38,7 +38,11 @@ const API = {
|
||||
create(data) { return API.request('POST', '/agents', data); },
|
||||
update(id, data) { return API.request('PUT', `/agents/${id}`, data); },
|
||||
delete(id) { return API.request('DELETE', `/agents/${id}`); },
|
||||
execute(id, task, instructions) { return API.request('POST', `/agents/${id}/execute`, { task, instructions }); },
|
||||
execute(id, task, instructions, contextFiles) {
|
||||
const body = { task, instructions };
|
||||
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
|
||||
return API.request('POST', `/agents/${id}/execute`, body);
|
||||
},
|
||||
cancel(id, executionId) { return API.request('POST', `/agents/${id}/cancel/${executionId}`); },
|
||||
continue(id, sessionId, message) { return API.request('POST', `/agents/${id}/continue`, { sessionId, message }); },
|
||||
export(id) { return API.request('GET', `/agents/${id}/export`); },
|
||||
@@ -78,9 +82,10 @@ const API = {
|
||||
create(data) { return API.request('POST', '/pipelines', data); },
|
||||
update(id, data) { return API.request('PUT', `/pipelines/${id}`, data); },
|
||||
delete(id) { return API.request('DELETE', `/pipelines/${id}`); },
|
||||
execute(id, input, workingDirectory) {
|
||||
execute(id, input, workingDirectory, contextFiles) {
|
||||
const body = { input };
|
||||
if (workingDirectory) body.workingDirectory = workingDirectory;
|
||||
if (contextFiles && contextFiles.length > 0) body.contextFiles = contextFiles;
|
||||
return API.request('POST', `/pipelines/${id}/execute`, body);
|
||||
},
|
||||
cancel(id) { return API.request('POST', `/pipelines/${id}/cancel`); },
|
||||
@@ -119,6 +124,21 @@ const API = {
|
||||
save(data) { return API.request('PUT', '/settings', data); },
|
||||
},
|
||||
|
||||
uploads: {
|
||||
async send(files) {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append('files', f);
|
||||
const response = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
headers: { 'X-Client-Id': API.clientId },
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || 'Erro no upload');
|
||||
return data;
|
||||
},
|
||||
},
|
||||
|
||||
reports: {
|
||||
list() { return API.request('GET', '/reports'); },
|
||||
get(filename) { return API.request('GET', `/reports/${encodeURIComponent(filename)}`); },
|
||||
|
||||
@@ -5,6 +5,8 @@ const App = {
|
||||
wsReconnectTimer: null,
|
||||
_initialized: false,
|
||||
_lastAgentName: '',
|
||||
_executeDropzone: null,
|
||||
_pipelineDropzone: null,
|
||||
|
||||
sectionTitles: {
|
||||
dashboard: 'Dashboard',
|
||||
@@ -32,6 +34,9 @@ const App = {
|
||||
App.setupEventListeners();
|
||||
App.setupKeyboardShortcuts();
|
||||
|
||||
App._executeDropzone = Utils.initDropzone('execute-dropzone', 'execute-files', 'execute-file-list');
|
||||
App._pipelineDropzone = Utils.initDropzone('pipeline-execute-dropzone', 'pipeline-execute-files', 'pipeline-execute-file-list');
|
||||
|
||||
const initialSection = location.hash.replace('#', '') || 'dashboard';
|
||||
App.navigateTo(App.sections.includes(initialSection) ? initialSection : 'dashboard');
|
||||
App.startPeriodicRefresh();
|
||||
@@ -841,11 +846,20 @@ const App = {
|
||||
const selectEl = document.getElementById('execute-agent-select');
|
||||
const agentName = selectEl?.selectedOptions[0]?.text || 'Agente';
|
||||
|
||||
let contextFiles = null;
|
||||
const dropzone = App._executeDropzone;
|
||||
if (dropzone && dropzone.getFiles().length > 0) {
|
||||
Toast.info('Fazendo upload dos arquivos...');
|
||||
const uploadResult = await API.uploads.send(dropzone.getFiles());
|
||||
contextFiles = uploadResult.files;
|
||||
}
|
||||
|
||||
Terminal.disableChat();
|
||||
App._lastAgentName = agentName;
|
||||
|
||||
await API.agents.execute(agentId, task, instructions);
|
||||
await API.agents.execute(agentId, task, instructions, contextFiles);
|
||||
|
||||
if (dropzone) dropzone.reset();
|
||||
Modal.close('execute-modal-overlay');
|
||||
App.navigateTo('terminal');
|
||||
Toast.info('Execução iniciada');
|
||||
|
||||
@@ -335,6 +335,8 @@ const AgentsUI = {
|
||||
const instructionsEl = document.getElementById('execute-instructions');
|
||||
if (instructionsEl) instructionsEl.value = '';
|
||||
|
||||
if (App._executeDropzone) App._executeDropzone.reset();
|
||||
|
||||
AgentsUI._loadSavedTasks();
|
||||
|
||||
Modal.open('execute-modal-overlay');
|
||||
|
||||
@@ -188,6 +188,10 @@ const HistoryUI = {
|
||||
Modal.open('execution-detail-modal-overlay');
|
||||
Utils.refreshIcons(content);
|
||||
|
||||
content.querySelector('[data-action="download-result-md"]')?.addEventListener('click', () => {
|
||||
HistoryUI._downloadResultMd(exec);
|
||||
});
|
||||
|
||||
content.querySelectorAll('.pipeline-step-prompt-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const stepCard = btn.closest('.pipeline-step-detail');
|
||||
@@ -217,6 +221,12 @@ const HistoryUI = {
|
||||
: '';
|
||||
|
||||
return `
|
||||
${exec.result ? `
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-action="download-result-md" type="button">
|
||||
<i data-lucide="download"></i> Download .md
|
||||
</button>
|
||||
</div>` : ''}
|
||||
<div class="execution-detail-meta">
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Agente</span>
|
||||
@@ -326,7 +336,14 @@ const HistoryUI = {
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const hasResults = steps.some(s => s.result);
|
||||
return `
|
||||
${hasResults ? `
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-ghost btn-sm" data-action="download-result-md" type="button">
|
||||
<i data-lucide="download"></i> Download .md
|
||||
</button>
|
||||
</div>` : ''}
|
||||
<div class="execution-detail-meta">
|
||||
<div class="execution-detail-row">
|
||||
<span class="execution-detail-label">Pipeline</span>
|
||||
@@ -374,6 +391,36 @@ const HistoryUI = {
|
||||
`;
|
||||
},
|
||||
|
||||
_downloadResultMd(exec) {
|
||||
let md = '';
|
||||
const name = exec.type === 'pipeline'
|
||||
? (exec.pipelineName || 'Pipeline')
|
||||
: (exec.agentName || 'Agente');
|
||||
|
||||
if (exec.type === 'pipeline') {
|
||||
md += `# ${name}\n\n`;
|
||||
const steps = Array.isArray(exec.steps) ? exec.steps : [];
|
||||
steps.forEach((step, i) => {
|
||||
md += `## Passo ${i + 1} — ${step.agentName || 'Agente'}\n\n`;
|
||||
if (step.result) md += `${step.result}\n\n`;
|
||||
});
|
||||
} else {
|
||||
md += exec.result || '';
|
||||
}
|
||||
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
const filename = `${slug}-${new Date(exec.startedAt || Date.now()).toISOString().slice(0, 10)}.md`;
|
||||
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
Toast.success('Download iniciado');
|
||||
},
|
||||
|
||||
async retryExecution(id) {
|
||||
try {
|
||||
await API.executions.retry(id);
|
||||
|
||||
@@ -370,6 +370,8 @@ const PipelinesUI = {
|
||||
const workdirEl = document.getElementById('pipeline-execute-workdir');
|
||||
if (workdirEl) workdirEl.value = '';
|
||||
|
||||
if (App._pipelineDropzone) App._pipelineDropzone.reset();
|
||||
|
||||
Modal.open('pipeline-execute-modal-overlay');
|
||||
},
|
||||
|
||||
@@ -384,7 +386,16 @@ const PipelinesUI = {
|
||||
}
|
||||
|
||||
try {
|
||||
await API.pipelines.execute(pipelineId, input, workingDirectory);
|
||||
let contextFiles = null;
|
||||
const dropzone = App._pipelineDropzone;
|
||||
if (dropzone && dropzone.getFiles().length > 0) {
|
||||
Toast.info('Fazendo upload dos arquivos...');
|
||||
const uploadResult = await API.uploads.send(dropzone.getFiles());
|
||||
contextFiles = uploadResult.files;
|
||||
}
|
||||
|
||||
await API.pipelines.execute(pipelineId, input, workingDirectory, contextFiles);
|
||||
if (dropzone) dropzone.reset();
|
||||
Modal.close('pipeline-execute-modal-overlay');
|
||||
App.navigateTo('terminal');
|
||||
Toast.info('Pipeline iniciado');
|
||||
|
||||
@@ -35,6 +35,71 @@ const Utils = {
|
||||
if (pending.length === 0) return;
|
||||
lucide.createIcons();
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
initDropzone(dropzoneId, fileInputId, fileListId) {
|
||||
const zone = document.getElementById(dropzoneId);
|
||||
const input = document.getElementById(fileInputId);
|
||||
const list = document.getElementById(fileListId);
|
||||
if (!zone || !input || !list) return null;
|
||||
|
||||
const state = { files: [] };
|
||||
|
||||
function render() {
|
||||
list.innerHTML = state.files.map((f, i) => `
|
||||
<li class="dropzone-file">
|
||||
<span class="dropzone-file-name">${Utils.escapeHtml(f.name)}</span>
|
||||
<span class="dropzone-file-size">${Utils.formatFileSize(f.size)}</span>
|
||||
<button type="button" class="dropzone-file-remove" data-index="${i}" title="Remover">×</button>
|
||||
</li>
|
||||
`).join('');
|
||||
|
||||
const content = zone.querySelector('.dropzone-content');
|
||||
if (content) content.style.display = state.files.length > 0 ? 'none' : '';
|
||||
}
|
||||
|
||||
function addFiles(fileList) {
|
||||
for (const f of fileList) {
|
||||
if (state.files.length >= 20) break;
|
||||
if (f.size > 10 * 1024 * 1024) continue;
|
||||
const dupe = state.files.some(x => x.name === f.name && x.size === f.size);
|
||||
if (!dupe) state.files.push(f);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
zone.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.dropzone-file-remove')) {
|
||||
const idx = parseInt(e.target.closest('.dropzone-file-remove').dataset.index);
|
||||
state.files.splice(idx, 1);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (!e.target.closest('.dropzone-file')) input.click();
|
||||
});
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files.length > 0) addFiles(input.files);
|
||||
input.value = '';
|
||||
});
|
||||
|
||||
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('dragover'); });
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
zone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
|
||||
});
|
||||
|
||||
state.reset = () => { state.files = []; render(); };
|
||||
state.getFiles = () => state.files;
|
||||
return state;
|
||||
},
|
||||
};
|
||||
|
||||
window.Utils = Utils;
|
||||
|
||||
Reference in New Issue
Block a user