debut illustration
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "DONA_DYNAMIC_PROXMOX_V3",
|
||||
"files": {
|
||||
"services/dona_dynamic.py": {
|
||||
"old": "b38472d6164e2678f8fc4e742b7d1f5fc67b82fab3af148f7fc1b3c2a3f8afcb",
|
||||
"new": "17b96d9f4904a8a450ecbda643ac3409f70b9d90ac62993214eef686c3fc150b",
|
||||
"mode": 420
|
||||
},
|
||||
"static/js/dona-dynamic-v8.js": {
|
||||
"old": "2a61399cb67d8cd207c5249f2eb12cfccc5afb8eec02ae06479245823a5fe9b9",
|
||||
"new": "9f5762e6f6b2dfd7f1924261259a2f90e77ceb067c83f323ab55a301ea62a49d",
|
||||
"mode": 420
|
||||
},
|
||||
"services/dona_proxmox_direct.py": {
|
||||
"old": null,
|
||||
"new": "84aff9fd67f1b98347c54935cd2ce0bfcbc2883e02e1190e9cfc06bf90d90f42",
|
||||
"mode": 420
|
||||
}
|
||||
}
|
||||
}
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
"""Dona V8: trusted data-only dashboard protocol; no arbitrary HTML/JS/code execution.
|
||||
|
||||
LLM can suggest *data* and layout from an explicit user request; server validates shape.
|
||||
Client renders with DOM textContent only. Source tools stay in existing Hermes/MCP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import math
|
||||
import unicodedata
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlsplit
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
MAX_PROMPT = 500
|
||||
MAX_RESPONSE = 120000
|
||||
CARD_TYPES = frozenset({'metric','text','list','table','bars','light'})
|
||||
|
||||
|
||||
def is_dynamic_request(text):
|
||||
q = str(text or '').casefold()
|
||||
return bool(re.search(r'(tableau de bord|tableau dynamique|graphique|visualise|visualiser|vue synth[eé]tique|affiche.{0,30}sous forme de (tableau|cartes|graphique)|cr[eé]e.{0,30}(un [eé]cran|une interface)|(?:montre|affiche|pr[eé]sente)(?:[ -]moi)? .{0,65}(?:[eé]tat|statistiques|performances|proxmox|wazuh|serveurs?))', q))
|
||||
|
||||
|
||||
def _short(value, limit=200):
|
||||
if isinstance(value, (dict, list)):
|
||||
return ''
|
||||
return str(value if value is not None else '').replace('\x00','').strip()[:limit]
|
||||
|
||||
|
||||
def _numeric(value):
|
||||
if isinstance(value, bool): return None
|
||||
if isinstance(value, str): value = value.strip().replace(',', '.')
|
||||
try: number = float(value)
|
||||
except (ValueError, TypeError, OverflowError): return None
|
||||
return number if math.isfinite(number) and -1e10 < number < 1e10 else None
|
||||
|
||||
|
||||
def _table_row(row, columns):
|
||||
if isinstance(row, list):
|
||||
return [_short(v, 140) for v in row[:len(columns)]]
|
||||
if not isinstance(row, dict): return None
|
||||
def key(v):
|
||||
return ''.join(c for c in unicodedata.normalize('NFD', str(v).casefold())
|
||||
if unicodedata.category(c) != 'Mn').strip()
|
||||
obj = {key(k):v for k,v in row.items()}
|
||||
aliases = {'nom':('name','id'), 'etat':('state','status'),
|
||||
'statut':('status','state'), 'noeud':('node',)}
|
||||
return [_short(next((obj[k] for k in (key(col),)+aliases.get(key(col),()) if k in obj), ''), 140)
|
||||
for col in columns]
|
||||
|
||||
|
||||
def _repair_brackets(text):
|
||||
# Répare uniquement les fermetures ]/} confondues, hors chaînes JSON.
|
||||
stack, output = [], []
|
||||
quoted = escaped = False
|
||||
for char in text:
|
||||
if quoted:
|
||||
if escaped: escaped = False
|
||||
elif char == "\\": escaped = True
|
||||
elif char == '"': quoted = False
|
||||
elif char == '"': quoted = True
|
||||
elif char in '{[': stack.append(char)
|
||||
elif char in '}]':
|
||||
if not stack: return text
|
||||
char = '}' if stack.pop() == '{' else ']'
|
||||
output.append(char)
|
||||
return ''.join(output)
|
||||
|
||||
|
||||
def normalize_dashboard(raw, *, prompt='', origin='hermes'):
|
||||
"""Reject unsupported widgets/fields. No actions, URLs, HTML or code accepted."""
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError('Tableau de bord non structuré.')
|
||||
cards = raw.get('cards')
|
||||
if not isinstance(cards,list):
|
||||
raise ValueError('Liste de cartes absente.')
|
||||
result = []
|
||||
for c in cards[:16]:
|
||||
if not isinstance(c,dict):
|
||||
continue
|
||||
kind = _short(c.get('type') or c.get('kind'),20)
|
||||
if kind not in CARD_TYPES or (kind=='light' and origin != 'lights'):
|
||||
continue
|
||||
card = {'type':kind,'title':_short(c.get('title') or c.get('label') or '',100)}
|
||||
if kind == 'light':
|
||||
card.update(entity_id=_short(c.get('entity_id'),120), state=_short(c.get('state'),24))
|
||||
if not re.fullmatch(r'light\.[a-z0-9_]+',card['entity_id']):continue
|
||||
elif kind == 'metric':
|
||||
card.update(value=_short(c.get('value'),80),unit=_short(c.get('unit'),24),
|
||||
detail=_short(c.get('detail'),150),status=_short(c.get('status'),20))
|
||||
elif kind == 'text':
|
||||
card['text'] = _short(c.get('text'),2200)
|
||||
elif kind == 'list':
|
||||
card['items'] = [_short(x,300) for x in (c.get('items') or [])[:32] if not isinstance(x,(dict,list))] if isinstance(c.get('items'),list) else []
|
||||
elif kind == 'table':
|
||||
cols = c.get('columns') or []
|
||||
card['columns'] = [_short(x,50) for x in cols[:6]] if isinstance(cols,list) else []
|
||||
rows = c.get('rows') or []
|
||||
card['rows'] = [v for v in (_table_row(r, card['columns']) for r in rows[:35]) if v is not None] if isinstance(rows,list) else []
|
||||
if not card['columns']: continue
|
||||
elif kind == 'bars':
|
||||
items=c.get('items') or []
|
||||
card['items'] = [{'label':_short(x.get('label'),80),'value':_numeric(x.get('value')),'unit':_short(x.get('unit'),12)} for x in items[:22] if isinstance(x,dict) and _numeric(x.get('value')) is not None] if isinstance(items,list) else []
|
||||
result.append(card)
|
||||
if not result:
|
||||
raise ValueError('Aucune carte exploitable.')
|
||||
return {
|
||||
'schema':'dona.dashboard.v1', 'title':_short(raw.get('title') or 'Tableau dynamique',110),
|
||||
'subtitle':_short(raw.get('subtitle') or '',240), 'prompt':_short(prompt,MAX_PROMPT),
|
||||
'origin':_short(origin,24), 'generated_at':datetime.now(timezone.utc).isoformat(),
|
||||
'cards':result,
|
||||
}
|
||||
|
||||
|
||||
def extract_dashboard(text):
|
||||
"""Take JSON anywhere in a model response, including fenced JSON."""
|
||||
if not isinstance(text,str) or len(text)>MAX_RESPONSE:
|
||||
raise ValueError('Réponse trop volumineuse ou invalide.')
|
||||
text = text.replace(' ', ' ').replace(' ', ' ')
|
||||
decoder=json.JSONDecoder()
|
||||
for i,char in enumerate(text):
|
||||
if char != '{':continue
|
||||
try:
|
||||
obj,_=decoder.raw_decode(text,i)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(obj,dict) and isinstance(obj.get('cards'),list):
|
||||
return obj
|
||||
# Seconde passe : réparation contrôlée des fermetures ] et }.
|
||||
last = text.rfind('}')
|
||||
if last >= 0:
|
||||
for i, c in enumerate(text):
|
||||
if c != '{' or '"cards"' not in text[i:min(last+1, i+3000)]: continue
|
||||
candidate = _repair_brackets(text[i:last+1])
|
||||
try: obj, _ = decoder.raw_decode(candidate)
|
||||
except ValueError: continue
|
||||
if isinstance(obj,dict) and isinstance(obj.get('cards'),list): return obj
|
||||
raise ValueError('Aucune structure JSON exploitable dans la réponse Hermes.')
|
||||
|
||||
|
||||
def _fallback(answer,prompt):
|
||||
"""Show the actual Hermes answer rather than inventing measurements."""
|
||||
safe = _short(answer,6500)
|
||||
if not safe:
|
||||
safe='Hermes n’a pas fourni de données pour construire ce tableau.'
|
||||
return normalize_dashboard({
|
||||
'title':'Résultat · '+_short(prompt,75),
|
||||
'subtitle':'Affichage textuel : Hermes n’a pas renvoyé de cartes structurées.',
|
||||
'cards':[{'type':'text','title':'Réponse Hermes','text':safe[:2200]}] +
|
||||
([{'type':'text','title':'Suite de la réponse','text':safe[2200:4400]}] if len(safe)>2200 else []) +
|
||||
([{'type':'text','title':'Fin de la réponse','text':safe[4400:6500]}] if len(safe)>4400 else []),
|
||||
},prompt=prompt,origin='fallback')
|
||||
|
||||
|
||||
def _live_spotify(prompt):
|
||||
from services import dona_spotify
|
||||
raw=dona_spotify._call('GET','/me/player')
|
||||
devices=dona_spotify._call('GET','/me/player/devices').get('devices') or []
|
||||
pub=dona_spotify._public_state(raw,[{'name':x.get('name',''), 'is_active':bool(x.get('is_active'))} for x in devices if isinstance(x,dict)])
|
||||
cards=[{'type':'metric','title':'Lecture','value':'En cours' if pub['is_playing'] else 'À l’arrêt'},
|
||||
{'type':'text','title':'Morceau','text':(pub['title']+' — '+pub['artist']).strip(' —') or 'Aucun morceau actuellement sélectionné.'},
|
||||
{'type':'list','title':'Appareils Spotify Connect','items':[(x.get('name') or 'Sans nom') + (' · actif' if x.get('is_active') else '') for x in pub['devices']]}]
|
||||
return normalize_dashboard({'title':'Spotify Connect','subtitle':'Données directes de Spotify · actualisation sans LLM','cards':cards},prompt=prompt,origin='spotify')
|
||||
|
||||
|
||||
def _live_temperatures(prompt):
|
||||
from services import ha_views
|
||||
raw=ha_views.get_temperatures()
|
||||
cards=[{'type':'metric','title':'Capteurs disponibles','value':str(len(raw))},
|
||||
{'type':'table','title':'Températures Home Assistant','columns':['Capteur','Température'],
|
||||
'rows':[[x.get('name','Capteur'),str(x.get('value','—'))+' '+str(x.get('unit',''))] for x in raw[:35] if isinstance(x,dict)]}]
|
||||
return normalize_dashboard({'title':'Températures de la maison','subtitle':'Données directes de Home Assistant','cards':cards},prompt=prompt,origin='homeassistant')
|
||||
|
||||
|
||||
def _live_lights(prompt):
|
||||
from services import ha_views
|
||||
states=ha_views.states() or []
|
||||
cards=[{'type':'metric','title':'Lumières allumées',
|
||||
'value':str(sum(1 for x in states if isinstance(x,dict) and x.get('entity_id','').startswith('light.') and x.get('state')=='on'))}]
|
||||
for x in states:
|
||||
if not isinstance(x,dict):continue
|
||||
eid=x.get('entity_id','')
|
||||
if not isinstance(eid,str) or not re.fullmatch(r'light\.[a-z0-9_]+',eid):continue
|
||||
attrs=x.get('attributes') or {}
|
||||
cards.append({'type':'light','title':attrs.get('friendly_name') or eid,'entity_id':eid,'state':x.get('state','unknown')})
|
||||
if len(cards)>=16:break
|
||||
return normalize_dashboard({'title':'Éclairage de la maison',
|
||||
'subtitle':'Données Home Assistant en direct · les interrupteurs agissent sur les lumières',
|
||||
'cards':cards},prompt=prompt,origin='lights')
|
||||
|
||||
|
||||
def choose_live_source(prompt):
|
||||
q = str(prompt).casefold()
|
||||
if re.search(r'\bspotify\b|\blecteur musical\b',q) and not re.search(r'\b(et|avec|ainsi que)\b.{0,25}\b(proxmox|wazuh|home assistant|temp[eé]rature)',q):
|
||||
return 'spotify'
|
||||
if re.search(r'\blumi[eè]res?\b|\b[eé]clairage\b|\blampes?\b',q) and not re.search(r'\b(proxmox|wazuh|spotify|serveur)\b',q):
|
||||
return 'lights'
|
||||
if re.search(r'\btemp[eé]ratures?\b',q) and not re.search(r'\b(proxmox|wazuh|spotify|serveur)\b',q):
|
||||
return 'temperatures'
|
||||
return None
|
||||
|
||||
|
||||
def build_dashboard(prompt, ask_hermes):
|
||||
"""Calls existing MCP through Hermes only when no suitable direct read API exists."""
|
||||
if not isinstance(prompt,str) or not 2 <= len(prompt.strip()) <= MAX_PROMPT:
|
||||
raise ValueError('Demande de 2 à 500 caractères requise.')
|
||||
source=choose_live_source(prompt)
|
||||
if source=='spotify':return _live_spotify(prompt)
|
||||
if source=='temperatures':return _live_temperatures(prompt)
|
||||
if source=='lights':return _live_lights(prompt)
|
||||
instruction=(
|
||||
'Demande originale : '+prompt+'\n\n'
|
||||
'Interroge d’abord les outils disponibles (MCP) pour obtenir les données réelles. '
|
||||
'N’invente jamais de serveurs, valeurs, statuts, nombres ou périodes. '
|
||||
'Si les outils échouent, affiche des cartes texte indiquant les données indisponibles. '
|
||||
'Réponds UNIQUEMENT par un objet JSON valide, sans balises Markdown ni commentaire. '
|
||||
'Schéma: {"title":"...","subtitle":"...","cards":[ '
|
||||
'{"type":"metric","title":"Nœuds actifs","value":"5/5","detail":"..."}, '
|
||||
'{"type":"list","title":"Nœuds","items":["nom — état"]}, '
|
||||
'{"type":"table","title":"Machines","columns":["Nom","État"],"rows":[["...","..."]]}, '
|
||||
'{"type":"bars","title":"CPU (%)","items":[{"label":"nœud","value":42}]}, '
|
||||
'{"type":"text","title":"Remarques","text":"..."} ]}. '
|
||||
'N’utilise que les types metric, list, table, bars ou text, uniquement les cartes pertinentes. '
|
||||
'Les exemples du schéma ci-dessus sont illustratifs, pas des valeurs à reproduire. '
|
||||
'N’inclus aucun HTML, JavaScript, lien ou action exécutable.'
|
||||
)
|
||||
answer=ask_hermes(instruction)
|
||||
if isinstance(answer,dict):answer=str(answer.get('text') or answer.get('speech') or '')
|
||||
try:
|
||||
return normalize_dashboard(extract_dashboard(answer),prompt=prompt)
|
||||
except ValueError:
|
||||
return _fallback(answer,prompt)
|
||||
|
||||
|
||||
def _same_origin():
|
||||
if request.headers.get('X-Alfred-Request')!='1' or not request.is_json:return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin:
|
||||
p=urlsplit(origin)
|
||||
return p.scheme in ('http','https') and p.netloc==request.host
|
||||
return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none')
|
||||
|
||||
|
||||
def register_dynamic(app, submit):
|
||||
bp=Blueprint('dona_dynamic_v8',__name__)
|
||||
@bp.get('/api/dona/dynamic/live')
|
||||
def live():
|
||||
source=request.args.get('source','')
|
||||
try:
|
||||
if source=='spotify':board=_live_spotify('Tableau Spotify')
|
||||
elif source=='homeassistant':board=_live_temperatures('Tableau températures')
|
||||
elif source=='lights':board=_live_lights('Tableau lumières')
|
||||
else:return jsonify(ok=False,error='Source invalide.'),400
|
||||
response=jsonify(ok=True,dashboard=board)
|
||||
response.headers['Cache-Control']='no-store, private'
|
||||
return response
|
||||
except Exception:
|
||||
app.logger.exception('Dona dynamic live')
|
||||
return jsonify(ok=False,error='Données momentanément indisponibles.'),503
|
||||
|
||||
@bp.post('/api/dona/dynamic/lights/toggle')
|
||||
def lights_toggle():
|
||||
if not _same_origin():return jsonify(ok=False,error='Requête non autorisée.'),403
|
||||
data=request.get_json(silent=True)
|
||||
eid=data.get('entity_id') if isinstance(data,dict) else None
|
||||
if not isinstance(eid,str) or not re.fullmatch(r'light\.[a-z0-9_]{1,100}',eid):
|
||||
return jsonify(ok=False,error='Entité invalide.'),400
|
||||
try:
|
||||
from services import ha_views
|
||||
# Refuse une entité qui n'existe pas dans HA.
|
||||
if not any(x.get('entity_id')==eid and x.get('state') in ('on','off') for x in (ha_views.states() or []) if isinstance(x,dict)):
|
||||
return jsonify(ok=False,error='Lumière absente de Home Assistant.'),404
|
||||
ha_views._request('POST','/api/services/light/toggle',payload={'entity_id':eid})
|
||||
return jsonify(ok=True)
|
||||
except Exception:
|
||||
app.logger.exception('Dona dynamic light action')
|
||||
return jsonify(ok=False,error='Home Assistant n’a pas exécuté la commande.'),503
|
||||
|
||||
@bp.post('/api/dona/dynamic/create')
|
||||
def create():
|
||||
if not _same_origin():return jsonify(ok=False,error='Origine non autorisée.'),403
|
||||
if request.content_length and request.content_length>3000:return jsonify(ok=False,error='Requête trop volumineuse.'),413
|
||||
body=request.get_json(silent=True)
|
||||
prompt=body.get('prompt') if isinstance(body,dict) else None
|
||||
if not isinstance(prompt,str) or not 2<=len(prompt.strip())<=MAX_PROMPT:
|
||||
return jsonify(ok=False,error='Demande de 2 à 500 caractères requise.'),400
|
||||
try:queued=submit(prompt.strip())
|
||||
except Exception:
|
||||
app.logger.exception('Dona dynamic submit')
|
||||
return jsonify(ok=False,error='Impossible de créer la vue.'),503
|
||||
if not queued:return jsonify(ok=False,error='Une autre demande est en cours.'),409
|
||||
return jsonify(ok=True,queued=True),202
|
||||
app.register_blueprint(bp)
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/* DONA_V8_DYNAMIC — data-only dashboards. All user/model text is textContent, never HTML. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const ID='donaDynamicV8';
|
||||
if(document.getElementById(ID))return;
|
||||
const $=id=>document.getElementById(id);
|
||||
const el=(tag,cls,text)=>{
|
||||
const item=document.createElement(tag);if(cls)item.className=cls;
|
||||
if(text!==undefined)item.textContent=String(text);return item;
|
||||
};
|
||||
const btn=(text,fn,cls='d8-btn')=>{const b=el('button',cls,text);b.type='button';b.addEventListener('click',fn);return b;};
|
||||
let active=null,opened=false,busy=false,ticker=null;
|
||||
// DONA_DYNAMIC_PERF_V2 — HTTP 202 is queue acceptance, not generation completion.
|
||||
let creating=false, createTimer=null;
|
||||
const CREATE_MAX_WAIT_MS=240000;
|
||||
function updateCreationControls(){
|
||||
const root=document.getElementById(ID);if(!root)return;
|
||||
root.querySelectorAll('.d8-submit, .d8-examples button').forEach(button=>{
|
||||
button.disabled=creating;button.setAttribute('aria-disabled',String(creating));
|
||||
});
|
||||
}
|
||||
function finishCreating(){
|
||||
if(createTimer!==null){clearTimeout(createTimer);createTimer=null;}
|
||||
creating=false;updateCreationControls();
|
||||
}
|
||||
function startCreating(){
|
||||
creating=true;updateCreationControls();
|
||||
if(createTimer!==null)clearTimeout(createTimer);
|
||||
createTimer=setTimeout(()=>{
|
||||
finishCreating();
|
||||
if(opened)status('Confirmation non reçue. Tu peux réessayer ; une génération peut encore être en cours côté serveur.');
|
||||
},CREATE_MAX_WAIT_MS);
|
||||
}
|
||||
const KEY='dona.dynamic.pins.v8';
|
||||
const safeGetPins=()=>{
|
||||
try{const pins=JSON.parse(localStorage.getItem(KEY)||'[]');return Array.isArray(pins)?pins.slice(0,8):[];}
|
||||
catch(_){return []}
|
||||
};
|
||||
const safeSavePins=list=>{try{localStorage.setItem(KEY,JSON.stringify(list.slice(0,8)));}catch(_){status('Stockage local indisponible.');}};
|
||||
function status(text){$('d8Status').textContent=text||'';}
|
||||
function makeModal(){
|
||||
const root=el('div','d8-overlay');root.id=ID;root.hidden=true;
|
||||
root.setAttribute('role','dialog');root.setAttribute('aria-modal','true');root.setAttribute('aria-label','Tableaux dynamiques Dona');
|
||||
const panel=el('section','d8-panel');
|
||||
const head=el('header','d8-head');
|
||||
const ident=el('div','d8-brand');ident.append(el('span','d8-eyebrow','DONA · INTERFACE DYNAMIQUE'),el('h1','d8-title','Tableaux de bord'));
|
||||
head.append(ident,btn('×',close,'d8-close'));panel.append(head);
|
||||
const bar=el('form','d8-compose');bar.id='d8Compose';
|
||||
const input=el('input','d8-input');input.id='d8Input';input.required=true;input.maxLength=500;
|
||||
input.placeholder='Ex. Tableau de bord Proxmox, Wazuh, lumières…';input.setAttribute('aria-label','Demande de tableau dynamique');
|
||||
bar.append(input,btn('Créer',()=>{},'d8-submit'));bar.lastChild.type='submit';
|
||||
bar.addEventListener('submit',e=>{e.preventDefault();create(input.value);});panel.append(bar);
|
||||
const examples=el('div','d8-examples');
|
||||
[['Spotify','Tableau de bord Spotify'],['Lumières','Tableau de bord des lumières'],
|
||||
['Températures','Tableau de bord des températures'],['Proxmox','Tableau de bord Proxmox'],
|
||||
['Wazuh','Tableau de bord Wazuh']].forEach(([text,prompt])=>examples.append(btn(text,()=>{input.value=prompt;create(prompt)},'d8-chip')));
|
||||
panel.append(examples);
|
||||
const actions=el('div','d8-toolbar');
|
||||
actions.append(btn('↻ Actualiser',refresh,'d8-btn'),btn('☆ Épingler',pin,'d8-btn'));
|
||||
actions.children[0].id='d8Refresh'; actions.children[1].id='d8Pin';panel.append(actions);
|
||||
const caption=el('div','d8-note');caption.id='d8Status';caption.setAttribute('role','status');caption.setAttribute('aria-live','polite');panel.append(caption);
|
||||
const pinned=el('div','d8-pinned');pinned.id='d8Pinned';panel.append(pinned);
|
||||
const grid=el('main','d8-grid');grid.id='d8Grid';panel.append(grid);
|
||||
const foot=el('p','d8-footer','Données provenant de Spotify, Home Assistant ou des outils Hermes. Aucun code produit par l’IA n’est exécuté.');panel.append(foot);
|
||||
root.append(panel);document.body.append(root);
|
||||
root.addEventListener('click',e=>{if(e.target===root)close()});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&opened)close();});
|
||||
return root;
|
||||
}
|
||||
makeModal();
|
||||
function open(){opened=true;$(ID).hidden=false;document.body.classList.add('d8-open');$('d8Input').focus();renderPins();schedule();}
|
||||
function close(){opened=false;$(ID).hidden=true;document.body.classList.remove('d8-open');stopTimer();}
|
||||
function stopTimer(){if(ticker){clearInterval(ticker);ticker=null;}}
|
||||
function schedule(){stopTimer();if(!opened||!active||!['spotify','homeassistant','lights'].includes(active.origin))return;
|
||||
ticker=setInterval(()=>{if(!document.hidden&&!busy)reloadLive(false);},active.origin==='spotify'?15000:30000);
|
||||
}
|
||||
function originLabel(origin){return ({spotify:'Spotify · direct',homeassistant:'Home Assistant · direct',lights:'Home Assistant · direct',hermes:'Hermes · MCP',fallback:'Hermes · réponse texte'})[origin]||'Données contextuelles';}
|
||||
const tagText=(parent,tag,cls,text)=>{parent.append(el(tag,cls,text));};
|
||||
function drawCard(c){
|
||||
const section=el('article','d8-card d8-'+c.type);
|
||||
if(c.title)tagText(section,'h2','d8-card-title',c.title);
|
||||
if(c.type==='metric'){
|
||||
const value=el('div','d8-metric-value',c.value??'—');
|
||||
if(c.unit)value.append(el('small','d8-unit',c.unit));section.append(value);
|
||||
if(c.detail)tagText(section,'p','d8-detail',c.detail);
|
||||
} else if(c.type==='text'){
|
||||
tagText(section,'p','d8-text',c.text||'—');
|
||||
} else if(c.type==='list'){
|
||||
const list=el('ul','d8-list');(c.items||[]).forEach(x=>list.append(el('li','',x)));
|
||||
if(!list.childNodes.length)list.append(el('li','','Aucune donnée.'));section.append(list);
|
||||
} else if(c.type==='table'){
|
||||
const scroll=el('div','d8-table-scroll'),table=el('table','d8-table'),thead=el('thead'),headrow=el('tr');
|
||||
(c.columns||[]).forEach(x=>headrow.append(el('th','',x)));thead.append(headrow);table.append(thead);
|
||||
const tbody=el('tbody');(c.rows||[]).forEach(r=>{const tr=el('tr');(r||[]).forEach(v=>tr.append(el('td','',v)));tbody.append(tr);});
|
||||
table.append(tbody);scroll.append(table);section.append(scroll);
|
||||
} else if(c.type==='bars'){
|
||||
const values=(c.items||[]).map(x=>Number(x.value)).filter(x=>Number.isFinite(x));
|
||||
const max=Math.max(1,...values.map(x=>Math.abs(x)));
|
||||
(c.items||[]).forEach(item=>{
|
||||
const row=el('div','d8-bar-row'),line=el('div','d8-bar-line');
|
||||
line.append(el('span','',item.label),el('strong','',String(item.value)+(item.unit?' '+item.unit:'')));
|
||||
const track=el('div','d8-track'),fill=el('div','d8-fill');
|
||||
fill.style.width=Math.max(0,Math.min(100,Math.abs(Number(item.value))/max*100))+'%';
|
||||
track.append(fill);row.append(line,track);section.append(row);
|
||||
});
|
||||
} else if(c.type==='light' && active?.origin==='lights'){
|
||||
const state=String(c.state||'unknown');tagText(section,'p','d8-light-state',state==='on'?'Allumée':state==='off'?'Éteinte':'Indisponible');
|
||||
if(state==='on'||state==='off'){
|
||||
const b=btn(state==='on'?'Éteindre':'Allumer',()=>toggleLight(c.entity_id,c.title),'d8-btn d8-action');section.append(b);
|
||||
}
|
||||
}
|
||||
return section;
|
||||
}
|
||||
function render(board){
|
||||
if(!board||board.schema!=='dona.dashboard.v1'||!Array.isArray(board.cards))return;
|
||||
active=board;open();
|
||||
$('d8Input').value=board.prompt||$('d8Input').value;
|
||||
$('d8Grid').replaceChildren();
|
||||
const sub=el('div','d8-heading');sub.append(el('h2','',board.title||'Tableau de bord'));
|
||||
sub.append(el('p','',board.subtitle||originLabel(board.origin)));
|
||||
if(board.generated_at){const d=new Date(board.generated_at);sub.append(el('small','',`Mis à jour : ${Number.isNaN(d.getTime())?'—':d.toLocaleTimeString('fr-FR')}`));}
|
||||
$('d8Grid').append(sub);
|
||||
if(board.origin==='spotify'){
|
||||
const controls=el('div','d8-player-controls');
|
||||
[['⏮ Précédent','previous'],['▶ Lecture','play'],['Ⅱ Pause','pause'],['⏭ Suivant','next']].forEach(([label,action])=>{
|
||||
controls.append(btn(label,async()=>{
|
||||
try{await post('/api/dona/spotify/control',{action});await reloadLive(false);}
|
||||
catch(e){status('Spotify : '+e.message)}
|
||||
},'d8-btn'));
|
||||
});
|
||||
$('d8Grid').append(controls);
|
||||
}
|
||||
board.cards.slice(0,16).forEach(c=>{if(c&&typeof c==='object')$('d8Grid').append(drawCard(c));});
|
||||
status(originLabel(board.origin)+(board.origin==='hermes'?' · contenu communiqué par l’agent':''));
|
||||
renderPins();schedule();
|
||||
}
|
||||
function renderPins(){
|
||||
const host=$('d8Pinned');host.replaceChildren();
|
||||
const pins=safeGetPins();if(!pins.length)return;
|
||||
host.append(el('span','d8-pins-label','Épinglés :'));
|
||||
pins.forEach((p,i)=>{
|
||||
const item=el('span','d8-pin-item');
|
||||
item.append(btn(p.title||'Tableau '+(i+1),()=>render(p),'d8-chip'));
|
||||
item.append(btn('×',()=>{const rest=safeGetPins().filter((_,j)=>j!==i);safeSavePins(rest);renderPins();},'d8-pin-remove'));
|
||||
host.append(item);
|
||||
});
|
||||
}
|
||||
function pin(){if(!active){status('Crée d’abord un tableau.');return;}
|
||||
const pins=safeGetPins();pins.unshift(active);safeSavePins(pins);renderPins();status('Tableau épinglé dans ce navigateur.');}
|
||||
async function post(url,body){
|
||||
const res=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(body)});
|
||||
const obj=await res.json().catch(()=>({}));if(!res.ok||obj.ok===false){const error=Error(obj.error||'HTTP '+res.status);error.httpStatus=res.status;throw error;}return obj;
|
||||
}
|
||||
async function create(prompt){
|
||||
const query=String(prompt||'').trim();if(query.length<2||query.length>500){status('Décris le tableau en 2 à 500 caractères.');return;}
|
||||
if(creating){status('Une génération est déjà en cours.');return;}
|
||||
if(busy){status('Une actualisation est en cours.');return;}
|
||||
startCreating();status('Dona prépare le tableau…');
|
||||
try{
|
||||
await post('/api/dona/dynamic/create',{prompt:query});
|
||||
// A fast Socket.IO completion may have already cleared creating.
|
||||
if(creating)status('Dona consulte les données ; la vue apparaîtra ici.');
|
||||
}catch(e){
|
||||
if(e.httpStatus===409){
|
||||
// Another tab or another entry point may have submitted the job.
|
||||
// Wait for its completion event, or release after the safety timeout.
|
||||
status('Une génération est déjà en cours côté serveur. Attente du résultat…');
|
||||
}else{
|
||||
finishCreating();status(e.message||'Demande impossible.');
|
||||
}
|
||||
}
|
||||
}
|
||||
async function reloadLive(notify=true){
|
||||
if(!active)return;
|
||||
if(['spotify','homeassistant','lights'].includes(active.origin)){
|
||||
if(busy)return;const current=active;busy=true;
|
||||
try{const r=await fetch('/api/dona/dynamic/live?source='+encodeURIComponent(active.origin),{cache:'no-store',credentials:'same-origin'});
|
||||
const j=await r.json();if(!r.ok||!j.ok)throw Error(j.error||'Source indisponible');
|
||||
if(current===active)render(j.dashboard);
|
||||
}catch(e){status('Actualisation impossible : '+e.message);}finally{busy=false;}
|
||||
} else if(notify && active.prompt){create(active.prompt);}
|
||||
}
|
||||
function refresh(){if(!active){status('Aucun tableau à actualiser.');return;}reloadLive(true);}
|
||||
async function toggleLight(entity,name){
|
||||
if(!active||active.origin!=='lights')return;
|
||||
if(!/^light\.[a-z0-9_]{1,100}$/.test(String(entity)))return;
|
||||
if(!confirm('Changer l’état de « '+String(name).slice(0,100)+' » ?'))return;
|
||||
try{await post('/api/dona/dynamic/lights/toggle',{entity_id:entity});await reloadLive();}
|
||||
catch(e){status('Commande refusée : '+e.message);}
|
||||
}
|
||||
function initButton(){
|
||||
const primary=document.querySelector('#alfredMainV1 .av-head-actions')||document.querySelector('.header-actions');
|
||||
if(!primary)return;
|
||||
if($('d8Open'))return;
|
||||
const b=btn('▦',open,'d8-open-button');b.id='d8Open';b.title='Tableaux dynamiques';b.setAttribute('aria-label','Ouvrir les tableaux de bord dynamiques');
|
||||
primary.prepend(b);
|
||||
}
|
||||
let tries=0;const timer=setInterval(()=>{initButton();if($('d8Open')||++tries>70)clearInterval(timer);},150);
|
||||
const socket=window.alfredSocket;
|
||||
socket?.on('alfred_render',data=>{
|
||||
if(data?.type==='dona_dynamic'){
|
||||
// Only a server result (not a pinned board or HTTP 202) ends the wait.
|
||||
if(data.dashboard?.schema==='dona.dashboard.v1'&&Array.isArray(data.dashboard.cards))finishCreating();
|
||||
render(data.dashboard);
|
||||
}
|
||||
else if(opened && data?.type!=='dona_dynamic')close();
|
||||
});
|
||||
socket?.on('alfred_state',data=>{
|
||||
if(opened && data?.mode==='error'){
|
||||
if(creating)finishCreating();
|
||||
status('Erreur lors de la préparation du tableau.');
|
||||
}
|
||||
});
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden&&opened&&active&&['spotify','homeassistant','lights'].includes(active.origin))reloadLive(false);});
|
||||
window.DonaDynamicV8={open,render,close};
|
||||
})();
|
||||
Reference in New Issue
Block a user