// ─── Tâches planifiées ─────────────────────────────────────────────────────── // Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté. // // L'IA exécute une consigne toute seule sur une fréquence réglable (intervalle // simple ou cron). Chaque tâche tourne ISOLÉE des discussions : elle livre son // résultat via les propres outils de l'IA (mail via MCP, shell…), dans son // dossier de travail à elle. L'interrupteur maître (« suspendre toutes les // tâches ») permet de tout figer d'un coup pour discuter tranquille. Réutilise le // gabarit visuel des serveurs MCP (mcp-row) et celui de l'éditeur de preset (pe-*). let tasksList = []; let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé) let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune) let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale) let TASK_PROJECTS = []; // projets disponibles (sélecteur « projet visé ») let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche) let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche) let tasksPollTimer = null; async function loadTasks(){ renderTasks(await jget('/api/tasks')); } // Tant qu'une tâche tourne, on rafraîchit la liste toutes les 2 s (l'exécution est // isolée du fil : sans sondage, on ne verrait ni son démarrage ni sa fin). On // arrête le sondage dès qu'aucune tâche ne tourne — pas de poll permanent. function ensureTasksPoll(){ if(TASK_RUNNING && !tasksPollTimer){ tasksPollTimer = setInterval(loadTasks, 2000); } else if(!TASK_RUNNING && tasksPollTimer){ clearInterval(tasksPollTimer); tasksPollTimer = null; } } function renderTasks(r){ tasksList = (r && r.tasks) || []; const paused = !!(r && r.paused); const agentOn = !!(r && r.agent); TASK_PRESETS = (r && r.presets) || []; TASK_PROJECTS = (r && r.projects) || []; TASK_MEM_ON = !r || r.mem_on !== false; TASK_WEB_ON = !r || r.web_on !== false; TASK_RUNNING = (r && r.running_id) || ''; ensureTasksPoll(); const pc = document.getElementById('tasks-pause-toggle'); if(pc) pc.checked = paused; // Avertissement : sans mode agent, une tâche n'a aucun outil pour agir. const warn = document.getElementById('tasks-agent-warn'); if(warn) warn.style.display = (tasksList.length && !agentOn) ? '' : 'none'; // Pastille : « en cours » l'emporte, sinon le nombre de tâches actives. const nActive = tasksList.filter(t=>t.enabled).length; if(TASK_RUNNING) setBadge('tasks-badge', 'warn', 'en cours'); else setBadge('tasks-badge', nActive ? true : null, nActive ? nActive+' active'+(nActive>1?'s':'') : ''); const list = document.getElementById('tasks-list'); list.textContent = ''; if(!tasksList.length){ list.innerHTML = '
(aucune tâche)
'; return; } tasksList.forEach(t=>{ const running = (t.id === TASK_RUNNING); const row = document.createElement('div'); row.className = 'mcp-row'+(t.enabled?'':' off')+(running?' task-running':''); const dot = document.createElement('span'); dot.className = 'mcp-dot'; if(running){ // Point animé pendant l'exécution. dot.classList.add('mcp-dot-ok','task-dot-run'); } else if(!t.enabled){ dot.classList.add('mcp-dot-off'); } else if(t.last_run && !t.last_ok){ dot.classList.add('mcp-dot-err'); } else if(t.last_run && t.last_ok){ dot.classList.add('mcp-dot-ok'); } else { dot.classList.add('mcp-dot-off'); } dot.title = running ? 'en cours d’exécution' : (!t.enabled ? 'désactivée' : (t.last_run ? (t.last_ok ? 'dernier passage : ok' : 'dernier passage : échec') : 'jamais exécutée')); const info = document.createElement('div'); info.className = 'mcp-info'; const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = t.name; nm.title = t.name; const meta = document.createElement('div'); meta.className = 'mcp-meta'; if(running){ const rn = document.createElement('span'); rn.className = 'task-run-badge'; rn.textContent = 'en cours…'; meta.appendChild(rn); } else { const fr = document.createElement('span'); fr.className = 'mcp-tag'; fr.textContent = scheduleLabel(t.schedule); meta.appendChild(fr); if(t.preset){ const pr = document.createElement('span'); pr.className = 'mcp-tag mcp-tag-preset'; const pn = (TASK_PRESETS.find(p=>p.id===t.preset)||{}).name || t.preset; pr.textContent = pn; pr.title = 'preset : '+pn; meta.appendChild(pr); } if(t.enabled && t.next_run){ const nx = document.createElement('span'); nx.className = 'mcp-tools'; nx.textContent = '→ '+taskWhen(t.next_run); nx.title = 'prochaine exécution : '+taskWhen(t.next_run); meta.appendChild(nx); } if(t.last_run && !t.last_ok && t.last_error){ const er = document.createElement('span'); er.className = 'mcp-err'; er.textContent = 'échec'; er.title = t.last_error; meta.appendChild(er); } } info.appendChild(nm); info.appendChild(meta); row.appendChild(dot); row.appendChild(info); if(running){ // Bouton arrêter : passe par /api/chat/stop (conv.Stop annule le contexte de // la tâche, cf. RunAutonomous). const st = document.createElement('button'); st.className = 'task-stop-btn'; st.textContent = 'arrêter'; st.onclick = (e)=>{ e.stopPropagation(); stopRunningTask(); }; row.appendChild(st); } else { const sw = document.createElement('label'); sw.className = 'switch mcp-switch'; sw.onclick = (e)=>e.stopPropagation(); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!t.enabled; cb.onchange = ()=>toggleTask(t.id, cb.checked); const sl = document.createElement('span'); sl.className = 'slider'; sw.appendChild(cb); sw.appendChild(sl); const edit = document.createElement('button'); edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎'; edit.onclick = (e)=>{ e.stopPropagation(); openTask(t.id); }; row.appendChild(sw); row.appendChild(edit); } row.onclick = ()=> running ? null : openTask(t.id); list.appendChild(row); }); } async function stopRunningTask(){ await jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('arrêt de la tâche…'); setTimeout(loadTasks, 800); } // taskWhen met en forme un epoch ms dans le FUSEAU du navigateur (jj/mm HH:MM) — // le serveur envoie l'horodatage brut, sinon il serait figé en UTC (décalage). // Nom distinct du fmtWhen du panneau Fichiers, qui prend des SECONDES et rend // autre chose : deux vues, deux formats, aucune raison de les confondre. function taskWhen(ms){ if(!ms) return ''; const d = new Date(ms); const p = n => String(n).padStart(2,'0'); return p(d.getDate())+'/'+p(d.getMonth()+1)+' '+p(d.getHours())+':'+p(d.getMinutes()); } // (fmtDur vit dans 09-stream.js : même unité, même rendu — on ne le redouble pas.) // scheduleLabel rend une fréquence lisible pour la pastille de la liste. function scheduleLabel(s){ s = (s||'').trim(); const m = /^@every\s+(.+)$/i.exec(s); if(m){ const d = m[1].trim(); const mm = /^(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?$/.exec(d); if(mm){ let n = parseInt(mm[1], 10), u = mm[2]; const tm = mm[3]; // Anciennes tâches « jours » stockées en heures (multiple de 24). if(u === 'h' && n % 24 === 0 && n >= 24){ n = n/24; u = 'd'; } const ul = {m:'min', h:'h', d:'jour'}[u] || u; let lbl = 'toutes les '+n+' '+ul+((u==='d'&&n>1)?'s':''); if(u==='d' && tm) lbl += ' à '+tm; return lbl; } return 'toutes les '+d; } return 'cron : '+s; } async function toggleTask(id, on){ await jpost('/api/tasks/toggle', {id, on}); await loadTasks(); toast(on ? 'tâche activée' : 'tâche désactivée'); } async function toggleTasksPause(){ const on = document.getElementById('tasks-pause-toggle').checked; await jpost('/api/tasks/pause', {on}); toast(on ? 'tâches suspendues' : 'tâches réactivées'); loadTasks(); } // Ouvre la modale d'ajout (id='') ou d'édition (id existant). function openTask(id){ taskEditing = id || ''; const t = id ? tasksList.find(x=>x.id===id) : null; document.getElementById('task-modal-title').textContent = t ? 'Éditer « '+t.name+' »' : 'Nouvelle tâche'; document.getElementById('task-name').value = t ? t.name : ''; document.getElementById('task-prompt').value = t ? t.prompt : ''; document.getElementById('task-enabled').checked = t ? !!t.enabled : true; // Accès mémoire/web : sur une tâche existante on lit son réglage (no_mem/no_web, // stockés en négation) ; sur une nouvelle on suit l'état global de la machine. document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON; document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON; fillPresetSelect(t ? (t.preset||'') : ''); // Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une // tâche pour le chantier qu'on a sous les yeux. fillTaskProjectSelect(t ? (t.project||'') : (typeof PROJ_ACTIVE!=='undefined' ? PROJ_ACTIVE : '')); // Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle : // « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours). const sched = t ? (t.schedule||'') : '@every 2h'; const m = /^@every\s+(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?\s*$/i.exec(sched); document.getElementById('task-time').value = '09:00'; if(m || !t){ setTaskFreqMode('interval'); document.getElementById('task-interval-n').value = m ? m[1] : '2'; document.getElementById('task-interval-unit').value = m ? m[2] : 'h'; if(m && m[3]){ const tm = m[3]; document.getElementById('task-time').value = tm.length===4 ? '0'+tm : tm; } document.getElementById('task-cron').value = ''; } else { setTaskFreqMode('cron'); document.getElementById('task-cron').value = sched; } taskUnitUI(); document.getElementById('task-modal-status').textContent = ''; renderTaskResult(t); document.getElementById('task-del').style.display = t ? '' : 'none'; document.getElementById('task-run').style.display = t ? '' : 'none'; showModal('task-modal'); } // renderTaskResult remplit le panneau « Dernier résultat » avec le compte-rendu // COMPLET (défilable), l'état (ok / échec / en cours) et l'horodatage. Masqué pour // une tâche neuve ou jamais exécutée. function renderTaskResult(t){ const block = document.getElementById('task-result-block'); const box = document.getElementById('task-result'); const when = document.getElementById('task-result-when'); box.className = 'task-report'; if(!t || (!t.last_run && t.id !== TASK_RUNNING)){ block.style.display = 'none'; return; } block.style.display = ''; if(t.id === TASK_RUNNING){ when.textContent = '(en cours)'; box.classList.add('report-run'); box.textContent = 'exécution en cours…'; return; } const parts = []; if(t.last_run) parts.push(new Date(t.last_run).toLocaleString()); if(t.last_dur_ms) parts.push('en '+fmtDur(t.last_dur_ms)); when.textContent = parts.length ? '· '+parts.join(' · ') : ''; if(!t.last_ok){ box.classList.add('report-err'); box.textContent = 'Échec : '+(t.last_error||'erreur inconnue'); return; } const rep = (t.last_report||'').trim(); if(rep){ box.classList.add('md-report'); box.innerHTML = md(rep); } else { box.classList.add('report-empty'); box.textContent = '(terminée sans texte de compte-rendu — l\'action a été faite via les outils)'; } } function closeTask(){ hideModal('task-modal'); taskEditing = null; } // fillPresetSelect peuple le sélecteur de preset. Première option = « preset // actif » (vide) : la tâche utilise le modèle en cours au moment de l'exécution. function fillPresetSelect(selected){ const sel = document.getElementById('task-preset'); sel.textContent = ''; const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = 'preset actif'; sel.appendChild(opt0); TASK_PRESETS.forEach(p=>{ const o = document.createElement('option'); o.value = p.id; o.textContent = p.name + (p.active ? ' (actif)' : ''); sel.appendChild(o); }); sel.value = selected || ''; } // fillTaskProjectSelect peuple le sélecteur « projet visé ». Pas d'option vide : // une tâche appartient toujours à un projet — le serveur retombe sur le projet // actif si rien n'est envoyé, autant le montrer explicitement. function fillTaskProjectSelect(selected){ const sel = document.getElementById('task-project'); if(!sel) return; sel.textContent = ''; TASK_PROJECTS.forEach(p=>{ const o = document.createElement('option'); o.value = p.slug; o.textContent = p.name; sel.appendChild(o); }); if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected; } function setTaskFreqMode(mode){ const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]'); if(r) r.checked = true; taskFreqUI(mode); } function taskFreqUI(mode){ document.getElementById('task-interval-fields').style.display = mode==='interval' ? '' : 'none'; document.getElementById('task-cron-fields').style.display = mode==='cron' ? '' : 'none'; taskUnitUI(); } // taskUnitUI : la ligne « À (heure) » n'apparaît que pour un intervalle en JOURS // (« tous les N jours à HH:MM »). Sans intérêt pour des minutes/heures. function taskUnitUI(){ const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value; const unit = document.getElementById('task-interval-unit').value; document.getElementById('task-time-row').style.display = (mode==='interval' && unit==='d') ? '' : 'none'; } // buildSchedule sérialise l'état du formulaire de fréquence en chaîne serveur. function buildSchedule(){ const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value || 'interval'; if(mode==='cron') return document.getElementById('task-cron').value.trim(); const n = parseInt(document.getElementById('task-interval-n').value, 10); const u = document.getElementById('task-interval-unit').value; if(!n || n < 1) return ''; // Jours : on ancre à une heure de la journée (« @every Nd@HH:MM »). if(u === 'd'){ const tm = document.getElementById('task-time').value || '09:00'; return '@every '+n+'d@'+tm; } return '@every '+n+u; } async function saveTask(){ const name = document.getElementById('task-name').value.trim(); const prompt = document.getElementById('task-prompt').value.trim(); const schedule = buildSchedule(); const enabled = document.getElementById('task-enabled').checked; const preset = document.getElementById('task-preset').value; const project = (document.getElementById('task-project')||{}).value || ''; const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || ''; const no_mem = !document.getElementById('task-mem').checked; const no_web = !document.getElementById('task-web').checked; const st = document.getElementById('task-modal-status'); if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; } if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; } const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, project, tz, no_mem, no_web}); if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; } closeTask(); await loadTasks(); toast('tâche enregistrée'); } async function deleteTaskUI(){ if(!taskEditing) return; const t = tasksList.find(x=>x.id===taskEditing); if(!await askConfirm('Supprimer la tâche « '+(t?t.name:'')+' » ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return; await jpost('/api/tasks/delete', {id: taskEditing}); closeTask(); await loadTasks(); toast('tâche supprimée'); } async function runTaskNow(){ if(!taskEditing) return; const st = document.getElementById('task-modal-status'); const r = await jpost('/api/tasks/run', {id: taskEditing}); if(r && r.ok){ st.textContent = 'lancée, suis son avancement dans la liste'; st.style.color = ''; closeTask(); // Rafraîchit tout de suite : loadTasks détecte running_id et lance le sondage // qui suivra l'exécution jusqu'à la fin. setTimeout(loadTasks, 400); } else { st.textContent = (r && r.error) || 'impossible de lancer maintenant'; st.style.color = 'var(--err)'; } }