PageSourceSearch

https://roxima.io/blocks/projects/ai_planner.js

js roxima.io collected 2026-10-02 01:58:39 UTC 64,953 bytes, 1,265 lines download raw bytes

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: { &quot;version&quot;: &quot;1.0&quot;, ... })"></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.