1/** 2 * Roxima AI Planner - Module Autonome 3 * Gère le flux d'intégration semi-automatique avec le LLM. 4 */ 5 6import { objectifsStore } from '../../shared/objectifs_store.js'; 7import { i18n } from '../../shared/i18n.js'; 8import { utils } from '../../shared/utils.js'; 9 10const PROMPT_HEADER = `Utilise tout le contexte que tu possèdes déjà sur moi et sur ce projet. Le contexte Roxima ci-dessous sert uniquement à te donner mon état opérationnel actuel, mes tâches et mes disponibilités. Détermine les actions réellement prioritaires, pas uniquement les tâches les plus proches dans la liste. 11 12Retourne uniquement un objet JSON brut et valide (sans texte d'explication) respectant précisément ce schéma : 13{ 14 "version": "1.0", 15 "project_id": "identifiant transmis par Roxima", 16 "plan_title": "Priorités du jour", 17 "summary": "courte explication du plan", 18 "tasks": [ 19 { 20 "action": "create" | "update", 21 "task_id": null (pour create) ou "identifiant exact fourni par Roxima" (pour update), 22 "title": "Titre concret de la tâche", 23 "description": "Résultat attendu", 24 "priority": "high" | "medium" | "low", 25 "duration_minutes": 5 à 180, 26 "due_date": null ou "YYYY-MM-DD", 27 "today": true | false, 28 "order": 1, 2, 3 (ordre séquentiel), 29 "why": "Pourquoi cette tâche est prioritaire", 30 "first_micro_action": "Première action précise et immédiatement réalisable", 31 "preferred_period": "morning" | "afternoon" | "evening" | "anytime" 32 } 33 ] 34} 35 36Limiter le plan à 3 tâches du jour (today = true) au maximum.`; 37 38/** 39 * Calcule les créneaux libres du jour en intersectant la plage de travail 40 * et les événements occupés (Google Calendar + Local Roxima). 41 */ 42function calculateFreeSlots(busyEvents) { 43 const today = new Date(); 44 45 // Lecture des heures de réveil/coucher dans store_pomodoro 46 const pomoSettings = JSON.parse(localStorage.getItem('rox_pomo_settings') || '{}'); 47 const wakeStr = pomoSettings.sleepWakeup || "07:00"; 48 const bedStr = pomoSettings.sleepBedtime || "23:00"; 49 50 const [wakeH, wakeM] = wakeStr.split(':').map(Number); 51 const [bedH, bedM] = bedStr.split(':').map(Number); 52 53 const workStart = new Date(today); 54 workStart.setHours(wakeH, wakeM, 0, 0); 55 56 const workEnd = new Date(today); 57 workEnd.setHours(bedH, bedM, 0, 0); 58 if (workEnd <= workStart) { 59 workEnd.setDate(workEnd.getDate() + 1); 60 } 61 62 // Filtrer et tronquer les événements occupés pour aujourd'hui 63 const intervals = []; 64 busyEvents.forEach(e => { 65 const start = new Date(Math.max(e.start.getTime(), workStart.getTime())); 66 const end = new Date(Math.min(e.end.getTime(), workEnd.getTime())); 67 if (end > start) { 68 intervals.push({ start, end }); 69 } 70 }); 71 72 // Fusionner les intervalles occupés qui se chevauchent 73 const busy = []; 74 if (intervals.length > 0) { 75 intervals.sort((a, b) => a.start - b.start); 76 let current = intervals[0]; 77 for (let i = 1; i < intervals.length; i++) { 78 const next = intervals[i]; 79 if (next.start <= current.end) { 80 current.end = new Date(Math.max(current.end.getTime(), next.end.getTime())); 81 } else { 82 busy.push(current); 83 current = next; 84 } 85 } 86 busy.push(current); 87 } 88 89 // Déduire les créneaux libres 90 const free = []; 91 let currentMs = workStart.getTime(); 92 93 for (const b of busy) { 94 const bStartMs = b.start.getTime(); 95 const bEndMs = b.end.getTime(); 96 97 if (bStartMs > currentMs) { 98 free.push({ 99 start: new Date(currentMs), 100 end: new Date(bStartMs) 101 }); 102 } 103 currentMs = Math.max(currentMs, bEndMs); 104 } 105 106 if (currentMs < workEnd.getTime()) { 107 free.push({ 108 start: new Date(currentMs), 109 end: new Date(workEnd.getTime()) 110 }); 111 } 112 113 return free; 114} 115 116/** 117 * Soustrait un intervalle d'une liste de créneaux libres (splitting). 118 */ 119function subtractIntervalFromFreeSlots(freeSlots, start, end) { 120 const result = []; 121 const sTime = start.getTime(); 122 const eTime = end.getTime(); 123 124 for (const slot of freeSlots) { 125 const slotStart = slot.start.getTime(); 126 const slotEnd = slot.end.getTime(); 127 128 if (sTime >= slotEnd || eTime <= slotStart) { 129 // Aucun chevauchement 130 result.push(slot); 131 } else { 132 // Chevauchement 133 if (sTime > slotStart) { 134 result.push({ start: new Date(slotStart), end: new Date(sTime) }); 135 } 136 if (eTime < slotEnd) { 137 result.push({ start: new Date(eTime), end: new Date(slotEnd) }); 138 } 139 } 140 } 141 return result; 142} 143 144/** 145 * Planifie localement et de manière déterministe les tâches IA 146 * dans les créneaux disponibles. 147 */ 148function planCalendarTasks(tasks, freeSlots) { 149 const today = new Date(); 150 const pomoSettings = JSON.parse(localStorage.getItem('rox_pomo_settings') || '{}'); 151 const wakeStr = pomoSettings.sleepWakeup || "07:00"; 152 const bedStr = pomoSettings.sleepBedtime || "23:00"; 153 154 const [wakeH, wakeM] = wakeStr.split(':').map(Number); 155 const [bedH, bedM] = bedStr.split(':').map(Number); 156 157 const workStart = new Date(today); 158 workStart.setHours(wakeH, wakeM, 0, 0); 159 160 const workEnd = new Date(today); 161 workEnd.setHours(bedH, bedM, 0, 0); 162 if (workEnd <= workStart) { 163 workEnd.setDate(workEnd.getDate() + 1); 164 } 165 166 const morningThreshold = new Date(today); 167 morningThreshold.setHours(12, 0, 0, 0); 168 169 const afternoonThreshold = new Date(today); 170 afternoonThreshold.setHours(18, 0, 0, 0); 171 172 let activeFreeSlots = freeSlots.map(s => ({ start: new Date(s.start), end: new Date(s.end) })); 173 const plannedEvents = []; 174 175 // Trier les tâches par ordre suggéré 176 const sortedTasks = [...tasks].sort((a, b) => (a.order || 0) - (b.order || 0)); 177 178 for (const task of sortedTasks) { 179 if (!task.today) continue; // Uniquement pour les tâches du jour 180 181 const durationMs = task.duration_minutes * 60 * 1000; 182 183 // Déterminer les limites de la période demandée 184 let pStart = new Date(workStart); 185 let pEnd = new Date(workEnd); 186 187 if (task.preferred_period === 'morning') { 188 pEnd = new Date(morningThreshold); 189 } else if (task.preferred_period === 'afternoon') { 190 pStart = new Date(morningThreshold); 191 pEnd = new Date(afternoonThreshold); 192 } else if (task.preferred_period === 'evening') { 193 pStart = new Date(afternoonThreshold); 194 } 195 196 pStart = new Date(Math.max(pStart.getTime(), workStart.getTime())); 197 pEnd = new Date(Math.min(pEnd.getTime(), workEnd.getTime())); 198 if (pEnd <= pStart) { 199 pStart = new Date(workStart); 200 pEnd = new Date(workEnd); 201 } 202 203 // Essayer d'insérer dans la période souhaitée 204 let found = findSlotAndPlace(pStart, pEnd, durationMs); 205 206 // Si non trouvé, repli sur toute la journée 207 if (!found && task.preferred_period !== 'anytime') { 208 found = findSlotAndPlace(workStart, workEnd, durationMs); 209 } 210 211 if (found) { 212 plannedEvents.push({ 213 task, 214 start: found.start, 215 end: found.end, 216 preferred_period: task.preferred_period 217 }); 218 // Déduire le créneau occupé 219 activeFreeSlots = subtractIntervalFromFreeSlots(activeFreeSlots, found.start, found.end); 220 } else { 221 plannedEvents.push({ 222 task, 223 start: null, 224 end: null, 225 preferred_period: task.preferred_period, 226 unplanned: true 227 }); 228 } 229 } 230 231 return plannedEvents; 232 233 // Helper interne de positionnement dans les créneaux 234 function findSlotAndPlace(startBound, endBound, duration) { 235 for (const slot of activeFreeSlots) { 236 const s = new Date(Math.max(slot.start.getTime(), startBound.getTime())); 237 const e = new Date(Math.min(slot.end.getTime(), endBound.getTime())); 238 239 if (e.getTime() - s.getTime() >= duration) { 240 // Aligner à la tranche de 15 min suivante 241 let tStart = new Date(s); 242 const remainder = tStart.getMinutes() % 15; 243 if (remainder > 0) { 244 tStart.setMinutes(tStart.getMinutes() + (15
244- remainder), 0, 0); 245 } else { 246 tStart.setSeconds(0, 0); 247 } 248 249 // Si l'alignement déborde ou réduit le temps utile, utiliser le début du créneau brut 250 if (tStart.getTime() + duration > e.getTime()) { 251 tStart = new Date(s); 252 } 253 254 return { 255 start: tStart, 256 end: new Date(tStart.getTime() + duration) 257 }; 258 } 259 } 260 return null; 261 } 262} 263 264/** 265 * Construit dynamiquement le prompt avec toutes les données contextuelles. 266 */ 267function buildProjectContext(projectId, ctx) { 268 const store = ctx.store; 269 const page = objectifsStore.getPage(projectId); 270 if (!page) return ''; 271 272 const path = objectifsStore.getPagePath(projectId); 273 const subProjects = objectifsStore.getPages().filter(p => p.parentId === projectId); 274 const allBlocks = objectifsStore.getBlocks(projectId); 275 276 // Extraction des descriptions/contextes 277 const details = allBlocks.filter(b => b.type !== 'todo' && b.text).map(b => `- ${b.text}`).join('\n'); 278 279 // Tâches 280 const tasks = allBlocks.filter(b => b.type === 'todo'); 281 const subProjectTasks = []; 282 subProjects.forEach(sp => { 283 const spBlocks = objectifsStore.getBlocks(sp.id).filter(b => b.type === 'todo'); 284 spBlocks.forEach(t => { 285 subProjectTasks.push({ ...t, projectName: sp.title }); 286 }); 287 }); 288 289 const formatTask = (t) => { 290 return `- ID: "${t.id}" | Titre: "${t.text}" | Statut: ${t.checked ? 'terminée' : 'à faire'} | Priorité: ${t.priority || 'non définie'} | Durée: ${t.duration_minutes ? t.duration_minutes + ' min' : 'non estimée'} | Ãchéance: ${t.due_date || 'aucune'}${t.projectName ? ` | Sous-projet: "${t.projectName}"` : ''}`; 291 }; 292 293 // Notion Tasks 294 const notionTasks = [...tasks, ...subProjectTasks].filter(t => t.isNotion || page.isNotion); 295 296 // Tâches bloquées 297 const blockedTasks = [...tasks, ...subProjectTasks].filter(t => { 298 const textLower = (t.text || '').toLowerCase(); 299 return t.blocked === true || textLower.includes('[bloqué]') || textLower.includes('[bloque]') || textLower.includes('bloque'); 300 }); 301 302 // Tâches terminées 303 const finishedTasks = [...tasks, ...subProjectTasks].filter(t => t.checked); 304 305 // Récupération Google Calendar du jour 306 const todayStr = new Date().toDateString(); 307 let googleEvents = []; 308 const cachedEventsRaw = localStorage.getItem('rox_google_events_cache'); 309 if (cachedEventsRaw) { 310 try { 311 googleEvents = JSON.parse(cachedEventsRaw).filter(e => { 312 const start = new Date(e.start); 313 return start.toDateString() === todayStr; 314 }); 315 } catch (e) { 316 console.error(e); 317 } 318 } 319 320 // Récupération Agenda Local 321 let localCalEvents = []; 322 const rawLocalEvents = store.get('rox_calendar_v1'); 323 if (Array.isArray(rawLocalEvents)) { 324 localCalEvents = rawLocalEvents.filter(e => { 325 const start = new Date(e.startISO || e.start); 326 return start.toDateString() === todayStr; 327 }); 328 } 329 330 const allBusyEvents = [ 331 ...googleEvents.map(e => ({ title: e.title, start: new Date(e.start), end: new Date(e.end) })), 332 ...localCalEvents.map(e => ({ title: e.title, start: new Date(e.startISO || e.start), end: new Date(e.endISO || e.end) })) 333 ]; 334 335 // Historique des sessions focus 336 let pomoHistory = []; 337 try { 338 pomoHistory = JSON.parse(localStorage.getItem('rox_pomo_history') || '[]'); 339 } catch(e) {} 340 const recentSessions = pomoHistory.filter(s => s.projectId === projectId).slice(-5); 341 342 // Créneaux libres 343 const freeSlots = calculateFreeSlots(allBusyEvents); 344 345 // Temps restant avant prochain événement 346 const now = new Date(); 347 let timeToNext = 'Aucun événement à venir aujourd\'hui'; 348 const upcoming = allBusyEvents.filter(e => e.start > now).sort((a, b) => a.start - b.start); 349 if (upcoming.length > 0) { 350 const diffMs = upcoming[0].start - now; 351 const diffMins = Math.round(diffMs / 60000); 352 timeToNext = `${diffMins} minutes avant "${upcoming[0].title}"`; 353 } 354 355 // Assemblage du prompt 356 let prompt = `${PROMPT_HEADER}\n\n`; 357 prompt += `--- CONTEXTE ROXIMA ---\n`; 358 prompt += `PROJET ACTUEL : "${path}"\n`; 359 prompt += `ID PROJET ACTUEL : "${projectId}"\n`; 360 if (page.isNotion || notionTasks.length > 0) { 361 prompt += `Intégration Notion : Ce projet comporte des tâches synchronisées avec Notion.\n`; 362 } 363 364 if (details) { 365 prompt += `\nDESCRIPTION / OBJECTIFS DU PROJET :\n${details}\n`; 366 } 367 368 if (subProjects.length > 0) { 369 prompt += `\nSOUS-PROJETS DE CE PROJET :\n${subProjects.map(sp => `- Sous-projet: "${sp.title}"`).join('\n')}\n`; 370 } 371 372 prompt += `\nTÃCHES ACTUELLES DU PROJET PRINCIPAL :\n`; 373 if (tasks.length > 0) { 374 prompt += tasks.map(formatTask).join('\n') + '\n'; 375 } else { 376 prompt += `- Aucune tâche enregistrée\n`; 377 } 378 379 if (subProjectTasks.length > 0) { 380 prompt += `\nTÃCHES DES SOUS-PROJETS :\n`; 381 prompt += subProjectTasks.map(formatTask).join('\n') + '\n'; 382 } 383 384 if (blockedTasks.length > 0) { 385 prompt += `\nTÃCHES ACTUELLEMENT BLOQUÃES :\n`; 386 prompt += blockedTasks.map(t => `- "${t.text}"`).join('\n') + '\n'; 387 } 388 389 if (finishedTasks.length > 0) { 390 prompt += `\nTÃCHES DÃJà TERMINÃES :\n`; 391 prompt += finishedTasks.map(t => `- "${t.text}"`).join('\n') + '\n'; 392 } 393 394 if (recentSessions.length > 0) { 395 prompt += `\nSESSIONS FOCUS RÃCENTES SUR CE PROJET :\n`; 396 prompt += recentSessions.map(s => { 397 const date = new Date(s.timestamp).toLocaleDateString('fr-FR'); 398 return `- Session focus de ${Math.round(s.duration / 60)} min le ${date} ${s.outcome ? `(Résultat: "${s.outcome}")` : ''}`; 399 }).join('\n') + '\n'; 400 } 401 402 prompt += `\nDISPONIBILITÃS ET ÃVÃNEMENTS DU LOGICIEL DU JOUR :\n`; 403 prompt += `Date : ${new Date().toLocaleDateString('fr-FR')}\n`; 404 if (allBusyEvents.length > 0) { 405 prompt += `Ãvénements dans l'agenda :\n`;
406 prompt += allBusyEvents.map(e => { 407 const startStr = e.start.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); 408 const endStr = e.end.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); 409 return `- ${startStr} - ${endStr} : "${e.title}"`; 410 }).join('\n') + '\n'; 411 } else { 412 prompt += `- Aucun événement prévu aujourd'hui dans l'agenda\n`; 413 } 414 415 prompt += `Créneaux de temps libre :\n`; 416 if (freeSlots.length > 0) { 417 prompt += freeSlots.map(s => { 418 const startStr = s.start.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); 419 const endStr = s.end.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); 420 return `- ${startStr} - ${endStr}`; 421 }).join('\n') + '\n'; 422 } else { 423 prompt += `- Aucun créneau de travail disponible aujourd'hui\n`; 424 } 425 426 prompt += `Temps disponible avant prochain événement : ${timeToNext}\n`; 427 428 return prompt; 429} 430 431/** 432 * Valide le JSON de réponse de l'IA. 433 */ 434function validateImportJson(rawText, activeProjectId) { 435 let jsonString = rawText.trim(); 436 437 // Retrait des blocs de code Markdown 438 const mdMatch = jsonString.match(/```json\s*([\s\S]*?)\s*```/) || jsonString.match(/```\s*([\s\S]*?)\s*```/); 439 if (mdMatch) { 440 jsonString = mdMatch[1].trim(); 441 } else { 442 const startIdx = jsonString.indexOf('{'); 443 const endIdx = jsonString.lastIndexOf('}'); 444 if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { 445 jsonString = jsonString.substring(startIdx, endIdx + 1); 446 } 447 } 448 449 let parsed = null; 450 try { 451 parsed = JSON.parse(jsonString); 452 } catch (e) { 453 return { 454 valid: false, 455 errorMsg: `Erreur syntaxique JSON. Vérifiez qu'aucun caractère de contrôle n'interfère.\nDétail : ${e.message}` 456 }; 457 } 458 459 if (parsed.version !== "1.0") { 460 return { 461 valid: false, 462 errorMsg: `Version "${parsed.version || 'inconnue'}" non supportée. Requis : "1.0".` 463 }; 464 } 465 466 if (parsed.project_id !== activeProjectId) { 467 return { 468 valid: false, 469 errorMsg: `Le JSON cible le projet "${parsed.project_id || 'inconnu'}", mais le projet actif actuel est "${activeProjectId}".` 470 }; 471 } 472 473 if (!Array.isArray(parsed.tasks)) { 474 return { 475 valid: false, 476 errorMsg: `Propriété "tasks" manquante ou invalide (doit être un tableau).` 477 }; 478 } 479 480 const tasks = parsed.tasks; 481 const taskIds = new Set(); 482 const todayCount = tasks.filter(t => t.today).length; 483 484 if (todayCount > 3) { 485 return { 486 valid: false, 487 errorMsg: `Le plan propose ${todayCount} tâches pour aujourd'hui. Roxima limite la planification à 3 tâches du jour maximum.` 488 }; 489 } 490 491 const existingBlocks = objectifsStore.getBlocks(activeProjectId); 492 const existingBlockIds = new Set(existingBlocks.map(b => b.id)); 493 494 for (let i = 0; i < tasks.length; i++) { 495 const t = tasks[i]; 496 const indexStr = `Tâche #${i+1} ("${t.title || 'sans titre'}")`; 497 498 if (!t.action || !['create', 'update'].includes(t.action)) { 499 return { 500 valid: false, 501 errorMsg: `${indexStr} : Action "${t.action || 'manquante'}" invalide. Valeurs autorisées: "create", "update".` 502 }; 503 } 504 505 if (t.action === 'update') { 506 if (!t.task_id) { 507 return { 508 valid: false, 509 errorMsg: `${indexStr} : "task_id" obligatoire pour une mise à jour ("update").` 510 }; 511 } 512 if (!existingBlockIds.has(t.task_id)) { 513 return { 514 valid: false, 515 errorMsg: `${indexStr} : L'identifiant task_id "${t.task_id}" n'a pas été trouvé dans ce projet.` 516 }; 517 } 518 } 519 520 if (t.task_id) { 521 if (taskIds.has(t.task_id)) { 522 return { 523 valid: false, 524 errorMsg: `${indexStr} : Identifiant "task_id" ("${t.task_id}") dupliqué.` 525 }; 526 } 527 taskIds.add(t.task_id); 528 } 529 530 if (!t.title || typeof t.title !== 'string') { 531 return { 532 valid: false, 533 errorMsg: `${indexStr} : Champ "title" obligatoire (doit être du texte).` 534 }; 535 } 536 537 if (t.priority && !['low', 'medium', 'high'].includes(t.priority)) { 538 return { 539 valid: false, 540 errorMsg: `${indexStr} : Priorité "${t.priority}" non valide. Requis : "low", "medium" ou "high".` 541 }; 542 } 543 544 if (t.preferred_period && !['morning', 'afternoon', 'evening', 'anytime'].includes(t.preferred_period)) { 545 return { 546 valid: false, 547 errorMsg: `${indexStr} : Période "${t.preferred_period}" non valide. Requis : "morning", "afternoon", "evening" ou "anytime".` 548 }; 549 } 550 551 if (t.duration_minutes !== undefined) { 552 const dur = parseInt(t.duration_minutes, 10); 553 if (isNaN(dur) || dur < 5 || dur > 180) { 554 return { 555 valid: false, 556 errorMsg: `${indexStr} : La durée estimée (${t.duration_minutes}) doit être un entier compris entre 5 et 180 minutes.` 557 }; 558 } 559 } else { 560 return { 561 valid: false, 562 errorMsg: `${indexStr} : Durée d'exécution "duration_minutes" obligatoire.` 563 }; 564 } 565 } 566 567 return { 568 valid: true, 569 data: parsed 570 }; 571} 572 573/** 574 * Point d'entrée principal pour l'ouverture du planificateur d'IA. 575 */ 576export function openAiPlanner(projectId, ctx, onAppliedCallback) { 577 // Récupération des événements du jour (Google et locaux) 578 const todayStr = new Date().toDateString(); 579 let googleEvents = [];
580 const cachedEventsRaw = localStorage.getItem('rox_google_events_cache'); 581 if (cachedEventsRaw) { 582 try { 583 googleEvents = JSON.parse(cachedEventsRaw).filter(e => { 584 const start = new Date(e.start); 585 return start.toDateString() === todayStr; 586 }); 587 } catch (e) { 588 console.error(e); 589 } 590 } 591 592 let localCalEvents = []; 593 const rawLocalEvents = ctx.store.get('rox_calendar_v1'); 594 if (Array.isArray(rawLocalEvents)) { 595 localCalEvents = rawLocalEvents.filter(e => { 596 const start = new Date(e.startISO || e.start); 597 return start.toDateString() === todayStr; 598 }); 599 } 600 601 const allBusyEvents = [ 602 ...googleEvents.map(e => ({ title: e.title, start: new Date(e.start), end: new Date(e.end) })), 603 ...localCalEvents.map(e => ({ title: e.title, start: new Date(e.startISO || e.start), end: new Date(e.endISO || e.end) })) 604 ]; 605 606 // Calcul des créneaux libres 607 const freeSlots = calculateFreeSlots(allBusyEvents); 608 609 // Génération du prompt 610 const generatedPrompt = buildProjectContext(projectId, ctx); 611 612 // Rendu initial de la modale 613 ctx.bus.emit('ui:modal:open', { 614 title: 'Planifier avec mon IA', 615 className: 'ai-planner-modal', 616 content: ` 617 <div class="ai-modal-stepper"> 618 <div class="ai-step active" id="step-tab-1"> 619 <span class="ai-step-num">1</span> 620 <span>Copier le prompt</span> 621 </div> 622 <div class="ai-step" id="step-tab-2"> 623 <span class="ai-step-num">2</span> 624 <span>Coller la réponse</span> 625 </div> 626 <div class="ai-step" id="step-tab-3"> 627 <span class="ai-step-num">3</span> 628 <span>Vérifier & Appliquer</span> 629 </div> 630 </div> 631 632 <div id="ai-planner-modal-wrap"> 633 <!-- ÃTAPE 1 --> 634 <div id="ai-step-1-content" class="ai-step-panel"> 635 <p style="margin-bottom:12px; font-size:0.9rem; line-height:1.4;"> 636 Générez et copiez le prompt contextuel ci-dessous, puis collez-le dans votre IA de prédilection (ChatGPT, Claude, Gemini...) pour obtenir une planification sur-mesure. 637 </p> 638 <div style="font-weight:700; font-size:0.85rem; margin-bottom: 4px;">Données locales partagées :</div> 639 <ul style="font-size:0.8rem; color:var(--text-muted); margin-left: 16px; margin-bottom: 12px; list-style-type: disc;"> 640 <li>Nom, description et sous-projets actifs</li> 641 <li>Tâches existantes (avec priorité et durée)</li> 642 <li>Sessions de focus récentes sur le projet</li> 643 <li>Disponibilités réelles issues de votre Google Agenda</li> 644 </ul> 645 <textarea id="ai-generated-prompt-box" class="ai-prompt-area" readonly>${generatedPrompt}</textarea> 646 647 <div class="ai-llm-buttons"> 648 <a href="#" data-url="https://chatgpt.com/" data-name="ChatGPT" class="ai-llm-btn"> 649 <span class="ai-llm-icon" style="color: #10a37f;"> 650 <svg viewBox='0 0 320 320' xmlns='http://www.w3.org/2000/svg' width="16" height="16" fill="currentColor"> 651 <path d='m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm
65114.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z'/> 652 </svg> 653 </span> 654 <span>Ouvrir ChatGPT</span> 655 </a> 656 <a href="#" data-url="https://claude.ai/" data-name="Claude" class="ai-llm-btn"> 657 <span class="ai-llm-icon" style="color: #d9775c;"> 658 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="16" height="16" fill="currentColor"> 659 <path d="m19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z"/> 660 </svg> 661 </span> 662 <span>Ouvrir Claude</span> 663 </a> 664 <a href="#" data-url="https://gemini.google.com/" data-name="Gemini" class="ai-llm-btn"> 665 <span class="ai-llm-icon"> 666 <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 65 65" width="16" height="16"> 667 <mask id="maskme" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="65" height="65"><path d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z" fill="#000"/><path d="M32.447 0c.68 0 1.273.465 1.439 1.125a38.904 38.904 0 001.999 5.905c2.152 5 5.105 9.376 8.854 13.125 3.751 3.75 8.126 6.703 13.125 8.855a38.98 38.98 0 005.906 1.999c.66.166 1.124.758 1.124 1.438 0 .68-.464 1.273-1.125 1.439a38.902 38.902 0 00-5.905 1.999c-5 2.152-9.375 5.105-13.125 8.854-3.749 3.751-6.702 8.126-8.854 13.125a38.973 38.973 0 00-2 5.906 1.485 1.485 0 01-1.438 1.124c-.68 0-1.272-.464-1.438-1.125a38.913 38.913 0 00-2-5.905c-2.151-5-5.103-9.375-8.854-13.125-3.75-3.749-8.125-6.702-13.125-8.854a38.973 38.973 0 00-5.905-2A1.485 1.485 0 010 32.448c0-.68.465-1.272 1.125-1.438a38.903 38.903 0 005.905-2c5-2.151 9.376-5.104 13.125-8.854 3.75-3.749 6.703-8.125 8.855-13.125a38.972 38.972 0 001.999-5.905A1.485 1.485 0 0132.447 0z" fill="url(#prefix__paint0_linear_2001_67)"/></mask><g mask="url(#maskme)"><g filter="url(#prefix__filter0_f_2001_67)"><path d="M-5.859 50.734c7.498 2.663 16.116-2.33 19.249-11.152 3.133-8.821-.406-18.131-7.904-20.794-7.498-2.663-16.116 2.33-19.25 11.151-3.132 8.822.407 18.132 7.905 20.795z" fill="#FFE432"/></g><g filter="url(#prefix__filter1_f_2001_67)"><path d="M27.433 21.649c10.3 0 18.651-8.535 18.651-19.062 0-10.528-8.35-19.062-18.651-19.062S8.78-7.94 8.78 2.587c0 10.527 8.35 19.062 18.652 19.062z" fill="#FC413D"/></g>
667<g filter="url(#prefix__filter2_f_2001_67)"><path d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z" fill="#00B95C"/></g><g filter="url(#prefix__filter3_f_2001_67)"><path d="M20.184 82.608c10.753-.525 18.918-12.244 18.237-26.174-.68-13.93-9.95-24.797-20.703-24.271C6.965 32.689-1.2 44.407-.519 58.337c.681 13.93 9.95 24.797 20.703 24.271z" fill="#00B95C"/></g><g filter="url(#prefix__filter4_f_2001_67)"><path d="M30.954 74.181c9.014-5.485 11.427-17.976 5.389-27.9-6.038-9.925-18.241-13.524-27.256-8.04-9.015 5.486-11.428 17.977-5.39 27.902 6.04 9.924 18.242 13.523 27.257 8.038z" fill="#00B95C"/></g><g filter="url(#prefix__filter5_f_2001_67)"><path d="M67.391 42.993c10.132 0 18.346-7.91 18.346-17.666 0-9.757-8.214-17.667-18.346-17.667s-18.346 7.91-18.346 17.667c0 9.757 8.214 17.666 18.346 17.666z" fill="#3186FF"/></g><g filter="url(#prefix__filter6_f_2001_67)"><path d="M-13.065 40.944c9.33 7.094 22.959 4.869 30.442-4.972 7.483-9.84 5.987-23.569-3.343-30.663C4.704-1.786-8.924.439-16.408 10.28c-7.483 9.84-5.986 23.57 3.343 30.664z" fill="#FBBC04"/></g><g filter="url(#prefix__filter7_f_2001_67)"><path d="M34.74 51.43c11.135 7.656 25.896 5.524 32.968-4.764 7.073-10.287 3.779-24.832-7.357-32.488C49.215 6.52 34.455 8.654 27.382 18.94c-7.072 10.288-3.779 24.833 7.357 32.49z" fill="#3186FF"/></g><g filter="url(#prefix__filter8_f_2001_67)"><path d="M54.984-2.336c2.833 3.852-.808 11.34-8.131 16.727-7.324 5.387-15.557 6.631-18.39 2.78-2.833-3.853.807-11.342 8.13-16.728 7.324-5.387 15.558-6.631 18.39-2.78z" fill="#749BFF"/></g><g filter="url(#prefix__filter9_f_2001_67)"><path d="M31.727 16.104C43.053 5.598 46.94-8.626 40.41-15.666c-6.53-7.04-21.006-4.232-32.332 6.274s-15.214 24.73-8.683 31.77c6.53 7.04 21.006 4.232 32.332-6.274z" fill="#FC413D"/></g><g filter="url(#prefix__filter10_f_2001_67)"><path d="M8.51 53.838c6.732 4.818 14.46 5.55 17.262 1.636 2.802-3.915-.384-10.994-7.116-15.812-6.731-4.818-14.46-5.55-17.261-1.636-2.802 3.915.383 10.994 7.115 15.812z" fill="#FFEE48"/></g></g><defs><filter id="prefix__filter0_f_2001_67" x="-19.824" y="13.152" width="39.274" height="43.217" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="2.46" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter1_f_2001_67" x="-15.001" y="-40.257" width="84.868" height="85.688" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="11.891" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter2_f_2001_67" x="-20.776" y="11.927" width="79.454" height="90.916" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter3_f_2001_67" x="-20.776" y="11.927" width="79.454" height="90.916" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter4_f_2001_67" x="-19.845" y="15.459" width="79.731" height="81.505" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="10.109" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter5_f_2001_67" x="29.832" y="-11.552" width="75.117" height="73.758" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
667<feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="9.606" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter6_f_2001_67" x="-38.583" y="-16.253" width="78.135" height="78.758" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="8.706" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter7_f_2001_67" x="8.107" y="-5.966" width="78.877" height="77.539" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="7.775" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter8_f_2001_67" x="13.587" y="-18.488" width="56.272" height="51.81" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="6.957" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter9_f_2001_67" x="-15.526" y="-31.297" width="70.856" height="69.306" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="5.876" result="effect1_foregroundBlur_2001_67"/></filter><filter id="prefix__filter10_f_2001_67" x="-14.168" y="20.964" width="55.501" height="51.571" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur stdDeviation="7.273" result="effect1_foregroundBlur_2001_67"/></filter><linearGradient id="prefix__paint0_linear_2001_67" x1="18.447" y1="43.42" x2="52.153" y2="15.004" gradientUnits="userSpaceOnUse"><stop stop-color="#4893FC"/><stop offset=".27" stop-color="#4893FC"/><stop offset=".777" stop-color="#969DFF"/><stop offset="1" stop-color="#BD99FE"/></linearGradient></defs> 668 </svg> 669 </span> 670 <span>Ouvrir Gemini</span> 671 </a> 672 </div> 673 674 <div class="ai-btn-row"> 675 <button id="ai-btn-copy-prompt" class="ai-btn ai-btn-secondary" style="border:1px solid rgba(16,185,129,0.5); color:#10B981; display: inline-flex; align-items: center; gap: 6px;"> 676 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg> 677 Copier le prompt 678 </button> 679 <button id="ai-btn-to-step2" class="ai-btn ai-btn-primary"> 680 Suivant : Coller la réponse 681 </button> 682 </div> 683 </div> 684 685 <!-- ÃTAPE 2 --> 686 <div id="ai-step-2-content" class="ai-step-panel" style="display:none;"> 687 <p style="margin-bottom:12px; font-size:0.9rem; line-height:1.4;"> 688 Collez le JSON retourné par l'IA ci-dessous. Roxima va vérifier sa structure et valider son intégrité. 689 </p> 690 <textarea id="ai-paste-json-box" class="ai-paste-area" placeholder="Collez le JSON ici (ex: { "version": "1.0", ... })"></textarea> 691 692 <div id="ai-validation-error-box" class="ai-validation-err" style="display:none;"> 693 <div class="ai-validation-err-title" style="font-weight:700; display: flex; align-items: center; gap: 6px;"> 694 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-alert-triangle" style="flex-shrink: 0;"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/>
694<line x1="12" y1="17" x2="12.01" y2="17"/></svg> 695 Erreur de Validation 696 </div> 697 <div id="ai-validation-error-desc" style="white-space: pre-wrap; margin-bottom:8px;">Description de l'erreur...</div> 698 <div style="font-weight:700; font-size:0.75rem;">Exemple de format attendu :</div> 699 <pre style="font-family:monospace; font-size:0.75rem; background:rgba(0,0,0,0.05); padding:8px; border-radius:4px; margin-top:4px; overflow-x:auto; color:#1c1c1e;"> 700{ 701 "version": "1.0", 702 "project_id": "${projectId}", 703 "plan_title": "Priorités du jour", 704 "summary": "Résumé explicatif", 705 "tasks": [ 706 { 707 "title": "Nom de la tâche", 708 "action": "create", 709 "priority": "high", 710 "duration_minutes": 45, 711 "due_date": null, 712 "today": true, 713 "order": 1, 714 "why": "Objectif", 715 "first_micro_action": "Commencer par...", 716 "preferred_period": "morning" 717 } 718 ] 719}</pre> 720 </div> 721 722 <div class="ai-btn-row"> 723 <button id="ai-btn-back-to-step1" class="ai-btn ai-btn-secondary"> 724 Retour 725 </button> 726 <button id="ai-btn-validate-json" class="ai-btn ai-btn-primary" style="display: inline-flex; align-items: center; gap: 6px;"> 727 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-search"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg> 728 Valider la réponse 729 </button> 730 </div> 731 </div> 732 733 <!-- ÃTAPE 3 -->
734 <div id="ai-step-3-content" class="ai-step-panel" style="display:none;"> 735 <div id="ai-preview-summary" style="margin-bottom: 12px; font-size: 0.95rem; font-weight: 600; color: #10B981;"> 736 Résumé du plan... 737 </div> 738 739 <div class="ai-preview-section"> 740 <div class="ai-preview-section-title">Modifications des Tâches :</div> 741 <div id="ai-preview-tasks-list" class="ai-preview-cards"> 742 <!-- Cards injectées --> 743 </div> 744 </div> 745 746 <div class="ai-preview-section" style="margin-top: 20px;"> 747 <div class="ai-preview-section-title">Créneaux de l'Agenda proposés :</div> 748 <div id="ai-preview-calendar-list" class="ai-preview-cards"> 749 <!-- Créneaux injectés --> 750 </div> 751 </div> 752 753 <div class="ai-btn-row" style="margin-top:24px;"> 754 <button id="ai-btn-back-to-step2" class="ai-btn ai-btn-secondary" style="display: inline-flex; align-items: center; gap: 6px;"> 755 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-edit"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg> 756 Modifier 757 </button> 758 <button id="ai-btn-apply-plan" class="ai-btn ai-btn-primary" style="background:#10B981; color:#fff; display: inline-flex; align-items: center; gap: 6px;"> 759 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check"><path d="M20 6 9 17l-5-5"/></svg> 760 Appliquer le plan 761 </button> 762 </div> 763 </div> 764 </div> 765 ` 766 }); 767 768 // Liaison des événements UI 769 const modalWrap = document.getElementById('ai-planner-modal-wrap'); 770 771 // Injecter le badge Bêta dans le titre de la modale 772 const titleEl = document.querySelector('.ai-planner-modal .modal-title'); 773 if (titleEl) { 774 titleEl.innerHTML = 'Planifier avec mon IA <span class="ai-beta-badge">Bêta</span>'; 775 } 776 777 // Intercepter les clics LLM pour copier automatiquement le prompt en premier 778 const promptBox = document.getElementById('ai-generated-prompt-box'); 779 const llmButtons = document.querySelectorAll('.ai-llm-btn'); 780 llmButtons.forEach(btn => { 781 btn.addEventListener('click', (e) => { 782 e.preventDefault(); 783 const targetUrl = btn.getAttribute('data-url'); 784 const name = btn.getAttribute('data-name'); 785 786 if (promptBox) { 787 navigator.clipboard.writeText(promptBox.value) 788 .then(() => { 789 window.Roxima?.showToast?.(`Prompt copié ! Ouverture de ${name}...`); 790 791 const oldContent = btn.innerHTML; 792 btn.innerHTML = `â Copié !`; 793 btn.style.background = 'rgba(16, 185, 129, 0.1)'; 794 btn.style.color = '#10B981'; 795 796 setTimeout(() => { 797 btn.innerHTML = oldContent; 798 btn.style.background = ''; 799 btn.style.color = ''; 800 window.open(targetUrl, '_blank'); 801 }, 600); 802 }) 803 .catch(err => { 804 console.error("Clipboard copy failed", err); 805 window.open(targetUrl, '_blank'); 806 }); 807 } else { 808 window.open(targetUrl, '_blank'); 809 } 810 }); 811 }); 812 if (!modalWrap) return; 813 814 const step1Panel = document.getElementById('ai-step-1-content'); 815 const step2Panel = document.getElementById('ai-step-2-content'); 816 const step3Panel = document.getElementById('ai-step-3-content'); 817 const tab1 = document.getElementById('step-tab-1'); 818 const tab2 = document.getElementById('step-tab-2'); 819 const tab3 = document.getElementById('step-tab-3'); 820 821 let validatedPlanData = null;
822 let calendarPlacements = []; 823 824 // --- Actions Ãtape 1 --- 825 const btnCopy = document.getElementById('ai-btn-copy-prompt'); 826 btnCopy.addEventListener('click', () => { 827 promptBox.select(); 828 navigator.clipboard.writeText(promptBox.value) 829 .then(() => { 830 btnCopy.innerHTML = ` 831 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check"><path d="M20 6 9 17l-5-5"/></svg> 832 Prompt copié ! 833 `; 834 btnCopy.style.background = 'rgba(16, 185, 129, 0.15)'; 835 btnCopy.style.color = '#10B981'; 836 setTimeout(() => { 837 btnCopy.innerHTML = ` 838 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg> 839 Copier le prompt 840 `; 841 btnCopy.style.background = ''; 842 btnCopy.style.color = ''; 843 }, 2500); 844 }) 845 .catch(err => { 846 console.error("Clipboard copy failed", err); 847 }); 848 }); 849 850 document.getElementById('ai-btn-to-step2').addEventListener('click', () => { 851 step1Panel.style.display = 'none'; 852 step2Panel.style.display = 'block'; 853 tab1.className = 'ai-step completed'; 854 tab2.className = 'ai-step active'; 855 }); 856 857 // --- Actions Ãtape 2 --- 858 document.getElementById('ai-btn-back-to-step1').addEventListener('click', () => { 859 step2Panel.style.display = 'none'; 860 step1Panel.style.display = 'block'; 861 tab2.className = 'ai-step'; 862 tab1.className = 'ai-step active'; 863 }); 864 865 const btnValidate = document.getElementById('ai-btn-validate-json'); 866 const pasteBox = document.getElementById('ai-paste-json-box'); 867 const errBox = document.getElementById('ai-validation-error-box'); 868 const errDesc = document.getElementById('ai-validation-error-desc'); 869 870 btnValidate.addEventListener('click', () => { 871 errBox.style.display = 'none'; 872 const rawText = pasteBox.value; 873 const result = validateImportJson(rawText, projectId); 874 875 if (!result.valid) { 876 errDesc.textContent = result.errorMsg; 877 errBox.style.display = 'block'; 878 recordImport(projectId, rawText, null, 'failed'); 879 return; 880 } 881 882 const data = result.data; 883 const hash = generateImportHash(data); 884 885 // Vérification anti-doublon (Idempotence) 886 if (isAlreadyImported(hash)) { 887 errDesc.textContent = `Ce plan d'importation a déjà été appliqué avec succès. Pour éviter la création de doublons, cet import est bloqué.`; 888 errBox.style.display = 'block'; 889 return; 890 } 891 892 validatedPlanData = data; 893 recordImport(projectId, rawText, data, 'validated'); 894 895 // Lancement du planificateur d'agenda local 896 calendarPlacements = planCalendarTasks(data.tasks, freeSlots); 897 898 // Affichage de l'aperçu 899 renderPreview(data, calendarPlacements); 900 901 step2Panel.style.display = 'none'; 902 step3Panel.style.display = 'block'; 903 tab2.className = 'ai-step completed'; 904 tab3.className = 'ai-step active'; 905 }); 906 907 // --- Actions Ãtape 3 --- 908 document.getElementById('ai-btn-back-to-step2').addEventListener('click', () => { 909 step3Panel.style.display = 'none'; 910 step2Panel.style.display = 'block'; 911 tab3.className = 'ai-step'; 912 tab2.className = 'ai-step active'; 913 }); 914 915 const btnApply = document.getElementById('ai-btn-apply-plan'); 916 btnApply.addEventListener('click', () => { 917 if (!validatedPlanData) return; 918 919 // 1. Appliquer les modifications de tâches 920 const appliedChanges = applyPlannerChanges(projectId, validatedPlanData, ctx); 921 922 // 2. Enregistrer l'état final de l'import 923 recordImport(projectId, pasteBox.value, validatedPlanData, 'applied', appliedChanges); 924 925 // 3. Demander si on ajoute au Google Calendar 926 const hasPlacements = calendarPlacements.some(p => !p.unplanned); 927 928 if (hasPlacements) { 929 askCalendarSync(calendarPlacements, appliedChanges, ctx, () => { 930 document.getElementById('modal-close-btn')?.click(); 931 if (onAppliedCallback) onAppliedCallback(); 932 }); 933 } else { 934 document.getElementById('modal-close-btn')?.click(); 935 if (onAppliedCallback) onAppliedCallback(); 936 } 937 }); 938 939 /**
940 * Rendu des prévisualisations dans l'Ãtape 3. 941 */ 942 function renderPreview(data, placements) { 943 document.getElementById('ai-preview-summary').innerHTML = ` 944 Plan : ${escapeHtml(data.plan_title)}<br> 945 <span style="font-size:0.85rem; font-weight:normal; color:var(--text-muted);">${escapeHtml(data.summary)}</span> 946 `; 947 948 const taskListEl = document.getElementById('ai-preview-tasks-list'); 949 taskListEl.innerHTML = ''; 950 951 data.tasks.forEach(t => { 952 const card = document.createElement('div'); 953 card.className = 'ai-preview-card'; 954 955 const badgeClass = t.action === 'create' ? 'ai-badge-create' : 'ai-badge-update'; 956 const badgeText = t.action === 'create' ? 'Création' : 'Mise à jour'; 957 958 card.innerHTML = ` 959 <div class="ai-preview-card-header"> 960 <div class="ai-preview-card-title">${escapeHtml(t.title)}</div> 961 <span class="ai-badge ${badgeClass}">${badgeText}</span> 962 </div> 963 <div class="ai-preview-card-details"> 964 <div><strong>Durée estimée :</strong> ${t.duration_minutes} min</div> 965 <div><strong>Priorité :</strong> ${t.priority || 'non définie'}</div> 966 ${t.today ? `<div style="color:#10B981; font-weight:600;">â Planifié pour Aujourd'hui</div>` : ''} 967 ${t.why ? `<div class="ai-preview-card-why"><strong>Raison :</strong> ${escapeHtml(t.why)}</div>` : ''} 968 ${t.first_micro_action ? `<div class="ai-preview-card-micro"><strong>Première micro-action :</strong> ${escapeHtml(t.first_micro_action)}</div>` : ''} 969 </div> 970 `; 971 taskListEl.appendChild(card); 972 }); 973 974 const calListEl = document.getElementById('ai-preview-calendar-list'); 975 calListEl.innerHTML = ''; 976 977 const hasPlacements = placements.length > 0; 978 if (!hasPlacements) { 979 calListEl.innerHTML = '<div style="font-size:0.8rem; color:var(--text-muted);">Aucune tâche du jour à placer dans l\'agenda.</div>'; 980 return; 981 } 982 983 placements.forEach(p => { 984 const card = document.createElement('div'); 985 card.className = 'ai-preview-card'; 986 987 if (p.unplanned) { 988 card.innerHTML = ` 989 <div class="ai-preview-card-header"> 990 <div class="ai-preview-card-title" style="opacity:0.6;">${escapeHtml(p.task.title)}</div> 991 <span class="ai-badge" style="background:rgba(0,0,0,0.05); color:#777;">Non planifiée</span> 992 </div> 993 <div style="font-size:0.8rem; color:#EF4444; font-weight:500; display:flex; align-items:center; gap:4px;"> 994 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-alert-triangle"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> 995 <span>Hors créneaux â à lancer lorsqu'un créneau est disponible aujourd'hui.</span> 996 </div> 997 `; 998 } else { 999 const startStr = p.start.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); 1000 const endStr = p.end.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); 1001 card.innerHTML = ` 1002 <div class="ai-preview-card-header"> 1003 <div class="ai-preview-card-title">${escapeHtml(p.task.title)}</div> 1004 <span class="ai-badge ai-badge-create">Planifiée</span> 1005 </div> 1006 <div style="font-size:0.85rem; font-weight:600; color:#10B981; display:flex; align-items:center; gap:4px;"> 1007 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calendar"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
1008 <span>${startStr} - ${endStr} (${p.task.duration_minutes} min)</span> 1009 </div> 1010 <div style="font-size:0.75rem; color:var(--text-muted); margin-top:2px;"> 1011 Période demandée : ${p.preferred_period} 1012 </div> 1013 `; 1014 } 1015 calListEl.appendChild(card); 1016 }); 1017 } 1018 1019 function escapeHtml(value = '') { 1020 const span = document.createElement('span'); 1021 span.textContent = String(value); 1022 return span.innerHTML; 1023 } 1024} 1025 1026/** 1027 * Applique les modifications de tâches. 1028 */ 1029function applyPlannerChanges(projectId, data, ctx) { 1030 const store = ctx.store; 1031 const page = objectifsStore.getPage(projectId); 1032 const projectName = page ? objectifsStore.getPagePath(projectId) : ''; 1033 const projectIcon = page ? page.icon : ''; 1034 1035 const createdIds = []; 1036 const updatedIds = []; 1037 1038 let localTodos = store.get('todos') || []; 1039 1040 data.tasks.forEach(t => { 1041 let blockId = t.task_id; 1042 1043 if (t.action === 'create') { 1044 const newBlock = objectifsStore.addBlock(projectId, { 1045 type: 'todo', 1046 text: t.title, 1047 checked: false, 1048 priority: t.priority || 'medium', 1049 duration_minutes: t.duration_minutes, 1050 why: t.why || '', 1051 first_micro_action: t.first_micro_action || '', 1052 preferred_period: t.preferred_period || 'anytime' 1053 }); 1054 blockId = newBlock.id; 1055 createdIds.push(blockId); 1056 } else if (t.action === 'update' && blockId) { 1057 const blocks = objectifsStore.getBlocks(projectId); 1058 const block = blocks.find(b => b.id === blockId); 1059 if (block) { 1060 const patch = { 1061 text: t.title, 1062 priority: t.priority || block.priority || 'medium', 1063 duration_minutes: t.duration_minutes || block.duration_minutes, 1064 why: t.why || block.why || '', 1065 first_micro_action: t.first_micro_action || block.first_micro_action || '', 1066 preferred_period: t.preferred_period || block.preferred_period || 'anytime' 1067 }; 1068 objectifsStore.updateBlock(projectId, blockId, patch); 1069 updatedIds.push(blockId); 1070 } 1071 } 1072 1073 if (t.today && blockId) { 1074 const existingTodoIdx = localTodos.findIndex(lt => lt.sourceId === blockId); 1075 if (existingTodoIdx > -1) { 1076 localTodos[existingTodoIdx].text = t.title; 1077 } else { 1078 localTodos.push({ 1079 id: ctx.utils.uuid(), 1080 text: t.title, 1081 completed: false, 1082 sourceId: blockId, 1083 projectId: projectId, 1084 projectName, 1085 projectIcon, 1086 sourceDeleted: false, 1087 level: 0 1088 }); 1089 } 1090 } 1091 }); 1092 1093 objectifsStore.save(); 1094 store.set('todos', localTodos); 1095 1096 if (ctx.bus) { 1097 ctx.bus.emit('objectifs:refresh'); 1098 ctx.bus.emit('sync:loaded'); 1099 } 1100 1101 return { 1102 projectId, 1103 created: createdIds, 1104 updated: updatedIds, 1105 todayTasks: data.tasks.filter(t => t.today).map(t => t.title) 1106 }; 1107} 1108 1109/** 1110 * Modale de confirmation pour l'ajout au Google Calendar. 1111 */ 1112function askCalendarSync(placements, appliedChanges, ctx, onDone) { 1113 const isGoogleConnected = localStorage.getItem('rox_google_connected') === 'true'; 1114 1115 ctx.bus.emit('ui:modal:open', { 1116 title: 'Planification de l\'Agenda', 1117 className: 'ai-calendar-sync-modal', 1118 content: ` 1119 <p style="margin-bottom:16px; font-size:0.95rem; line-height:1.4;"> 1120 Les tâches du jour ont été enregistrées avec succès dans vos listes Roxima !<br> 1121 Souhaitez-vous planifier leurs créneaux dans l'agenda ? 1122 </p> 1123 <div style="background:rgba(0,0,0,0.02); border:1px solid rgba(0,0,0,0.05); border-radius:8px; padding:12px; margin-bottom:16px;"> 1124 <div style="font-weight:700; font-size:0.85rem; margin-bottom:6px;">Créneaux qui seront ajoutés :</div> 1125 <div style="display:flex; flex-direction:column; gap:6px;"> 1126 ${placements.map(p => { 1127 if (p.unplanned) return ''; 1128 const startStr = p.start.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); 1129 const endStr = p.end.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); 1130 return `<div style="font-size:0.8rem;">⢠<strong>${startStr} - ${endStr}</strong> : ${p.task.title}</div>`; 1131 }).join('')} 1132 </div> 1133 </div> 1134 1135 ${isGoogleConnected ? ` 1136 <div style="font-size:0.85rem; color:#10B981; font-weight:500; margin-bottom:16px; display:flex; align-items:center; gap:6px;"> 1137 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check" style="flex-shrink: 0;"><path d="M20 6 9 17l-5-5"/></svg> 1138 Votre Google Agenda est connecté. Les blocs y seront synchronisés. 1139 </div> 1140 ` : ` 1141 <div style="font-size:0.85rem; color:var(--text-muted); margin-bottom:16px; display:flex; align-items:flex-start; gap:6px; line-height: 1.4;"> 1142 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-info" style="flex-shrink: 0; margin-top: 2px;"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
1143 <span>Google Agenda non connecté. Les créneaux seront ajoutés uniquement à l'agenda Roxima local.</span> 1144 </div> 1145 `} 1146 1147 <div class="ai-btn-row"> 1148 <button id="ai-btn-cal-none" class="ai-btn ai-btn-secondary"> 1149 Garder uniquement dans Roxima 1150 </button> 1151 <button id="ai-btn-cal-add" class="ai-btn ai-btn-primary" style="background:#10B981; color:#fff;"> 1152 Ajouter à l'agenda 1153 </button> 1154 </div> 1155 ` 1156 }); 1157 1158 const handleAction = (syncToGoogle) => { 1159 const calendarEvents = []; 1160 1161 placements.forEach(p => { 1162 if (p.unplanned) return; 1163 1164 const matchInApplied = [...appliedChanges.created, ...appliedChanges.updated].find(id => { 1165 const b = objectifsStore.getBlocks(appliedChanges.projectId).find(blk => blk.id === id); 1166 return b && b.text === p.task.title; 1167 }); 1168 1169 calendarEvents.push({ 1170 title: p.task.title, 1171 start: p.start.toISOString(), 1172 end: p.end.toISOString(), 1173 sourceTodoId: matchInApplied || p.task.task_id || null 1174 }); 1175 }); 1176 1177 ctx.bus.emit('calendar:add-events', { 1178 events: calendarEvents, 1179 syncToGoogle: syncToGoogle && isGoogleConnected 1180 }); 1181 1182 onDone(); 1183 }; 1184 1185 const btnNone = document.getElementById('ai-btn-cal-none'); 1186 const btnAdd = document.getElementById('ai-btn-cal-add'); 1187 1188 if (btnNone) { 1189 btnNone.addEventListener('click', () => { 1190 handleAction(false); 1191 }); 1192 } 1193 1194 if (btnAdd) { 1195 btnAdd.addEventListener('click', () => { 1196 handleAction(true); 1197 }); 1198 } 1199} 1200 1201/** 1202 * Génère un hash stable pour identifier de manière unique un plan importé. 1203 */ 1204function generateImportHash(data) { 1205 const str = JSON.stringify({ 1206 project_id: data.project_id, 1207 plan_title: data.plan_title, 1208 tasks: data.tasks.map(t => ({ 1209 title: t.title, 1210 action: t.action, 1211 task_id: t.task_id, 1212 duration_minutes: t.duration_minutes, 1213 today: t.today 1214 })) 1215 }); 1216 let hash = 0; 1217 for (let i = 0; i < str.length; i++) { 1218 const char = str.charCodeAt(i); 1219 hash = ((hash << 5) - hash) + char; 1220 hash |= 0; 1221 } 1222 return 'ai_plan_' + Math.abs(hash).toString(36); 1223} 1224 1225/** 1226 * Vérifie si un plan a déjà été appliqué avec succès. 1227 */ 1228function isAlreadyImported(hash) { 1229 try { 1230 const history = JSON.parse(localStorage.getItem('rox_ai_imports') || '[]'); 1231 return history.some(item => item.hash === hash && item.status === 'applied'); 1232 } catch (e) { 1233 console.error("Failed to parse rox_ai_imports from localStorage", e); 1234 return false; 1235 } 1236} 1237 1238/** 1239 * Journalise une tentative ou un succès d'importation dans le localStorage. 1240 */ 1241function recordImport(projectId, rawText, data, status, appliedChanges = null) { 1242 try { 1243 const history = JSON.parse(localStorage.getItem('rox_ai_imports') || '[]'); 1244 const hash = data ? generateImportHash(data) : null; 1245 1246 history.push({ 1247 id: 'imp_' + Math.random().toString(36).substr(2, 9), 1248 timestamp: new Date().toISOString(), 1249 projectId, 1250 rawTextLength: rawText.length, 1251 hash, 1252 status, // 'failed', 'validated', 'applied' 1253 appliedChanges 1254 }); 1255 1256 // Limiter à 50 entrées dans le journal d'historique 1257 if (history.length > 50) { 1258 history.shift(); 1259 } 1260 1261 localStorage.setItem('rox_ai_imports', JSON.stringify(history)); 1262 } catch (e) { 1263 console.error("Failed to write rox_ai_imports to localStorage", e); 1264 } 1265}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.