mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à 9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle avant l'exécution, attente du rechargement), accès mémoire et web réglables, interrupteur maître pour tout suspendre, bouton « tester maintenant ». Le planificateur est une goroutine, un tic par minute, dans le process qui détient la conversation et parle au moteur. Une seule tâche part par tic : de toute façon une seule inférence tourne à la fois, et étaler les départs évite qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement n'est pas un échec — la tâche repasse au tic suivant. Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et le jette, ne gardant que son texte final comme compte-rendu (borné à 4000 caractères, réinjecté au passage suivant pour la continuité). Deux adaptations, parce que loki n'est pas l'amont : — Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y aurait déposé ses fichiers, et en aurait changé en cours de route si l'utilisateur changeait de discussion — pour disparaître avec elle à la suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages continuent de suivre la discussion de l'utilisateur. — Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le mode code reste off : rôles, critères et passe de vérification n'ont pas de sens sans personne en face. Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe le modèle : « génération en cours » sur un fil vide et immobile n'expliquait rien. Interface : section repliable dans les réglages (liste, état, prochain passage, pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau « dernier résultat » en markdown. Vérifié dans un vrai navigateur — création, rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître, suppression — sans une seule erreur JS.
This commit is contained in:
25 files changed
+2441
-15
No files matched your search
@@ -451,7 +451,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -0,0 +1,345 @@
|
||||
// ─── 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_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_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 = '<div class="muted" style="font-size:12px">(aucune tâche)</div>';
|
||||
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||'') : '');
|
||||
|
||||
// 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 || '';
|
||||
}
|
||||
|
||||
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 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, 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)';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user