Files
Alfredv2/alfred/app/static/js/alfred-features.js
T

173 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);
}
})();