v2 - STT via Whisper for more precision and if nor available, twist to Vox

This commit is contained in:
pporcheret
2026-10-27 16:44:39 +01:00
parent 5ccb0a48c2
commit 5f82b1bdf2
49 changed files with 4667 additions and 329 deletions
+172
View File
@@ -0,0 +1,172 @@
/* 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);
}
})();