/* Alfred V4+V5: visual activity dock. Does not alter the existing interface. */ (() => { 'use strict'; const CSS = 'alfred-feature-'; const state = { cards: [], collapsed: false, lastStatus: new Map(), receivedOnce: false }; const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = CSS + className; if (text != null) node.textContent = String(text); return node; }; const root = el('aside', 'dock'); root.setAttribute('aria-label', 'Activité et informations d’Alfred'); const header = el('header', 'header'); const toggle = el('button', 'toggle', 'Activité Hermes'); toggle.type = 'button'; toggle.setAttribute('aria-expanded', 'true'); const count = el('span', 'count', '0'); const contents = el('section', 'contents'); contents.setAttribute('aria-live', 'polite'); contents.setAttribute('aria-relevant', 'additions text'); header.append(toggle, count); root.append(header, contents); document.body.append(root); const toggleDock = (collapse) => { state.collapsed = collapse; root.classList.toggle(CSS + 'collapsed', collapse); toggle.setAttribute('aria-expanded', String(!collapse)); toggle.textContent = collapse ? '☰ Activité Hermes' : '⌄ Activité Hermes'; }; toggle.addEventListener('click', () => toggleDock(!state.collapsed)); const addText = (parent, cls, text, maxlength=1600) => { if (text === undefined || text === null || String(text).trim() === '') return; const value = String(text); if (value.length <= maxlength) { parent.append(el('p', cls, value)); return; } const p = el('p', cls, value.slice(0, maxlength) + '…'); const button = el('button', 'more', 'Afficher tout'); button.type = 'button'; let open = false; button.addEventListener('click', () => { open = !open; p.textContent = open ? value : value.slice(0, maxlength) + '…'; button.textContent = open ? 'Réduire' : 'Afficher tout'; }); parent.append(p, button); }; const safeImage = url => { if (typeof url !== 'string') return null; try { const parsed = new URL(url, location.origin); return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : null; } catch { return null; } }; const displayExtra = (item, display) => { if (!display || typeof display !== 'object') return; const kind = display.type; if (kind === 'image' && display.url) { const src = safeImage(display.url); if (src) { const img = el('img', 'image'); img.src = src; img.alt = display.title || 'Illustration Alfred'; img.loading = 'lazy'; item.append(img); } } else if (kind === 'weather') { const line = [display.temperature, display.condition || display.description].filter(Boolean).join(' · '); addText(item, 'weather', line, 300); } else if (kind === 'calendar' && Array.isArray(display.events)) { for (const event of display.events.slice(0, 6)) { addText(item, 'event', [event.start, event.title].filter(Boolean).join(' — '), 300); } } if (display.text && display.text !== item.dataset.body) addText(item, 'detail', display.text); }; const STATUS_LABELS = { queued: 'En attente', running: 'En cours', succeeded: 'Terminé', failed: 'Erreur', interrupted: 'Interrompu' }; function render() { contents.replaceChildren(); count.textContent = String(state.cards.length); if (!state.cards.length) { contents.append(el('p', 'empty', 'Aucune activité pour le moment.')); return; } state.cards.forEach(card => { const item = el('article', 'card'); item.dataset.body = card.body || ''; const heading = el('div', 'card-header'); const title = el('strong', 'card-title', card.title || 'Information'); heading.append(title); if (card.status) { const status = el('span', 'status ' + (card.status || ''), STATUS_LABELS[card.status] || card.status); heading.append(status); } item.append(heading); const body = card.body || ''; if (body && (!card.display?.text || card.display.text !== body)) addText(item, 'body', body); displayExtra(item, card.display); if (body && card.display?.text === body) addText(item, 'body', body); const footer = el('div', 'footer'); if (card.updated_at) { const date = new Date(card.updated_at); footer.append(el('time', 'date', isNaN(date.getTime()) ? '' : date.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'}))); } if (card.status === 'succeeded' && body) { const say = el('button', 'speak', 'Lire'); say.type = 'button'; say.title = 'Faire lire le résultat par la voix Piper configurée'; say.addEventListener('click', async () => { say.disabled = true; try { const response = await fetch('/api/tts/say', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({text:body.slice(0,1800)}) }); if (!response.ok) throw new Error('TTS indisponible'); } catch (error) { say.title = String(error); } finally { say.disabled = false; } }); footer.append(say); } item.append(footer); contents.append(item); }); } let fetching = false; async function refresh() { if (fetching) return; fetching = true; try { const response = await fetch('/api/alfred/cards', {cache:'no-store'}); if (!response.ok) throw new Error('Cartes indisponibles'); const payload = await response.json(); const cards = Array.isArray(payload.cards) ? payload.cards : []; const sig = JSON.stringify(cards); if (sig === state.signature) return; state.signature = sig; const hasNewCompletion = cards.some(card => card.status === 'succeeded' && state.lastStatus.has(card.id) && state.lastStatus.get(card.id) !== 'succeeded'); const hasNewCard = cards.some(card => !state.lastStatus.has(card.id)); state.lastStatus = new Map(cards.map(c => [c.id, c.status])); state.cards = cards; render(); if (!state.receivedOnce) { state.receivedOnce = true; toggleDock(cards.length === 0); } else if (hasNewCompletion || hasNewCard) { toggleDock(false); } } catch (error) { if (!state.receivedOnce) contents.replaceChildren(el('p', 'empty', 'Connexion aux cartes indisponible.')); } finally { fetching = false; } } refresh(); setInterval(refresh, 10000); // safety fallback; Socket.IO wakes immediately if (typeof window.io === 'function') { const socket = window.io(); socket.on('alfred_cards_changed', refresh); socket.on('alfred_task', refresh); socket.on('connect', refresh); } })();