debut illustration

This commit is contained in:
pporcheret
2026-11-02 08:50:05 +01:00
parent e2340807aa
commit c2a4f7eb26
44 changed files with 6856 additions and 19 deletions
@@ -0,0 +1,15 @@
{
"version": "DONA_DYNAMIC_PERF_V2",
"files": {
"static/js/dona-dynamic-v8.js": {
"old": "7d714d6db21e23a97556aa2897fa4eb41867b5cc78a0d119782b13aae46fb284",
"new": "2a61399cb67d8cd207c5249f2eb12cfccc5afb8eec02ae06479245823a5fe9b9",
"mode": 420
},
"services/dona_hermes_stream.py": {
"old": "cf17d6f1df1c513f8e07d9539aaf3e85e72116b22900dbb1f8d12a72df237c61",
"new": "ec37b26e11119531d065f520c8eb7a593f3736a0c337cfc7dbcd3da1da890ac0",
"mode": 420
}
}
}
@@ -0,0 +1,205 @@
"""Dona CLI streaming and timing bridge (Python standard library only).
Does not access Hermes sessions or modify MCP config. Retains the terminal
JSONL result envelope for the existing _parse_jsonl implementation.
"""
from __future__ import annotations
import json
import logging
import os
import signal
import subprocess
import threading
import time
import uuid
_LOG = logging.getLogger("dona.hermes.performance")
_EMITTER = None
_EMITTER_LOCK = threading.Lock()
_FALSE = {"0", "false", "no", "off", "disabled"}
def configure_emitter(emitter):
"""Register Flask-SocketIO event emitter once, after socketio is created."""
global _EMITTER
with _EMITTER_LOCK:
_EMITTER = emitter
def _enabled(name: str, default: str = "on") -> bool:
return os.environ.get(name, default).strip().lower() not in _FALSE
def _publish(msg):
if not _enabled("DONA_HERMES_STREAM_UI", "on"):
return
with _EMITTER_LOCK:
fn = _EMITTER
if fn is not None:
try:
fn(msg)
except Exception:
# A browser disconnect must never corrupt an Hermes turn/session.
_LOG.debug("Dona UI emitter failed", exc_info=True)
def run_jsonl_command(cmd, prompt, timeout):
"""Compatibility replacement for subprocess.run used only by _call_hermes.
Publishes deltas/tool names while keeping exact final JSONL parsing behavior.
Fallback to the historical subprocess.run with DONA_HERMES_STREAM_CLI=off.
"""
if not _enabled("DONA_HERMES_STREAM_CLI", "on"):
return subprocess.run(
cmd, input=prompt, capture_output=True, text=True,
timeout=timeout, check=False,
)
request_id = uuid.uuid4().hex[:12]
start = time.monotonic()
first_text_ms = None
tool_count = 0
text_fragments = 0
emitted_characters = 0
pending_text = ""
last_emit = start
final_seen = False
# Keep only records the existing parser consumes, rather than potentially
# huge tool inputs/outputs and all intermediate assistant deltas.
output_records = []
stderr_records = []
deadline_hit = threading.Event()
proc = None
timer = None
_publish({"type": "start", "id": request_id})
def expire():
deadline_hit.set()
if proc is not None and proc.poll() is None:
try:
# subprocess in its own session: children of this CLI turn
# cannot linger after an actual timeout.
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except OSError:
pass
def collect_stderr():
try:
data = proc.stderr.read()
if data:
stderr_records.append(data)
except Exception:
pass
try:
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, bufsize=1,
start_new_session=True,
)
timer = threading.Timer(float(timeout), expire)
timer.daemon = True
timer.start()
stderr_thread = threading.Thread(target=collect_stderr, daemon=True)
stderr_thread.start()
try:
proc.stdin.write(prompt)
except BrokenPipeError:
pass
finally:
try:
proc.stdin.close()
except OSError:
pass
for line in proc.stdout:
try:
event = json.loads(line)
except (ValueError, TypeError):
# Preserve unrecognized lines to aid existing error diagnostics.
continue
if not isinstance(event, dict):
continue
kind = event.get("type")
now = time.monotonic()
if kind == "system" and event.get("subtype") == "init":
output_records.append(line)
elif kind == "text":
delta = event.get("text")
if isinstance(delta, str) and delta:
text_fragments += 1
if first_text_ms is None:
first_text_ms = round((now - start) * 1000, 1)
_publish({"type": "first_text", "id": request_id,
"first_text_ms": first_text_ms})
# Emit batched, rather than one websocket event/token.
pending_text += delta
if (now - last_emit) >= .12 or len(pending_text) >= 240:
_publish({"type": "delta", "id": request_id,
"text": pending_text})
emitted_characters += len(pending_text)
pending_text = ""
last_emit = now
elif kind == "tool_use":
tool_count += 1
# Do not send tool arguments, user tokens, or raw tool outputs.
_publish({"type": "tool", "id": request_id,
"name": str(event.get("name") or "outil")[:90]})
elif kind == "tool_result":
_publish({"type": "tool_done", "id": request_id,
"name": str(event.get("name") or "outil")[:90],
"duration_ms": event.get("duration_ms")
if isinstance(event.get("duration_ms"), (float, int)) else None,
"error": bool(event.get("is_error"))})
elif kind == "result":
final_seen = True
output_records.append(line)
rc = proc.wait()
stderr_thread.join(timeout=1.0)
total_ms = round((time.monotonic() - start) * 1000, 1)
if deadline_hit.is_set():
_publish({"type": "error", "id": request_id, "reason": "timeout"})
raise subprocess.TimeoutExpired(cmd, timeout, output="".join(output_records),
stderr="".join(stderr_records))
if pending_text:
_publish({"type": "delta", "id": request_id, "text": pending_text})
emitted_characters += len(pending_text)
_publish({"type": "end", "id": request_id,
"total_ms": total_ms, "first_text_ms": first_text_ms,
"ok": rc == 0 and final_seen})
_LOG.warning(
"[DONA-PERF] hermes_cli total_ms=%s first_text_ms=%s tools=%s "
"fragments=%s ui_chars=%s exit=%s final=%s",
total_ms, first_text_ms, tool_count, text_fragments,
emitted_characters, rc, final_seen,
)
# Keep final result + init events exactly as _parse_jsonl expects.
return subprocess.CompletedProcess(
cmd, rc, "".join(output_records), "".join(stderr_records)
)
except Exception:
if not deadline_hit.is_set():
_publish({"type": "error", "id": request_id, "reason": "process_error"})
raise
finally:
if timer:
timer.cancel()
if proc is not None:
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except OSError:
pass
for pipe in (proc.stdin, proc.stdout, proc.stderr):
try:
pipe.close()
except (OSError, ValueError):
pass
@@ -0,0 +1,176 @@
/* 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;
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)throw Error(obj.error||'HTTP '+res.status);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(busy)return;busy=true;status('Dona prépare le tableau…');
try{await post('/api/dona/dynamic/create',{prompt:query});status('Dona consulte les données ; la vue apparaîtra ici.');}
catch(e){status(e.message||'Demande impossible.');}
finally{busy=false;}
}
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')render(data.dashboard);
else if(opened && data?.type!=='dona_dynamic')close();
});
socket?.on('alfred_state',data=>{
if(opened && data?.mode==='error')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};
})();
@@ -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
}
}
}
@@ -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('&#x20;', ' ').replace('&nbsp;', ' ')
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)
@@ -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};
})();
@@ -0,0 +1,32 @@
{
"version": "DONA_HERMES_PERF_V1",
"created_utc": "20261101T152747103028Z",
"modified": {
"services/hermes_client.py": {
"original_sha256": "cdc0dfc6755e2b516c4b89c2e557631d2cfa39ca599bbc4bf5843895fba4b8a0",
"patched_sha256": "39736acf8e8ec4d109207f0592616f2225ee33fe0041f7fe476cdadf9362ad78",
"mode": 420
},
"app.py": {
"original_sha256": "367380be744b53e3cb478b359726e18964971dac16540f88b922e90f932eafa7",
"patched_sha256": "5905244f51a8d3ad639444bd9a204a63d49c0daef4c5c67a754e82752ee98ecb",
"mode": 420
},
"templates/index.html": {
"original_sha256": "8f6a1a7d9fa934d1fefa646976b6cf2b27e0ecc9e1dc0d2adf5bbf9ab3e1c390",
"patched_sha256": "f18c0c6ec11f61f1c961766eef5819cb0a078866222723b54636cf530e3c5030",
"mode": 420
}
},
"added": {
"services/dona_hermes_stream.py": {
"sha256": "cf17d6f1df1c513f8e07d9539aaf3e85e72116b22900dbb1f8d12a72df237c61"
},
"static/js/dona-hermes-stream-v1.js": {
"sha256": "d43280dd4029212fb3e1dbe037cdf11527bca1eae68a5a0e48cf8e4ce78e3857"
},
"static/css/dona-hermes-stream-v1.css": {
"sha256": "035d094a7a896a209e5589164c710b4822ecb2f3632e8aad67250368b9569ac6"
}
}
}
@@ -0,0 +1,929 @@
import os, threading, hmac
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
# Charger la configuration AVANT les imports des services
load_dotenv("/opt/alfred/.env")
load_dotenv(
"/opt/alfred/app/config/control_center.env",
override=True
)
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
from services.piper_tts import stop_playback as duplex_stop_piper
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import dona_clock_local as _dona_clock_local # DONA_CLOCK_SCULPTURE_V11
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
from services import alfred_features # ALFRED_FEATURES_V45
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
from services import camera_settings, ha_weather, weather_outfits
load_dotenv()
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur.
from services import piper_tts as _dona_waves_piper
_dona_waves_piper.dona_set_level_callback(
lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)})
)
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
# Service temps autonome (survit aux conversations Hermes)
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
on_interrupt=duplex_stop_piper,
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
camera_settings.restore_saved_settings()
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
if not conversation_lock.acquire(blocking=False):
return
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
from services.audio import STTResult
r = STTResult(text=injected_text)
else:
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
# DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée.
_dona_v93_original_vosk = r.text
_dona_v93_ack = _dona_learning_v93.voice_feedback(r.text)
if _dona_v93_ack is not None:
socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack})
if not audio_settings.speaker_muted():
_dona_v93_done = threading.Event()
if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set):
_dona_v93_done.wait(timeout=120)
emit_state('idle',partial='')
return
r.text = _dona_learning_v93.correct_transcript(r.text)
if r.text != _dona_v93_original_vosk:
socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text})
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# DONA_CLOCK_SCULPTURE_V11 : heure/date locales AVANT Hermes.
_dona_local_clock_reply = _dona_clock_local.answer(r.text)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,_dona_local_clock_reply))
if not audio_settings.speaker_muted():
emit_state('speaking')
_dona_clock_done=threading.Event()
_dona_clock_started=speak_async(_dona_local_clock_reply,
on_end=lambda: (_dona_clock_done.set(),
socketio.emit('alfred_tts_finished', {'source':'voice'})))
if _dona_clock_started:_dona_clock_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
if _dona_vision_v95.is_camera_question(r.text):
try:
_vision_answer = _dona_vision_monitor.analyze(r.text)
except RuntimeError as _vision_exc:
_vision_answer = str(_vision_exc)
except Exception as _vision_exc:
print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True)
_vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer))
if _vision_answer and not audio_settings.speaker_muted():
emit_state('speaking')
_vision_done=threading.Event()
if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_FILES_V91 : questions explicites sur la pièce jointe active.
if _dona_files_v91.is_file_question(r.text):
_file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech))
if _file_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_file_done = threading.Event()
if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120)
emit_state('idle', partial='')
return
# DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes.
if _dona_suite_v9.is_request(r.text):
_v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech))
if _v9_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_v9_done=threading.Event()
_v9_ok=speak_async(_v9_speech,on_end=_v9_done.set)
if _v9_ok:_v9_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM.
if _dona_nav.is_request(r.text):
_nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply))
if _nav_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_nav_done=threading.Event()
_nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'})))
if _nav_ok:_nav_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix.
if _dona_youtube.is_search(r.text):
_speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech))
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_evt=threading.Event()
_ok=speak_async(_speech,on_end=_evt.set)
if _ok: _evt.wait(timeout=120)
emit_state('idle',partial='')
return
if _dona_youtube.is_control(r.text,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(r.text,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
if _dona_dynamic.is_dynamic_request(r.text) or _dona_json_autorender.is_monitor_request(r.text):
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic', 'dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render', _payload)
if not audio_settings.speaker_muted():
emit_state('speaking')
_finished = threading.Event()
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
if _started: _finished.wait(timeout=120)
emit_state('idle', partial='')
return
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
done.wait(timeout=120)
emit_state("idle", partial="")
return
# ALFRED_LISTES_GLASS_V1_VOICE_START
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
try:
_lists_reply = _alfred_handle_list_voice(
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
except Exception as _lists_error:
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
if _lists_reply is not None:
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply))
_lists_display = {'type':'text', 'text':_lists_reply}
with STATE_LOCK:
STATE['content'] = _lists_display
socketio.emit('alfred_render', _lists_display)
if _lists_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_lists_done = threading.Event()
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_lists_done.wait(timeout=120)
emit_state('idle', partial='')
return
# ALFRED_LISTES_GLASS_V1_VOICE_END
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(r.text))
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
display, speech = _dona_json_autorender.convert(answer, r.text, display, speech)
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
alfred_context_images.schedule_context_image(
r.text, speech, socketio,
lambda: STATE.get("transcript") == r.text,
) # ALFRED_TIMERS_CONTEXT_V1
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
alfred_features.publish_voice_display(display)
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
try:
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
if started:
done.wait(timeout=120)
finally:
if not duplex_monitor.interrupted():
duplex_monitor.stop()
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
if duplex_monitor.interrupted():
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
duplex_monitor.stop()
else:
# A few wake-free seconds after the assistant finishes speaking.
pending_duplex_text = duplex_monitor.listen_conversation()
if pending_duplex_text:
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
# Only the voice is cancelled; the original Hermes job is untouched.
wake.pause()
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
daemon=True, name='dona-duplex-followup').start()
elif not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@app.get('/api/duplex/status')
def duplex_status():
return jsonify(duplex_monitor.status())
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
def _alfred_dashboard_quick_command(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _run():
try:
wake.pause()
with STATE_LOCK:
_image_revision_before = IMAGE_REVISION
emit_state('thinking', transcript=prompt, partial='',
content={'type':'text', 'text':prompt})
# DONA_JSON_AUTORENDER_V1: operational shortcuts use the isolated
# dashboard session; ordinary shortcuts remain regular Hermes calls.
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
_display = {'type':'dona_dynamic','dashboard':_board}
_speech = 'Voici le tableau de bord : ' + _board['title']
else:
_answer = ask_hermes(prompt)
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
with STATE_LOCK:
if IMAGE_REVISION == _image_revision_before:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_done = threading.Event()
speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_done.wait(timeout=120)
emit_state('idle', partial='')
except Exception as _exc:
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
emit_state('error', content={'type':'text', 'text':str(_exc)})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
return True
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
# ALFRED_ADMIN_GLASS_MAISON_V1_START
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
_alfred_register_home_glass(app)
# ALFRED_ADMIN_GLASS_MAISON_V1_END
# ALFRED_LISTES_GLASS_V1_BACKEND_START
from services.alfred_lists import register_lists as _alfred_register_lists
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
_alfred_register_lists(app, socketio)
# ALFRED_LISTES_GLASS_V1_BACKEND_END
# ALFRED_GLASS_V2_BACKEND_START
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
_alfred_register_logs_v2(app)
# ALFRED_GLASS_V2_BACKEND_END
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
_alfred_identity_register(app)
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
_alfred_developer_register(app)
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
_register_hermes_new_session(app)
# DONA_FAMILY_UI_V1_START
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
def _dona_submit_typed(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _dona_run_typed():
try:
wake.pause()
with STATE_LOCK:
_before_image = IMAGE_REVISION
emit_state('thinking', partial='', transcript=prompt,
content={'type': 'text', 'text': prompt})
# DONA_CLOCK_SCULPTURE_V11 : même traitement depuis Écrire à Dona / raccourcis.
_dona_local_clock_reply = _dona_clock_local.answer(prompt)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_dona_local_clock_reply))
emit_state('idle',partial='')
return
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
if _dona_vision_v95.is_camera_question(prompt):
try:
_vision_answer=_dona_vision_monitor.analyze(prompt)
except Exception as _vision_exc:
_vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer))
emit_state('idle',partial='')
return
# DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe.
if _dona_files_v91.is_file_question(prompt):
_dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK)
emit_state('idle',partial='')
return
# DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes.
if _dona_suite_v9.is_request(prompt):
_v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes.
if _dona_nav.is_request(prompt):
_v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
if _dona_youtube.is_search(prompt):
_v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
if _dona_youtube.is_control(prompt,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(prompt,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render',_payload)
emit_state('idle', partial='')
return
_answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt))
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
# DONA_LEARNING_V93 : évaluation de l’échange écrit.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_speech))
# Deliberately no speak_async(): typed interaction is silent.
emit_state('idle', partial='')
except Exception as exc:
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
import traceback
traceback.print_exc()
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
return True
from services.dona_family_ui import register_dona_family as _dona_register_family
_dona_register_family(app, adult_command=_dona_submit_typed)
# DONA_FAMILY_UI_V1_END
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
from services import dona_dynamic as _dona_dynamic
# DONA_JSON_AUTORENDER_V1 : rendu automatique JSON Hermes, demandes et raccourcis
from services import dona_json_autorender_v1 as _dona_json_autorender
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
from services import dona_dashboard_agent as _dona_dashboard_agent
def _dona_dynamic_submit(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _work():
try:
wake.pause()
emit_state('thinking',transcript=prompt,partial='')
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
_payload={'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content']=_payload
socketio.emit('alfred_render',_payload)
emit_state('idle',partial='')
except Exception as exc:
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
return True
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in.
from services import dona_vision_v95 as _dona_vision_v95
_dona_vision_monitor = _dona_vision_v95.register(app, socketio)
# DONA_LEARNING_V93 : retours explicites et corrections approuvées.
from services import dona_learning_v93 as _dona_learning_v93
_dona_learning_v93.register(app)
# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur.
from services import dona_shortcuts_v92 as _dona_shortcuts_v92
_dona_shortcuts_v92.register(app)
# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée.
from services import dona_files_v91 as _dona_files_v91
_dona_files_v91.register_files(app)
# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines.
from services import dona_suite_v9 as _dona_suite_v9
_dona_suite_v9.register_suite(app,socketio)
# DONA_NAV_V83: géocodage, carte et transfert Waze.
from services import dona_navigation as _dona_nav
_dona_nav.register_navigation(app,socketio)
# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé.
from services import dona_youtube as _dona_youtube
_dona_youtube.register_youtube(app,socketio)
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
_register_dona_spotify(app)
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
from services.dona_face import (register_face as _dona_register_face,
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,480 @@
"""Pont Alfred -> Hermes avec session persistante."""
import fcntl
import json
import os
import re
import subprocess
import tempfile
import threading
from contextlib import contextmanager
from pathlib import Path
# --------------------------------------------------
# CONFIGURATION
# --------------------------------------------------
DEFAULT_IDENTITY_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "identity.md"
)
DEFAULT_SESSION_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "hermes_session.json"
)
_REQUEST_LOCK = threading.Lock()
_ID_PATTERN = re.compile(
r"^[A-Za-z0-9_:.\-]{1,128}$"
)
# --------------------------------------------------
# IDENTITÉ D'ALFRED
# --------------------------------------------------
def _build_prompt(question: str) -> str:
"""Send only the real user utterance. Personality is configured in Hermes SOUL.md.
The previous identity.md wrapper caused absent/unknown-tool decisions in
both Spotify and Home Assistant. Never inject it into each user turn.
"""
return str(question or '').strip()
def _toolsets_for(question: str) -> str | None:
"""Conservative domain routing to avoid huge MCP schemas on simple turns.
Cross-domain, ambiguous, or generic requests retain Hermes' full toolset.
Set HERMES_TOOLSET_ROUTING=off for instant rollback without code changes.
"""
if os.getenv('HERMES_TOOLSET_ROUTING', 'on').lower() not in ('on','1','true','yes'):
return None
q = str(question or '').casefold()
domains = {
'spotify': (r'\bspotify\b|\bplaylist\b|\bmusique\b|\bmorceau\b|\bchanson\b|\balbum\b|\bspotify connect\b'),
'homeassistant': (r'\blumi[eè]res?\b|\blampes?\b|\bvolets?\b|\bdomotique\b|\bthermostat\b|\bchauffage\b|\bprises?\b|\bhome assistant\b|\ballume\b|\b[eé]teins?\b'),
'face_recognition': (r'\bface_recognition\b|\bidentify_people\b|\breconnaissance faciale\b'), # DONA_V7_RELEASE
'proxmox': (r'\bproxmox\b|\bcluster proxmox\b'), # DONA_V8_DYNAMIC
'wazuh': (r'\bwazuh\b'),
}
import re as _re
matching = [domain for domain, pattern in domains.items() if _re.search(pattern, q)]
# Combined or potentially compound queries keep full access to every MCP.
if len(matching) != 1:
return None
if _re.search(r'\b(et aussi|en m[eê]me temps|puis v[eé]rifie|compare avec|cherche sur|envoie|par mail|gmail|calendrier|agenda|comfyui|document|paperless)\b', q):
return None
return {'spotify':'spotify', 'homeassistant':'homeassistant,home_assistant', 'face_recognition':'face_recognition', 'proxmox':'proxmox', 'wazuh':'wazuh'}[matching[0]]
# --------------------------------------------------
# FICHIER DE SESSION
# --------------------------------------------------
def _session_file() -> Path:
return Path(
os.getenv(
"HERMES_SESSION_FILE",
str(DEFAULT_SESSION_FILE)
)
).expanduser()
def _read_session(path: Path) -> str | None:
if not path.exists():
return None
try:
obj = json.loads(
path.read_text(encoding="utf-8")
)
sid = obj["session_id"]
except (
OSError,
ValueError,
KeyError,
TypeError
) as exc:
raise RuntimeError(
f"État de session Hermes invalide "
f"({path}) : {exc}"
) from exc
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
f"Identifiant de session Hermes "
f"invalide dans {path}"
)
return sid
def _save_session(path: Path, sid: str):
if not _ID_PATTERN.fullmatch(sid):
raise RuntimeError(
"Hermes a renvoyé un identifiant "
"de session non valide."
)
path.parent.mkdir(
parents=True,
exist_ok=True
)
temp_path = None
try:
fd, temp_name = tempfile.mkstemp(
prefix=".hermes_session_",
suffix=".tmp",
dir=path.parent
)
temp_path = Path(temp_name)
with os.fdopen(
fd,
"w",
encoding="utf-8"
) as handle:
json.dump(
{"session_id": sid},
handle,
indent=2
)
handle.write("\n")
os.replace(temp_path, path)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
# --------------------------------------------------
# VERROUILLAGE SESSION
# --------------------------------------------------
@contextmanager
def _session_lock(path: Path):
path.parent.mkdir(
parents=True,
exist_ok=True
)
lock_path = path.with_name(
path.name + ".lock"
)
fd = os.open(
lock_path,
os.O_CREAT | os.O_RDWR,
0o600
)
with os.fdopen(fd, "w") as handle:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_EX
)
try:
yield
finally:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_UN
)
# --------------------------------------------------
# CONFIGURATION HERMES
# --------------------------------------------------
def _hermes_binary() -> str:
return os.getenv(
"HERMES_CLI",
"/root/.local/bin/hermes"
)
def _hermes_timeout() -> float:
return max(
10.0,
float(
os.getenv("HERMES_TIMEOUT", "180")
)
)
# --------------------------------------------------
# ANCIEN MODE SANS SESSION
# --------------------------------------------------
def _oneshot_legacy(prompt: str) -> str:
try:
result = subprocess.run(
[
_hermes_binary(),
"--oneshot",
prompt
],
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except subprocess.CalledProcessError as exc:
details = (
exc.stderr
or exc.stdout
or "aucun détail"
).strip()
raise RuntimeError(
f"Hermes a échoué "
f"(code {exc.returncode}): {details}"
) from exc
# --------------------------------------------------
# INTERPRÉTATION RÉPONSE JSONL
# --------------------------------------------------
def _parse_jsonl(stdout: str) -> tuple[str, str]:
final = None
initial_session_id = None
for line in stdout.splitlines():
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(event, dict):
continue
if (
event.get("type") == "system"
and event.get("subtype") == "init"
):
initial_session_id = event.get(
"session_id"
)
if event.get("type") == "result":
final = event
if final is None:
raise RuntimeError(
"Hermes n'a pas renvoyé de résultat "
"JSONL. Vérifie la prise en charge "
"de --format stream-json."
)
if final.get("exit_code", 0) != 0:
raise RuntimeError(
f"Hermes : "
f"{final.get('error') or 'échec'}"
)
sid = (
final.get("session_id")
or initial_session_id
)
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
"Hermes n'a pas fourni "
"d'identifiant de session utilisable."
)
answer = final.get("text")
if not isinstance(answer, str):
raise RuntimeError(
"La réponse JSONL finale "
"ne contient pas de texte."
)
return answer.strip(), sid
# --------------------------------------------------
# APPEL HERMES AVEC REPRISE DE SESSION
# --------------------------------------------------
def _call_hermes(
prompt: str,
session_id: str | None
) -> tuple[str, str]:
cmd = [
_hermes_binary(),
"chat",
"--oneshot",
"--query-file",
"-",
"--format",
"stream-json",
"--source",
"cli"
]
route = _toolsets_for(prompt)
if route and not session_id:
cmd.extend(["--toolsets", route])
if session_id:
cmd.extend([
"--resume",
session_id
])
try:
process = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=False
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except FileNotFoundError as exc:
raise RuntimeError(
f"Hermes introuvable : "
f"{_hermes_binary()}"
) from exc
if process.returncode != 0:
msg = (
process.stderr
or process.stdout
or "aucun détail"
).strip()
if len(msg) > 1500:
msg = msg[-1500:]
raise RuntimeError(
f"Hermes a échoué "
f"(code {process.returncode}) : "
f"{msg}"
)
return _parse_jsonl(process.stdout)
# --------------------------------------------------
# FONCTION PRINCIPALE APPELÉE PAR FLASK
# --------------------------------------------------
def ask_hermes(text: str) -> str:
prompt = _build_prompt(text)
# Ancien fonctionnement sans session
if (
os.getenv(
"HERMES_SESSION_MODE",
"persistent"
).lower() == "legacy"
):
return _oneshot_legacy(prompt)
base_path = _session_file()
route = _toolsets_for(prompt)
if route:
safe_route = re.sub(
r"[^a-z0-9]+", "_", route.lower()
).strip("_")
path = base_path.with_name(
f"{base_path.stem}.{safe_route}{base_path.suffix}"
)
else:
path = base_path
# Empêche les appels concurrents
with _REQUEST_LOCK, _session_lock(path):
# Recherche la session précédente
current_sid = _read_session(path)
# Envoie la question à Hermes
answer, new_sid = _call_hermes(
prompt,
current_sid
)
# Sauvegarde l'identifiant
_save_session(
path,
new_sid
)
print(
f"[ALFRED] Session Hermes : "
f"{new_sid} "
f"({'reprise' if current_sid else 'création'})",
flush=True
)
return answer
@@ -0,0 +1,266 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
<!-- ALFRED_MAIN_VISUAL_V1 -->
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
<!-- ALFRED_LISTES_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
<!-- ALFRED_GLASS_V2 -->
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
<!-- ALFRED_REPONSE_CLOSE_V1 -->
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
<!-- DONA_FAMILY_UI_V1 -->
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
<!-- DONA_V7_RELEASE -->
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
<!-- DONA_V8_DYNAMIC -->
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
<!-- DONA_V8_1_ISOLATION -->
<link rel="stylesheet" href="/static/css/dona-header-v8-1.css?v=81">
<!-- DONA_YOUTUBE_V82 -->
<link rel="stylesheet" href="/static/css/dona-youtube-v8-2.css?v=82">
<!-- DONA_NAV_V83 -->
<link rel="stylesheet" href="/static/css/dona-navigation-v8-3.css?v=83">
<!-- DONA_SUITE_V9 -->
<link rel="stylesheet" href="/static/css/dona-suite-v9.css?v=90">
<!-- DONA_FILES_V91 -->
<link rel="stylesheet" href="/static/css/dona-files-v9-1.css?v=91">
<!-- DONA_UI_V92 -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2.css?v=92">
<!-- DONA_UI_V921_CLOSE -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2-1-close.css?v=921">
<!-- DONA_LEARNING_V93 -->
<link rel="stylesheet" href="/static/css/dona-learning-v9-3.css?v=93">
<!-- DONA_VISION_V95 -->
<link rel="stylesheet" href="/static/css/dona-vision-v9-5.css?v=95">
<!-- DONA_WAVES_V1 -->
<link rel="stylesheet" href="/static/css/dona-waves-v1.css?v=1">
<!-- DONA_REPLY_FLOATING_V1 -->
<link rel="stylesheet" href="/static/css/dona-reply-floating-v1.css?v=1">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
<path d="m16.5 10 5-3v10l-5-3"/>
</svg>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
<div id="alfredTimersList"></div>
</aside>
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
aria-modal="true" hidden>
<article class="alfred-context-card">
<button id="alfredContextClose" class="alfred-context-close" type="button"
aria-label="Fermer l'illustration" title="Fermer">×</button>
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
<div class="alfred-context-details">
<h2 id="alfredContextTitle"></h2>
<p id="alfredContextText"></p>
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
</div>
</article>
</div>
<script src="/static/js/alfred-timers-context.js"></script>
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
<script src="/static/js/alfred-main-v1.js?v=1"></script>
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<script src="/static/js/alfred-maison-card.js?v=1"></script>
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
<script src="/static/js/alfred-main-v2.js?v=2"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
<script defer src="/static/js/dona-spotify.js?v=6"></script>
<!-- dona-ui-fix start -->
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
<!-- dona-ui-fix end -->
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
<script defer src="/static/js/dona-header-v8-1.js?v=81"></script>
<script defer src="/static/js/dona-youtube-v8-2.js?v=82"></script>
<script defer src="/static/js/dona-navigation-v8-3.js?v=83"></script>
<script defer src="/static/js/dona-suite-v9.js?v=90"></script>
<script defer src="/static/js/dona-files-v9-1.js?v=91"></script>
<script defer src="/static/js/dona-ui-v9-2.js?v=92"></script>
<script defer src="/static/js/dona-ui-v9-2-1-close.js?v=921"></script>
<script defer src="/static/js/dona-learning-v9-3.js?v=93"></script>
<script defer src="/static/js/dona-vision-v9-5.js?v=95"></script>
<!-- DONA_WAVES_V1 -->
<script defer src="/static/js/dona-waves-v1.js?v=1"></script>
<!-- DONA_REPLY_FLOATING_V1 -->
<script defer src="/static/js/dona-reply-floating-v1.js?v=1"></script>
</body>
</html>
@@ -0,0 +1,25 @@
{
"version": "DONA_UNIFIED_ROUTING_V4",
"files": {
"app.py": {
"old": "5905244f51a8d3ad639444bd9a204a63d49c0daef4c5c67a754e82752ee98ecb",
"new": "bf6466ec54c06500bbcfb79a0e2bd986134edb254855c8123bb64ab30af00fc2",
"mode": 420
},
"services/hermes_client.py": {
"old": "39736acf8e8ec4d109207f0592616f2225ee33fe0041f7fe476cdadf9362ad78",
"new": "7cdf8b45601a1e7b2c26423acfe8af002df1c19d649cdb84c73e505d41fa46cb",
"mode": 420
},
"static/js/dona-dynamic-v8.js": {
"old": "9f5762e6f6b2dfd7f1924261259a2f90e77ceb067c83f323ab55a301ea62a49d",
"new": "4e231a7a8227bd379cfda43d4f7977d011ac507243ebe155c3b92f47b170ed83",
"mode": 420
},
"services/dona_unified_router_v4.py": {
"old": null,
"new": "5ffc53c57fd0fc9ce6ff9116e53e156236aff737abd6bfa86a7a53cd08b8f48d",
"mode": 420
}
}
}
@@ -0,0 +1,933 @@
import os, threading, hmac
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
# Charger la configuration AVANT les imports des services
load_dotenv("/opt/alfred/.env")
load_dotenv(
"/opt/alfred/app/config/control_center.env",
override=True
)
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
from services.piper_tts import stop_playback as duplex_stop_piper
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import dona_clock_local as _dona_clock_local # DONA_CLOCK_SCULPTURE_V11
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
from services import alfred_features # ALFRED_FEATURES_V45
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
from services import camera_settings, ha_weather, weather_outfits
load_dotenv()
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
# DONA_HERMES_PERF_V1: progression non bloquante des réponses Hermes
from services.dona_hermes_stream import configure_emitter as _dona_stream_emitter
_dona_stream_emitter(lambda event: socketio.emit("dona_hermes_stream", event))
# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur.
from services import piper_tts as _dona_waves_piper
_dona_waves_piper.dona_set_level_callback(
lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)})
)
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
# Service temps autonome (survit aux conversations Hermes)
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
on_interrupt=duplex_stop_piper,
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
camera_settings.restore_saved_settings()
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
if not conversation_lock.acquire(blocking=False):
return
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
from services.audio import STTResult
r = STTResult(text=injected_text)
else:
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
# DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée.
_dona_v93_original_vosk = r.text
_dona_v93_ack = _dona_learning_v93.voice_feedback(r.text)
if _dona_v93_ack is not None:
socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack})
if not audio_settings.speaker_muted():
_dona_v93_done = threading.Event()
if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set):
_dona_v93_done.wait(timeout=120)
emit_state('idle',partial='')
return
r.text = _dona_learning_v93.correct_transcript(r.text)
if r.text != _dona_v93_original_vosk:
socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text})
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# DONA_CLOCK_SCULPTURE_V11 : heure/date locales AVANT Hermes.
_dona_local_clock_reply = _dona_clock_local.answer(r.text)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,_dona_local_clock_reply))
if not audio_settings.speaker_muted():
emit_state('speaking')
_dona_clock_done=threading.Event()
_dona_clock_started=speak_async(_dona_local_clock_reply,
on_end=lambda: (_dona_clock_done.set(),
socketio.emit('alfred_tts_finished', {'source':'voice'})))
if _dona_clock_started:_dona_clock_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
if _dona_vision_v95.is_camera_question(r.text):
try:
_vision_answer = _dona_vision_monitor.analyze(r.text)
except RuntimeError as _vision_exc:
_vision_answer = str(_vision_exc)
except Exception as _vision_exc:
print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True)
_vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer))
if _vision_answer and not audio_settings.speaker_muted():
emit_state('speaking')
_vision_done=threading.Event()
if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_FILES_V91 : questions explicites sur la pièce jointe active.
if _dona_files_v91.is_file_question(r.text):
_file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech))
if _file_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_file_done = threading.Event()
if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120)
emit_state('idle', partial='')
return
# DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes.
if _dona_suite_v9.is_request(r.text):
_v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech))
if _v9_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_v9_done=threading.Event()
_v9_ok=speak_async(_v9_speech,on_end=_v9_done.set)
if _v9_ok:_v9_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM.
if _dona_nav.is_request(r.text):
_nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply))
if _nav_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_nav_done=threading.Event()
_nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'})))
if _nav_ok:_nav_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix.
if _dona_youtube.is_search(r.text):
_speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech))
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_evt=threading.Event()
_ok=speak_async(_speech,on_end=_evt.set)
if _ok: _evt.wait(timeout=120)
emit_state('idle',partial='')
return
if _dona_youtube.is_control(r.text,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(r.text,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
if _dona_dynamic.is_dynamic_request(r.text) or _dona_json_autorender.is_monitor_request(r.text):
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic', 'dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render', _payload)
if not audio_settings.speaker_muted():
emit_state('speaking')
_finished = threading.Event()
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
if _started: _finished.wait(timeout=120)
emit_state('idle', partial='')
return
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
done.wait(timeout=120)
emit_state("idle", partial="")
return
# ALFRED_LISTES_GLASS_V1_VOICE_START
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
try:
_lists_reply = _alfred_handle_list_voice(
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
except Exception as _lists_error:
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
if _lists_reply is not None:
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply))
_lists_display = {'type':'text', 'text':_lists_reply}
with STATE_LOCK:
STATE['content'] = _lists_display
socketio.emit('alfred_render', _lists_display)
if _lists_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_lists_done = threading.Event()
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_lists_done.wait(timeout=120)
emit_state('idle', partial='')
return
# ALFRED_LISTES_GLASS_V1_VOICE_END
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(r.text))
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
display, speech = _dona_json_autorender.convert(answer, r.text, display, speech)
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
alfred_context_images.schedule_context_image(
r.text, speech, socketio,
lambda: STATE.get("transcript") == r.text,
) # ALFRED_TIMERS_CONTEXT_V1
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
alfred_features.publish_voice_display(display)
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
try:
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
if started:
done.wait(timeout=120)
finally:
if not duplex_monitor.interrupted():
duplex_monitor.stop()
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
if duplex_monitor.interrupted():
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
duplex_monitor.stop()
else:
# A few wake-free seconds after the assistant finishes speaking.
pending_duplex_text = duplex_monitor.listen_conversation()
if pending_duplex_text:
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
# Only the voice is cancelled; the original Hermes job is untouched.
wake.pause()
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
daemon=True, name='dona-duplex-followup').start()
elif not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@app.get('/api/duplex/status')
def duplex_status():
return jsonify(duplex_monitor.status())
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
def _alfred_dashboard_quick_command(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _run():
try:
wake.pause()
with STATE_LOCK:
_image_revision_before = IMAGE_REVISION
emit_state('thinking', transcript=prompt, partial='',
content={'type':'text', 'text':prompt})
# DONA_JSON_AUTORENDER_V1: operational shortcuts use the isolated
# dashboard session; ordinary shortcuts remain regular Hermes calls.
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
_display = {'type':'dona_dynamic','dashboard':_board}
_speech = 'Voici le tableau de bord : ' + _board['title']
else:
_answer = ask_hermes(prompt)
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
with STATE_LOCK:
if IMAGE_REVISION == _image_revision_before:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_done = threading.Event()
speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_done.wait(timeout=120)
emit_state('idle', partial='')
except Exception as _exc:
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
emit_state('error', content={'type':'text', 'text':str(_exc)})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
return True
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
# ALFRED_ADMIN_GLASS_MAISON_V1_START
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
_alfred_register_home_glass(app)
# ALFRED_ADMIN_GLASS_MAISON_V1_END
# ALFRED_LISTES_GLASS_V1_BACKEND_START
from services.alfred_lists import register_lists as _alfred_register_lists
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
_alfred_register_lists(app, socketio)
# ALFRED_LISTES_GLASS_V1_BACKEND_END
# ALFRED_GLASS_V2_BACKEND_START
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
_alfred_register_logs_v2(app)
# ALFRED_GLASS_V2_BACKEND_END
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
_alfred_identity_register(app)
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
_alfred_developer_register(app)
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
_register_hermes_new_session(app)
# DONA_FAMILY_UI_V1_START
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
def _dona_submit_typed(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _dona_run_typed():
try:
wake.pause()
with STATE_LOCK:
_before_image = IMAGE_REVISION
emit_state('thinking', partial='', transcript=prompt,
content={'type': 'text', 'text': prompt})
# DONA_CLOCK_SCULPTURE_V11 : même traitement depuis Écrire à Dona / raccourcis.
_dona_local_clock_reply = _dona_clock_local.answer(prompt)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_dona_local_clock_reply))
emit_state('idle',partial='')
return
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
if _dona_vision_v95.is_camera_question(prompt):
try:
_vision_answer=_dona_vision_monitor.analyze(prompt)
except Exception as _vision_exc:
_vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer))
emit_state('idle',partial='')
return
# DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe.
if _dona_files_v91.is_file_question(prompt):
_dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK)
emit_state('idle',partial='')
return
# DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes.
if _dona_suite_v9.is_request(prompt):
_v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes.
if _dona_nav.is_request(prompt):
_v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
if _dona_youtube.is_search(prompt):
_v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
if _dona_youtube.is_control(prompt,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(prompt,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render',_payload)
emit_state('idle', partial='')
return
_answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt))
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
# DONA_LEARNING_V93 : évaluation de l’échange écrit.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_speech))
# Deliberately no speak_async(): typed interaction is silent.
emit_state('idle', partial='')
except Exception as exc:
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
import traceback
traceback.print_exc()
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
return True
from services.dona_family_ui import register_dona_family as _dona_register_family
_dona_register_family(app, adult_command=_dona_submit_typed)
# DONA_FAMILY_UI_V1_END
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
from services import dona_dynamic as _dona_dynamic
# DONA_JSON_AUTORENDER_V1 : rendu automatique JSON Hermes, demandes et raccourcis
from services import dona_json_autorender_v1 as _dona_json_autorender
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
from services import dona_dashboard_agent as _dona_dashboard_agent
def _dona_dynamic_submit(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _work():
try:
wake.pause()
emit_state('thinking',transcript=prompt,partial='')
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
_payload={'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content']=_payload
socketio.emit('alfred_render',_payload)
emit_state('idle',partial='')
except Exception as exc:
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
return True
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in.
from services import dona_vision_v95 as _dona_vision_v95
_dona_vision_monitor = _dona_vision_v95.register(app, socketio)
# DONA_LEARNING_V93 : retours explicites et corrections approuvées.
from services import dona_learning_v93 as _dona_learning_v93
_dona_learning_v93.register(app)
# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur.
from services import dona_shortcuts_v92 as _dona_shortcuts_v92
_dona_shortcuts_v92.register(app)
# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée.
from services import dona_files_v91 as _dona_files_v91
_dona_files_v91.register_files(app)
# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines.
from services import dona_suite_v9 as _dona_suite_v9
_dona_suite_v9.register_suite(app,socketio)
# DONA_NAV_V83: géocodage, carte et transfert Waze.
from services import dona_navigation as _dona_nav
_dona_nav.register_navigation(app,socketio)
# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé.
from services import dona_youtube as _dona_youtube
_dona_youtube.register_youtube(app,socketio)
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
_register_dona_spotify(app)
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
from services.dona_face import (register_face as _dona_register_face,
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,477 @@
"""Pont Alfred -> Hermes avec session persistante."""
from services.dona_hermes_stream import run_jsonl_command as _dona_run_jsonl_command # DONA_HERMES_PERF_V1
import fcntl
import json
import os
import re
import subprocess
import tempfile
import threading
from contextlib import contextmanager
from pathlib import Path
# --------------------------------------------------
# CONFIGURATION
# --------------------------------------------------
DEFAULT_IDENTITY_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "identity.md"
)
DEFAULT_SESSION_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "hermes_session.json"
)
_REQUEST_LOCK = threading.Lock()
_ID_PATTERN = re.compile(
r"^[A-Za-z0-9_:.\-]{1,128}$"
)
# --------------------------------------------------
# IDENTITÉ D'ALFRED
# --------------------------------------------------
def _build_prompt(question: str) -> str:
"""Send only the real user utterance. Personality is configured in Hermes SOUL.md.
The previous identity.md wrapper caused absent/unknown-tool decisions in
both Spotify and Home Assistant. Never inject it into each user turn.
"""
return str(question or '').strip()
def _toolsets_for(question: str) -> str | None:
"""Conservative domain routing to avoid huge MCP schemas on simple turns.
Cross-domain, ambiguous, or generic requests retain Hermes' full toolset.
Set HERMES_TOOLSET_ROUTING=off for instant rollback without code changes.
"""
if os.getenv('HERMES_TOOLSET_ROUTING', 'on').lower() not in ('on','1','true','yes'):
return None
q = str(question or '').casefold()
domains = {
'spotify': (r'\bspotify\b|\bplaylist\b|\bmusique\b|\bmorceau\b|\bchanson\b|\balbum\b|\bspotify connect\b'),
'homeassistant': (r'\blumi[eè]res?\b|\blampes?\b|\bvolets?\b|\bdomotique\b|\bthermostat\b|\bchauffage\b|\bprises?\b|\bhome assistant\b|\ballume\b|\b[eé]teins?\b'),
'face_recognition': (r'\bface_recognition\b|\bidentify_people\b|\breconnaissance faciale\b'), # DONA_V7_RELEASE
'proxmox': (r'\bproxmox\b|\bcluster proxmox\b'), # DONA_V8_DYNAMIC
'wazuh': (r'\bwazuh\b'),
}
import re as _re
matching = [domain for domain, pattern in domains.items() if _re.search(pattern, q)]
# Combined or potentially compound queries keep full access to every MCP.
if len(matching) != 1:
return None
if _re.search(r'\b(et aussi|en m[eê]me temps|puis v[eé]rifie|compare avec|cherche sur|envoie|par mail|gmail|calendrier|agenda|comfyui|document|paperless)\b', q):
return None
return {'spotify':'spotify', 'homeassistant':'homeassistant,home_assistant', 'face_recognition':'face_recognition', 'proxmox':'proxmox', 'wazuh':'wazuh'}[matching[0]]
# --------------------------------------------------
# FICHIER DE SESSION
# --------------------------------------------------
def _session_file() -> Path:
return Path(
os.getenv(
"HERMES_SESSION_FILE",
str(DEFAULT_SESSION_FILE)
)
).expanduser()
def _read_session(path: Path) -> str | None:
if not path.exists():
return None
try:
obj = json.loads(
path.read_text(encoding="utf-8")
)
sid = obj["session_id"]
except (
OSError,
ValueError,
KeyError,
TypeError
) as exc:
raise RuntimeError(
f"État de session Hermes invalide "
f"({path}) : {exc}"
) from exc
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
f"Identifiant de session Hermes "
f"invalide dans {path}"
)
return sid
def _save_session(path: Path, sid: str):
if not _ID_PATTERN.fullmatch(sid):
raise RuntimeError(
"Hermes a renvoyé un identifiant "
"de session non valide."
)
path.parent.mkdir(
parents=True,
exist_ok=True
)
temp_path = None
try:
fd, temp_name = tempfile.mkstemp(
prefix=".hermes_session_",
suffix=".tmp",
dir=path.parent
)
temp_path = Path(temp_name)
with os.fdopen(
fd,
"w",
encoding="utf-8"
) as handle:
json.dump(
{"session_id": sid},
handle,
indent=2
)
handle.write("\n")
os.replace(temp_path, path)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
# --------------------------------------------------
# VERROUILLAGE SESSION
# --------------------------------------------------
@contextmanager
def _session_lock(path: Path):
path.parent.mkdir(
parents=True,
exist_ok=True
)
lock_path = path.with_name(
path.name + ".lock"
)
fd = os.open(
lock_path,
os.O_CREAT | os.O_RDWR,
0o600
)
with os.fdopen(fd, "w") as handle:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_EX
)
try:
yield
finally:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_UN
)
# --------------------------------------------------
# CONFIGURATION HERMES
# --------------------------------------------------
def _hermes_binary() -> str:
return os.getenv(
"HERMES_CLI",
"/root/.local/bin/hermes"
)
def _hermes_timeout() -> float:
return max(
10.0,
float(
os.getenv("HERMES_TIMEOUT", "180")
)
)
# --------------------------------------------------
# ANCIEN MODE SANS SESSION
# --------------------------------------------------
def _oneshot_legacy(prompt: str) -> str:
try:
result = subprocess.run(
[
_hermes_binary(),
"--oneshot",
prompt
],
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except subprocess.CalledProcessError as exc:
details = (
exc.stderr
or exc.stdout
or "aucun détail"
).strip()
raise RuntimeError(
f"Hermes a échoué "
f"(code {exc.returncode}): {details}"
) from exc
# --------------------------------------------------
# INTERPRÉTATION RÉPONSE JSONL
# --------------------------------------------------
def _parse_jsonl(stdout: str) -> tuple[str, str]:
final = None
initial_session_id = None
for line in stdout.splitlines():
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(event, dict):
continue
if (
event.get("type") == "system"
and event.get("subtype") == "init"
):
initial_session_id = event.get(
"session_id"
)
if event.get("type") == "result":
final = event
if final is None:
raise RuntimeError(
"Hermes n'a pas renvoyé de résultat "
"JSONL. Vérifie la prise en charge "
"de --format stream-json."
)
if final.get("exit_code", 0) != 0:
raise RuntimeError(
f"Hermes : "
f"{final.get('error') or 'échec'}"
)
sid = (
final.get("session_id")
or initial_session_id
)
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
"Hermes n'a pas fourni "
"d'identifiant de session utilisable."
)
answer = final.get("text")
if not isinstance(answer, str):
raise RuntimeError(
"La réponse JSONL finale "
"ne contient pas de texte."
)
return answer.strip(), sid
# --------------------------------------------------
# APPEL HERMES AVEC REPRISE DE SESSION
# --------------------------------------------------
def _call_hermes(
prompt: str,
session_id: str | None
) -> tuple[str, str]:
cmd = [
_hermes_binary(),
"chat",
"--oneshot",
"--query-file",
"-",
"--format",
"stream-json",
"--source",
"cli"
]
route = _toolsets_for(prompt)
if route and not session_id:
cmd.extend(["--toolsets", route])
if session_id:
cmd.extend([
"--resume",
session_id
])
try:
# DONA_HERMES_PERF_V1: conserver la session, transmettre les deltas en temps réel
process = _dona_run_jsonl_command(
cmd, prompt, _hermes_timeout()
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except FileNotFoundError as exc:
raise RuntimeError(
f"Hermes introuvable : "
f"{_hermes_binary()}"
) from exc
if process.returncode != 0:
msg = (
process.stderr
or process.stdout
or "aucun détail"
).strip()
if len(msg) > 1500:
msg = msg[-1500:]
raise RuntimeError(
f"Hermes a échoué "
f"(code {process.returncode}) : "
f"{msg}"
)
return _parse_jsonl(process.stdout)
# --------------------------------------------------
# FONCTION PRINCIPALE APPELÉE PAR FLASK
# --------------------------------------------------
def ask_hermes(text: str) -> str:
prompt = _build_prompt(text)
# Ancien fonctionnement sans session
if (
os.getenv(
"HERMES_SESSION_MODE",
"persistent"
).lower() == "legacy"
):
return _oneshot_legacy(prompt)
base_path = _session_file()
route = _toolsets_for(prompt)
if route:
safe_route = re.sub(
r"[^a-z0-9]+", "_", route.lower()
).strip("_")
path = base_path.with_name(
f"{base_path.stem}.{safe_route}{base_path.suffix}"
)
else:
path = base_path
# Empêche les appels concurrents
with _REQUEST_LOCK, _session_lock(path):
# Recherche la session précédente
current_sid = _read_session(path)
# Envoie la question à Hermes
answer, new_sid = _call_hermes(
prompt,
current_sid
)
# Sauvegarde l'identifiant
_save_session(
path,
new_sid
)
print(
f"[ALFRED] Session Hermes : "
f"{new_sid} "
f"({'reprise' if current_sid else 'création'})",
flush=True
)
return answer
@@ -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','proxmox'].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',proxmox:'Proxmox · API directe' /* DONA_DYNAMIC_PROXMOX_V3 */})[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','proxmox'].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','proxmox'].includes(active.origin))reloadLive(false);});
window.DonaDynamicV8={open,render,close};
})();
+21
View File
@@ -0,0 +1,21 @@
{
"version": "1.0.0",
"target": "/opt/alfred/app",
"backup_dir": "/opt/alfred/backups/dona-visual-context/20261102T071929Z-373243",
"edited": {
"app.py": {
"before_sha256": "bf6466ec54c06500bbcfb79a0e2bd986134edb254855c8123bb64ab30af00fc2",
"after_sha256": "3f0f90a19ccf77f5f202b4b5182d3c192f74513095e3ec63863bd2269448bc9c"
},
"templates/index.html": {
"before_sha256": "f18c0c6ec11f61f1c961766eef5819cb0a078866222723b54636cf530e3c5030",
"after_sha256": "3c7e9be4dfb39e1b58949419eb6872b46cedf77f9dc3c1ee8d05237581188e6a"
}
},
"created": {
"services/dona_visuals/__init__.py": "3eb5f2a98349cd9d33cb44c25bf735eb9aea0096f3d1c0d8071d9d94f627f8ba",
"static/js/dona-visuals-v1.js": "7d45569eb65735dafec8c54e6f61a72242552ec34774f636bfbd46ffbead6cb4",
"static/css/dona-visuals-v1.css": "2efe43e4231dd969a93911c3a8026d36b64a1a52fdb9712862cbaf750c5f5381"
},
"timestamp": "20261102T071929Z"
}
+17
View File
@@ -45,6 +45,13 @@ get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
# DONA_HERMES_PERF_V1: progression non bloquante des réponses Hermes
from services.dona_hermes_stream import configure_emitter as _dona_stream_emitter
_dona_stream_emitter(lambda event: socketio.emit("dona_hermes_stream", event))
# DONA_UNIFIED_ROUTING_V4 — tableaux standards depuis les requêtes vocales/écrites
from services.dona_unified_router_v4 import configure_emitter as _dona_v4_emitter
_dona_v4_emitter(lambda event: socketio.emit('alfred_render', event))
# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur.
from services import piper_tts as _dona_waves_piper
_dona_waves_piper.dona_set_level_callback(
@@ -923,6 +930,16 @@ from services.dona_face import (register_face as _dona_register_face,
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
# --- DONA VISUAL CONTEXT V1 BEGIN ---
try:
from services.dona_visuals import register_dona_visuals
register_dona_visuals(app, globals().get('socketio'))
except Exception as _dona_visual_error:
import logging as _dona_visual_logging
_dona_visual_logging.getLogger('dona.visuals').exception('Visual context init failed: %s', _dona_visual_error)
# --- DONA VISUAL CONTEXT V1 END ---
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
+87
View File
@@ -1148,5 +1148,92 @@
},
"cached_at": 1793535766.1160083,
"focus_label": "aujourd’hui"
},
"3df6376c38a741020f3a1be98423f62edfc7a5c67e13cdf8a38be151ca19d996": {
"file": "3df6376c38a741020f3a1be98423f62edfc7a5c67e13cdf8a38be151ca19d996.jpg",
"prompt_ids": [
"e4bd8550-140b-4d31-bbec-c1aabd1cf2e9",
"3f6dafd0-011d-4b7d-8145-26b9e304fe80"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
}
},
"cached_at": 1793542742.015088,
"focus_label": "aujourd’hui"
},
"40aa2eb3e1b0aca2c1408757099a86a94893b931ac43cb404e916374d92e89b8": {
"file": "40aa2eb3e1b0aca2c1408757099a86a94893b931ac43cb404e916374d92e89b8.jpg",
"prompt_ids": [
"18ba8c2b-c69a-4d4e-bc48-daea82971442",
"8dfcf133-1779-4c5a-af2e-61058685262f"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
}
},
"cached_at": 1793553025.269465,
"focus_label": "aujourd’hui"
},
"6b23e886b9faf492bc345e414eb490848988448afdf31169a3261dee0d407d27": {
"file": "6b23e886b9faf492bc345e414eb490848988448afdf31169a3261dee0d407d27.jpg",
"prompt_ids": [
"4d732429-1222-44cc-b034-44aa8ecd3c47",
"4cc964e5-08d2-478c-b708-00e98d173db5"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
}
},
"cached_at": 1793604238.0640697,
"focus_label": "aujourd’hui"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,3 @@
{
"session_id": "local-906583bb7279defc4c4c1a2859fa1369a456fe1544c5c1bfddb1c644dbdce63d"
}
Binary file not shown.
Binary file not shown.
+29
View File
@@ -209,6 +209,31 @@ def build_dashboard(prompt, ask_hermes):
if source=='spotify':return _live_spotify(prompt)
if source=='temperatures':return _live_temperatures(prompt)
if source=='lights':return _live_lights(prompt)
# DONA_DYNAMIC_PROXMOX_V3 — try data-only read-only mode for standard boards.
from services.dona_proxmox_direct import (
is_standard_proxmox, build_standard_dashboard, ProxmoxDirectUnavailable
)
if is_standard_proxmox(prompt):
import logging as _dona_logging
import time as _dona_time
_direct_start = _dona_time.monotonic()
try:
_direct_board = normalize_dashboard(build_standard_dashboard(prompt), prompt=prompt)
_direct_board['origin'] = 'proxmox'
_dona_logging.getLogger('dona.proxmox').warning(
'[DONA-DIRECT] source=proxmox outcome=ok elapsed_ms=%.1f',
(_dona_time.monotonic()-_direct_start)*1000
)
return _direct_board
except ProxmoxDirectUnavailable as _direct_exc:
_dona_reason = str(_direct_exc)
except Exception:
_dona_reason = 'erreur_interne'
_dona_logging.getLogger('dona.proxmox').warning(
'[DONA-DIRECT] source=proxmox outcome=fallback reason=%s elapsed_ms=%.1f',
_dona_reason, (_dona_time.monotonic()-_direct_start)*1000
)
# Existing Hermes/MCP implementation remains the safe fallback.
instruction=(
'Demande originale : '+prompt+'\n\n'
'Interroge d’abord les outils disponibles (MCP) pour obtenir les données réelles. '
@@ -251,6 +276,10 @@ def register_dynamic(app, submit):
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')
elif source=='proxmox':
from services.dona_proxmox_direct import build_standard_dashboard
board=normalize_dashboard(build_standard_dashboard('Tableau de bord Proxmox'), prompt='Tableau de bord Proxmox')
board['origin']='proxmox'
else:return jsonify(ok=False,error='Source invalide.'),400
response=jsonify(ok=True,dashboard=board)
response.headers['Cache-Control']='no-store, private'
+248
View File
@@ -0,0 +1,248 @@
"""Dona CLI streaming and timing bridge (Python standard library only).
Does not access Hermes sessions or modify MCP config. Retains the terminal
JSONL result envelope for the existing _parse_jsonl implementation.
"""
from __future__ import annotations
import json
import logging
import os
import signal
import subprocess
import threading
import time
import uuid
_LOG = logging.getLogger("dona.hermes.performance")
_EMITTER = None
_EMITTER_LOCK = threading.Lock()
_FALSE = {"0", "false", "no", "off", "disabled"}
def configure_emitter(emitter):
"""Register Flask-SocketIO event emitter once, after socketio is created."""
global _EMITTER
with _EMITTER_LOCK:
_EMITTER = emitter
def _enabled(name: str, default: str = "on") -> bool:
return os.environ.get(name, default).strip().lower() not in _FALSE
def _publish(msg):
if not _enabled("DONA_HERMES_STREAM_UI", "on"):
return
with _EMITTER_LOCK:
fn = _EMITTER
if fn is not None:
try:
fn(msg)
except Exception:
# A browser disconnect must never corrupt an Hermes turn/session.
_LOG.debug("Dona UI emitter failed", exc_info=True)
def run_jsonl_command(cmd, prompt, timeout):
"""Compatibility replacement for subprocess.run used only by _call_hermes.
Publishes deltas/tool names while keeping exact final JSONL parsing behavior.
Fallback to the historical subprocess.run with DONA_HERMES_STREAM_CLI=off.
"""
if not _enabled("DONA_HERMES_STREAM_CLI", "on"):
return subprocess.run(
cmd, input=prompt, capture_output=True, text=True,
timeout=timeout, check=False,
)
request_id = uuid.uuid4().hex[:12]
start = time.monotonic()
first_text_ms = None
first_event_ms = None
first_tool_ms = None
last_tool_done_ms = None
active_tools = []
finished_tools = 0
# Report only name/timing/outcome. Never log MCP arguments, outputs or prompts.
tool_count = 0
text_fragments = 0
emitted_characters = 0
pending_text = ""
last_emit = start
final_seen = False
# Keep only records the existing parser consumes, rather than potentially
# huge tool inputs/outputs and all intermediate assistant deltas.
output_records = []
stderr_records = []
deadline_hit = threading.Event()
proc = None
timer = None
_publish({"type": "start", "id": request_id})
def expire():
deadline_hit.set()
if proc is not None and proc.poll() is None:
try:
# subprocess in its own session: children of this CLI turn
# cannot linger after an actual timeout.
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except OSError:
pass
def collect_stderr():
try:
data = proc.stderr.read()
if data:
stderr_records.append(data)
except Exception:
pass
try:
proc = subprocess.Popen(
cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, bufsize=1,
start_new_session=True,
)
timer = threading.Timer(float(timeout), expire)
timer.daemon = True
timer.start()
stderr_thread = threading.Thread(target=collect_stderr, daemon=True)
stderr_thread.start()
try:
proc.stdin.write(prompt)
except BrokenPipeError:
pass
finally:
try:
proc.stdin.close()
except OSError:
pass
for line in proc.stdout:
try:
event = json.loads(line)
except (ValueError, TypeError):
# Preserve unrecognized lines to aid existing error diagnostics.
continue
if not isinstance(event, dict):
continue
kind = event.get("type")
now = time.monotonic()
if first_event_ms is None:
first_event_ms = round((now - start) * 1000, 1)
if kind == "system" and event.get("subtype") == "init":
output_records.append(line)
elif kind == "text":
delta = event.get("text")
if isinstance(delta, str) and delta:
text_fragments += 1
if first_text_ms is None:
first_text_ms = round((now - start) * 1000, 1)
_publish({"type": "first_text", "id": request_id,
"first_text_ms": first_text_ms})
# Emit batched, rather than one websocket event/token.
pending_text += delta
if (now - last_emit) >= .12 or len(pending_text) >= 240:
_publish({"type": "delta", "id": request_id,
"text": pending_text})
emitted_characters += len(pending_text)
pending_text = ""
last_emit = now
elif kind == "tool_use":
tool_count += 1
if first_tool_ms is None:
first_tool_ms = round((now - start) * 1000, 1)
name = str(event.get("name") or "outil")[:90]
# Events may use either a tool_call_id, id or no ID at all.
tool_id = event.get("tool_call_id") or event.get("id")
active_tools.append({"name": name, "id": tool_id,
"start": now, "sequence": tool_count})
_LOG.warning("[DONA-TOOL] id=%s index=%s name=%s phase=start at_ms=%.1f",
request_id, tool_count, name, (now-start)*1000)
# Do not send tool arguments, user tokens, or raw tool outputs.
_publish({"type": "tool", "id": request_id, "name": name})
elif kind == "tool_result":
name = str(event.get("name") or "outil")[:90]
tool_id = event.get("tool_call_id") or event.get("tool_use_id") or event.get("id")
matched = None
# Prefer a matching tool ID; fallback to matching name, then oldest.
if tool_id is not None:
matched = next((i for i, t in enumerate(active_tools)
if t["id"] == tool_id), None)
if matched is None:
matched = next((i for i, t in enumerate(active_tools)
if t["name"] == name), None)
if matched is None and active_tools:
matched = 0
record = active_tools.pop(matched) if matched is not None else None
if name == "outil" and record is not None:
name = record["name"]
measured_ms = round((now-record["start"])*1000, 1) if record else None
reported_ms = event.get("duration_ms")
if isinstance(reported_ms, bool) or not isinstance(reported_ms, (float, int)):
reported_ms = None
failed = bool(event.get("is_error"))
last_tool_done_ms = round((now-start)*1000, 1)
finished_tools += 1
_LOG.warning("[DONA-TOOL] id=%s index=%s name=%s phase=end at_ms=%.1f elapsed_ms=%s server_ms=%s error=%s",
request_id, record["sequence"] if record else "?", name,
last_tool_done_ms, measured_ms, reported_ms, failed)
_publish({"type": "tool_done", "id": request_id,
"name": name,
"duration_ms": reported_ms if reported_ms is not None else measured_ms,
"error": failed})
elif kind == "result":
final_seen = True
output_records.append(line)
rc = proc.wait()
stderr_thread.join(timeout=1.0)
total_ms = round((time.monotonic() - start) * 1000, 1)
if deadline_hit.is_set():
_publish({"type": "error", "id": request_id, "reason": "timeout"})
raise subprocess.TimeoutExpired(cmd, timeout, output="".join(output_records),
stderr="".join(stderr_records))
if pending_text:
_publish({"type": "delta", "id": request_id, "text": pending_text})
emitted_characters += len(pending_text)
_publish({"type": "end", "id": request_id,
"total_ms": total_ms, "first_text_ms": first_text_ms,
"ok": rc == 0 and final_seen})
_LOG.warning(
"[DONA-PERF] hermes_cli id=%s total_ms=%s first_event_ms=%s "
"first_tool_ms=%s last_tool_done_ms=%s first_text_ms=%s "
"tools=%s tool_results=%s unfinished_tools=%s "
"fragments=%s ui_chars=%s exit=%s final=%s",
request_id, total_ms, first_event_ms, first_tool_ms,
last_tool_done_ms, first_text_ms, tool_count, finished_tools,
len(active_tools), text_fragments, emitted_characters, rc, final_seen,
)
# Keep final result + init events exactly as _parse_jsonl expects.
return subprocess.CompletedProcess(
cmd, rc, "".join(output_records), "".join(stderr_records)
)
except Exception:
if not deadline_hit.is_set():
_publish({"type": "error", "id": request_id, "reason": "process_error"})
raise
finally:
if timer:
timer.cancel()
if proc is not None:
if proc.poll() is None:
try:
os.killpg(proc.pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except OSError:
pass
for pipe in (proc.stdin, proc.stdout, proc.stderr):
try:
pipe.close()
except (OSError, ValueError):
pass
+234
View File
@@ -0,0 +1,234 @@
"""Dona V3 — read-only, optional Proxmox dashboard renderer.
No model, no MCP mutation, no writes to Proxmox. A failure must be handled by
build_dashboard's existing Hermes fallback. Server-side credentials only.
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
import json
import os
from pathlib import Path
import re
import ssl
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit, urlunsplit
from urllib.request import Request, urlopen
OFF = {'0', 'false', 'off', 'no', 'disabled'}
ENV_PATH = Path('/opt/hermes/MCP_proxmox/.env')
MAX_DATA = 3 * 1024 * 1024
class ProxmoxDirectUnavailable(Exception):
"""Only short, non-sensitive public reason codes should be logged."""
def enabled():
return os.environ.get('DONA_PROXMOX_DIRECT', 'on').strip().lower() not in OFF
def is_standard_proxmox(prompt):
"""Fail conservative. Let Hermes handle analyses, commands and mixed topics."""
if not enabled() or not isinstance(prompt, str):
return False
p = prompt.casefold().strip()
if not (re.search(r'\bproxmox\b', p) and re.search(r'\b(tableau|dashboard|vue d.ensemble|synth[eè]se)\b', p)):
return False
if re.search(r'\b(wazuh|spotify|home\s*assistant|comfyui|paperless|gmail|youtube|courriel)\b', p):
return False
if re.search(r'\b(analy[sz]\w*|pourquoi|compar\w*|recommand\w*|optimis\w*|pr[eé]di\w*|histor\w*|tendance\w*|\bavant\b|\bapr[eè]s\b|migr\w*|sauvegard\w*|backup\w*|restaur\w*|red[eé]marr\w*|arr[eê]t\w*|allum\w*|cr[eé]\w*|supprim\w*|modifi\w*|ceph|stockage|r[eé]seau|sur\s+\d+\s+(jours|heures)|d[eé]taill\w*)\b', p):
return False
return True
def _read_env_file(path):
env = {}
try:
if not path.is_file() or path.stat().st_size > 65536:
return env
for line in path.read_text(encoding='utf-8').splitlines():
line = line.strip()
if line.startswith('export '):
line = line[7:].strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, val = line.split('=', 1)
key = key.strip()
if not re.fullmatch(r'[A-Z][A-Z0-9_]*', key):
continue
val = val.strip()
if val and val[0] in ("'", '"') and val[-1:] == val[0]:
val = val[1:-1]
else:
val = val.split(' #', 1)[0].strip()
env[key] = val
except (OSError, UnicodeError):
return {}
return env
def _config():
env = {}
path = os.environ.get('DONA_PROXMOX_ENV_FILE')
if path:
env.update(_read_env_file(Path(path).expanduser()))
else:
env.update(_read_env_file(ENV_PATH))
env.update(os.environ) # Live service settings override .env values.
def get(*names):
return next((str(env[n]).strip() for n in names if env.get(n)), '')
url = get('DONA_PROXMOX_URL', 'DONA_PROXMOX_API_URL', 'PROXMOX_URL',
'PROXMOX_API_URL', 'PROXMOX_HOST', 'PVE_HOST', 'PVE_URL', 'PVE_API_URL')
tid = get('DONA_PROXMOX_TOKEN_ID', 'PROXMOX_TOKEN_ID', 'PROXMOX_API_TOKEN_ID',
'PVE_TOKEN_ID', 'PVE_API_TOKEN_ID')
secret = get('DONA_PROXMOX_TOKEN_SECRET', 'PROXMOX_TOKEN_SECRET',
'PROXMOX_API_TOKEN_SECRET', 'PROXMOX_TOKEN_VALUE',
'PROXMOX_API_TOKEN', 'PVE_TOKEN_SECRET', 'PVE_API_TOKEN_SECRET')
if not tid:
user = get('PROXMOX_USER', 'PROXMOX_USERNAME', 'PVE_USER')
name = get('PROXMOX_TOKEN_NAME', 'PROXMOX_TOKEN', 'PVE_TOKEN_NAME')
if user and name:
tid = user + '!' + name
if secret.startswith('PVEAPIToken='):
secret = secret.split('=', 1)[1]
# Combined 'user@realm!name=secret' format is accepted if provided as one value.
if not tid and '=' in secret and '!' in secret:
tid, secret = secret.split('=', 1)
if not url or not tid or not secret:
raise ProxmoxDirectUnavailable('configuration_absente')
if not re.fullmatch(r'[^\s!=]+@[^\s!=]+![^\s!=]+', tid):
raise ProxmoxDirectUnavailable('token_id_invalide')
if any(c in secret for c in '\r\n'):
raise ProxmoxDirectUnavailable('token_invalide')
if '://' not in url:
url = 'https://' + url
parsed = urlsplit(url)
if parsed.scheme not in ('https', 'http') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ProxmoxDirectUnavailable('url_invalide')
if parsed.scheme == 'http' and get('DONA_PROXMOX_ALLOW_HTTP') not in ('1', 'true', 'yes'):
raise ProxmoxDirectUnavailable('http_non_autorise')
try:
port = parsed.port
except ValueError as exc:
raise ProxmoxDirectUnavailable('port_invalide') from exc
netloc = parsed.netloc if port is not None else parsed.netloc + ':8006'
path = parsed.path.rstrip('/')
if path not in ('', '/api2/json'):
raise ProxmoxDirectUnavailable('chemin_url_invalide')
base = urlunsplit((parsed.scheme, netloc, '/api2/json', '', ''))
verify = get('DONA_PROXMOX_VERIFY_SSL', 'PROXMOX_VERIFY_SSL', 'PVE_VERIFY_SSL').lower() not in OFF
ca_file = get('DONA_PROXMOX_CA_FILE')
timeout_str = get('DONA_PROXMOX_TIMEOUT') or '3'
try:
timeout = float(timeout_str)
except ValueError as exc:
raise ProxmoxDirectUnavailable('timeout_invalide') from exc
timeout = max(1.0, min(timeout, 10.0))
return base, f'PVEAPIToken={tid}={secret}', verify, ca_file, timeout
def _fetch(base, token, verify, ca_file, timeout, endpoint):
if verify:
try:
ctx = ssl.create_default_context(cafile=ca_file or None)
except (OSError, ssl.SSLError) as exc:
raise ProxmoxDirectUnavailable('certificat_invalide') from exc
else:
ctx = ssl._create_unverified_context() # Only when explicitly selected in config.
req = Request(base + endpoint, headers={'Authorization': token, 'Accept': 'application/json'}, method='GET')
try:
with urlopen(req, timeout=timeout, context=ctx) as r:
raw = r.read(MAX_DATA + 1)
if len(raw) > MAX_DATA:
raise ProxmoxDirectUnavailable('reponse_trop_volumineuse')
data = json.loads(raw)
if not isinstance(data, dict) or not isinstance(data.get('data'), list):
raise ProxmoxDirectUnavailable('schema_inattendu')
return data['data']
except HTTPError as exc:
raise ProxmoxDirectUnavailable(f'http_{exc.code}') from None
except (URLError, TimeoutError, ssl.SSLError, OSError):
raise ProxmoxDirectUnavailable('connexion_impossible') from None
except (ValueError, UnicodeError):
raise ProxmoxDirectUnavailable('json_invalide') from None
def _percent(value):
try:
number = float(value)
except (ValueError, TypeError):
return None
return round(max(0, min(100, number * 100)), 1)
def _text(value, limit=80):
return str(value if value is not None else '—').replace('\r', ' ').replace('\n', ' ')[:limit]
def _mk_dashboard(prompt, cluster, nodes, resources):
if not isinstance(cluster, list) or not isinstance(nodes, list) or not isinstance(resources, list):
raise ProxmoxDirectUnavailable('schema_inattendu')
nodes = [n for n in nodes if isinstance(n, dict) and n.get('node')]
resources = [r for r in resources if isinstance(r, dict) and r.get('type') in ('qemu', 'lxc')]
if not nodes:
raise ProxmoxDirectUnavailable('aucun_noeud_visible')
cards = []
count_up = sum(str(n.get('status', '')).lower() == 'online' for n in nodes)
cards.append({'type': 'metric', 'title': 'Nœuds actifs', 'value': f'{count_up}/{len(nodes)}'})
qemu = [r for r in resources if r['type'] == 'qemu' and not r.get('template')]
lxc = [r for r in resources if r['type'] == 'lxc' and not r.get('template')]
cards.append({'type': 'metric', 'title': 'VM actives', 'value': f"{sum(r.get('status') == 'running' for r in qemu)}/{len(qemu)}"})
cards.append({'type': 'metric', 'title': 'Conteneurs LXC actifs', 'value': f"{sum(r.get('status') == 'running' for r in lxc)}/{len(lxc)}"})
c = next((x for x in cluster if isinstance(x, dict) and x.get('type') == 'cluster'), None)
if c is not None and c.get('quorate') is not None:
cards.append({'type': 'metric', 'title': 'Quorum', 'value': 'Oui' if str(c['quorate']) in ('1', 'True', 'true') else 'Non'})
status_nodes = [f"{_text(n['node'])} — {_text(n.get('status', 'inconnu'))}" for n in sorted(nodes, key=lambda n: str(n['node']))][:24]
cards.append({'type': 'list', 'title': 'État des nœuds', 'items': status_nodes or ['Aucun nœud visible']})
cpus = [(_text(n['node']), _percent(n.get('cpu'))) for n in nodes]
bars = [{'label': name, 'value': value, 'unit': '%'} for name, value in cpus if value is not None]
if bars:
cards.append({'type': 'bars', 'title': 'CPU par nœud', 'items': bars[:16]})
workloads = sorted(qemu + lxc, key=lambda r: (str(r.get('node', '')), str(r.get('name', '')), str(r.get('vmid', ''))))
rows = [[_text(r.get('vmid')), _text(r.get('name') or f"{r.get('type')} {r.get('vmid', '')}"),
'VM' if r['type'] == 'qemu' else 'LXC', _text(r.get('node')), _text(r.get('status', 'inconnu'))]
for r in workloads[:20]]
if rows:
cards.append({'type': 'table', 'title': 'VM et conteneurs (20 premiers au maximum)',
'columns': ['ID', 'Nom', 'Type', 'Nœud', 'État'], 'rows': rows})
if len(workloads) > 20:
cards.append({'type': 'text', 'title': 'Affichage',
'text': f"{len(workloads)} VM/conteneurs visibles ; 20 affichés dans le tableau."})
return {'title': 'Tableau de bord Proxmox', 'subtitle': 'Données directes de l’API Proxmox',
'cards': cards}
def build_standard_dashboard(prompt):
if not enabled():
raise ProxmoxDirectUnavailable('desactive')
base, token, verify, ca_file, timeout = _config()
endpoints = ['/cluster/status', '/nodes', '/cluster/resources?type=vm']
with ThreadPoolExecutor(max_workers=3, thread_name_prefix='dona-proxmox-ro') as ex:
results = list(ex.map(lambda e: _fetch(base, token, verify, ca_file, timeout, e), endpoints))
return _mk_dashboard(prompt, *results)
def diagnostic():
start = time.monotonic()
try:
board = build_standard_dashboard('Tableau de bord Proxmox')
except ProxmoxDirectUnavailable as exc:
print('Proxmox direct : INDISPONIBLE (' + str(exc) + ')')
print('Dona continuera à utiliser Hermes/MCP pour les tableaux Proxmox.')
return 1
print('Proxmox direct : OK')
print('Cartes construites :', len(board['cards']))
print('Temps de lecture et assemblage : %.0f ms' % ((time.monotonic()-start)*1000))
return 0
if __name__ == '__main__':
raise SystemExit(diagnostic())
@@ -0,0 +1,137 @@
"""Dona V4: conservative fast routing for dashboard *requests* (voice or text).
No direct shell calls, no side effects on Proxmox/HA/Spotify. Reuses Dona V3
`build_dashboard()` and its existing read-only providers. Complex queries,
missing UI sockets and any provider error fall through to Hermes unchanged.
"""
from __future__ import annotations
import logging
import os
import re
import threading
import time
from typing import Callable
_LOG = logging.getLogger('dona.router.v4')
_LOCK = threading.Lock()
_EMITTER: Callable | None = None
_FALSE = {'0', 'false', 'off', 'no', 'disabled'}
def configure_emitter(fn):
"""Called once by app.py after socketio is constructed."""
global _EMITTER
if not callable(fn):
raise TypeError('Un émetteur Socket.IO callable est nécessaire')
with _LOCK:
_EMITTER = fn
def enabled():
return os.environ.get('DONA_FAST_DASHBOARDS_V4', 'on').strip().lower() not in _FALSE
# Only an explicit presentation request. No guess from vague words such as
# "proxmox" or "température" alone. These aren't commands to control devices.
_VIEW_RE = re.compile(
r'\b(tableaux?\s+(?:de\s+)?bord|tableaux?|dashboards?|vue\s+d.ensemble)\b',
re.I,
)
_COMPLEX_RE = re.compile(
r'\b(?:analy[sz]\w*|pourquoi|compar\w*|recommand\w*|optimis\w*|'
r'pr[eé]di\w*|histor\w*|tendance\w*|migr\w*|sauvegard\w*|'
r'backup\w*|restaur\w*|red[eé]marr\w*|arr[eê]t\w*|'
r'allum\w*|[eé]tein\w*|cr[eé]\w*|supprim\w*|modifi\w*|'
r'configure\w*|change\w*|envoie\w*|partage\w*|notifie\w*|'
r'enregistre\w*|planifi\w*|automati\w*|graphique\s+sur\s+\d+|'
r'sur\s+\d+\s+(?:jours?|heures?|semaines?|mois|ans)|'
r'\b(?:ceph|stockage|r[eé]seau)\b)\b', re.I,
)
_DOMAINS = {
'proxmox': re.compile(r'\bproxmox\b', re.I),
'spotify': re.compile(r'\bspotify\b', re.I),
'lights': re.compile(r'\b(lumi[eè]res?|[eé]clairages?|lampes?)\b', re.I),
'temperatures': re.compile(r'\b(temp[eé]ratures?|thermom[eè]tres?)\b', re.I),
}
_OTHER = re.compile(
r'\b(wazuh|comfyui|paperless|gmail|youtube|courriel|email|'
r'calendrier|agenda|m[eé]t[eé]o|cam[eé]ra|sonnette|visages?|'
r'reconnaissance\s+faciale|kanban)\b', re.I,
)
def route(prompt):
"""Return a source label or None; deliberate false negatives are safe."""
if not enabled() or not isinstance(prompt, str) or len(prompt.strip()) > 500:
return None
q = prompt.casefold().strip()
if not _VIEW_RE.search(q) or _COMPLEX_RE.search(q) or _OTHER.search(q):
return None
found = [key for key, regex in _DOMAINS.items() if regex.search(q)]
if len(found) != 1:
return None
# Proxmox V3 has an additional explicit standard-query classifier.
if found[0] == 'proxmox':
from services.dona_proxmox_direct import is_standard_proxmox
if not is_standard_proxmox(prompt):
return None
return found[0]
class _FallbackToHermes(Exception):
pass
def _no_hermes(_prompt):
"""Prohibit an accidental second Hermes invocation in direct mode."""
raise _FallbackToHermes()
def try_route(prompt):
"""Return spoken/text confirmation when a live board was emitted, else None.
Fallback is intentionally the original ask_hermes path. Session state is
untouched by direct-only read operations.
"""
source = route(prompt)
if source is None:
return None
with _LOCK:
emitter = _EMITTER
if emitter is None:
return None
start = time.monotonic()
try:
from services.dona_dynamic import build_dashboard
board = build_dashboard(prompt, _no_hermes)
if not isinstance(board, dict) or board.get('schema') != 'dona.dashboard.v1':
raise _FallbackToHermes()
if not isinstance(board.get('cards'), list):
raise _FallbackToHermes()
actual = str(board.get('origin', '')).lower()
allowed = {
'proxmox': {'proxmox'},
'spotify': {'spotify'},
'lights': {'lights', 'homeassistant'},
'temperatures': {'homeassistant'},
}
if actual not in allowed[source]:
raise _FallbackToHermes()
# Socket.IO callbacks may raise, in which case normal Hermes fallback.
emitter({'type': 'dona_dynamic', 'dashboard': board,
'dona_fast_v4': True})
except Exception as exc:
# No prompt, payload, tokens or tool data in logs.
_LOG.warning('[DONA-V4] route=%s outcome=fallback reason=%s elapsed_ms=%.1f',
source, type(exc).__name__, (time.monotonic()-start)*1000)
return None
_LOG.warning('[DONA-V4] route=%s outcome=direct elapsed_ms=%.1f cards=%d',
source, (time.monotonic()-start)*1000, len(board['cards']))
return {
'proxmox': 'J’affiche le tableau de bord Proxmox.',
'spotify': 'J’affiche le tableau de bord Spotify.',
'lights': 'J’affiche le tableau de bord des lumières.',
'temperatures': 'J’affiche le tableau de bord des températures.',
}[source]
@@ -0,0 +1,317 @@
"""Dona Visual Context v1.0: structured, read-only contextual cards.
No action is executed from a visual card. This module never invokes Home Assistant,
Hermes, Spotify or ComfyUI. It only renders events / image references supplied by
those systems, or tries a public Wikimedia Commons illustration for recipes.
"""
from __future__ import annotations
import copy
import functools
import json
import os
import re
import threading
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
VERSION = "1.0.0"
EVENT = "dona_visual_card"
MAX_TITLE = 110
ALLOWED_TYPES = {"action_state", "recipe", "media", "weather", "calendar", "info"}
ALLOWED_STATUSES = {"confirmed", "reported", "pending", "error", "informational"}
ALLOWED_ICONS = {"lightbulb-on", "lightbulb-off", "power", "thermometer", "lock", "unlock", "blinds-open", "blinds-closed", "music", "chef-hat", "cloud-sun", "calendar", "info", "check", "alert"}
RESPONSE_EVENTS = {"assistant_response", "assistant_message", "assistant_reply", "ai_response", "hermes_response", "response", "chat_response", "message", "dona_response"}
WEB_IMAGE_ENABLED = os.environ.get("DONA_VISUAL_WEB_IMAGES", "1") == "1"
_cache: dict[str, tuple[float, dict[str, str]]] = {}
_cache_lock = threading.Lock()
_request_limits: dict[str, tuple[float, int]] = {}
_request_lock = threading.Lock()
def _short(value: Any, limit: int = 140) -> str:
return str(value or "").strip()[:limit]
def _safe_web_url(url: Any) -> str | None:
"""Only accept public HTTPS Wikimedia/Commons URLs or local absolute URLs.
ComfyUI must publish its image through an existing same-origin endpoint.
"""
if not isinstance(url, str) or len(url) > 2048:
return None
if url.startswith("/") and not url.startswith("//") and "\\" not in url:
return url
p = urllib.parse.urlsplit(url)
host = (p.hostname or "").lower()
if p.scheme == "https" and (host == "upload.wikimedia.org" or host == "commons.wikimedia.org"):
return url
return None
def validate_card(card: Any) -> dict[str, Any]:
"""Return a safe copy; reject HTML and Javascript-defined cards."""
if not isinstance(card, dict):
raise ValueError("card must be an object")
if card.get("schema") != "dona.visual.v1":
raise ValueError("schema must be dona.visual.v1")
kind = card.get("type")
if kind not in ALLOWED_TYPES:
raise ValueError("unknown card type")
state = card.get("status", "informational")
if state not in ALLOWED_STATUSES:
raise ValueError("unknown status")
title = _short(card.get("title"), MAX_TITLE)
if not title:
raise ValueError("title required")
icon = card.get("icon", "info")
if icon not in ALLOWED_ICONS:
icon = "info"
result = {
"schema": "dona.visual.v1", "id": _short(card.get("id") or uuid4().hex, 64),
"type": kind, "status": state, "title": title,
"subtitle": _short(card.get("subtitle"), 220), "icon": icon,
"timestamp": _short(card.get("timestamp") or datetime.now(timezone.utc).isoformat(), 40),
"source": _short(card.get("source") or "dona", 30),
}
# Only whitelisted primitive fields are forwarded. No HTML, CSS or script.
if isinstance(card.get("details"), list):
result["details"] = [_short(v, 180) for v in card["details"][:12] if isinstance(v, (str, int, float))]
if kind == "recipe":
result["ingredients"] = [_short(v, 180) for v in (card.get("ingredients") or [])[:20] if isinstance(v, str)] if isinstance(card.get("ingredients"), list) else []
result["steps"] = [_short(v, 400) for v in (card.get("steps") or [])[:15] if isinstance(v, str)] if isinstance(card.get("steps"), list) else []
result["duration"] = _short(card.get("duration"), 60)
image = card.get("image")
if isinstance(image, dict):
provider = image.get("source", "none")
if provider in {"web", "comfyui", "local", "none"}:
img = {"source": provider, "alt": _short(image.get("alt") or title, 140)}
if provider == "web":
img["query"] = _short(image.get("query") or title, 80)
else:
img_url = _safe_web_url(image.get("url"))
if img_url:
img["url"] = img_url
result["image"] = img
return result
def make_action_card(entity_id: str, action: str, *, success: bool | None = None, label: str | None = None) -> dict[str, Any]:
"""Confirmed only when caller verified a successful tool result/state readback."""
domain = str(entity_id).split(".", 1)[0]
if not re.fullmatch(r"[a-z_]+\.[a-z0-9_]+", entity_id):
raise ValueError("invalid entity_id")
on = action in {"turn_on", "on", "open", "unlock"}
if action not in {"turn_on", "on", "turn_off", "off", "open", "close", "unlock", "lock"}:
raise ValueError("unsupported action")
status = "confirmed" if success is True else ("error" if success is False else "reported")
verb = ("allumée" if on else "éteinte") if domain in {"light", "switch"} else ("activé" if on else "désactivé")
if domain == "cover": verb = "ouvert" if on else "fermé"
if domain == "lock": verb = "déverrouillée" if on else "verrouillée"
target = label or entity_id.split(".", 1)[1].replace("_", " ").capitalize()
card = {
"schema": "dona.visual.v1", "type": "action_state", "title": target,
"subtitle": f"{verb.capitalize()} · {'confirmé' if status == 'confirmed' else 'échec' if status == 'error' else 'annoncé, non vérifié'}",
"status": status, "icon": (
("lightbulb-on" if on else "lightbulb-off") if domain == "light" else
("blinds-open" if on else "blinds-closed") if domain == "cover" else
("unlock" if on else "lock") if domain == "lock" else "power"
),
"source": "home_assistant"
}
return validate_card(card)
def make_recipe_card(title: str, *, ingredients: list[str] | None = None, steps: list[str] | None = None,
duration: str = "", image_source: str = "web", image_url: str | None = None) -> dict[str, Any]:
return validate_card({
"schema": "dona.visual.v1", "type": "recipe", "status": "informational",
"title": title, "subtitle": "Recette illustrée", "icon": "chef-hat", "source": "hermes",
"ingredients": ingredients or [], "steps": steps or [], "duration": duration,
"image": {"source": image_source, "query": title, "url": image_url, "alt": title},
})
def _recipe_sections(text: str) -> tuple[list[str], list[str]]:
"""Extract only ingredients/steps explicitly present in the assistant text."""
ingredients: list[str] = []
steps: list[str] = []
section = None
for raw in text.splitlines()[:110]:
line = raw.strip()
clean_heading = re.sub(r"^[#*\s]+", "", line).strip(" :*-").casefold()
if re.fullmatch(r"(?:ingrédients|ingredients)(?:\s*\([^)]*\))?", clean_heading):
section = "ingredients"
continue
if re.fullmatch(r"(?:préparation|étapes|etapes|instructions|recette)", clean_heading):
section = "steps"
continue
if line.startswith("#") and section:
section = None
continue
if section is None or not line:
continue
matched = re.match(r"^(?:[-*•]\s+|\d{1,2}[.)]\s+)(.+)$", line)
if matched:
value = matched.group(1).strip()
if section == "ingredients" and len(ingredients) < 20:
ingredients.append(value[:180])
if section == "steps" and len(steps) < 15:
steps.append(value[:400])
return ingredients, steps
def extract_card(event: str, payload: Any) -> dict[str, Any] | None:
"""Non-intrusive optional auto-adapter for known outgoing Socket.IO messages."""
if event == EVENT:
return None
if isinstance(payload, dict):
# The integration contract is the preferred and fully reliable path.
explicit = payload.get("visual_card") or payload.get("dona_visual_card")
if isinstance(explicit, dict):
try:
return validate_card(explicit)
except ValueError:
return None
# Confirmed HA tool event. Never infer success from a user instruction.
if payload.get("source") in {"home_assistant", "ha"} and "success" in payload:
try:
return make_action_card(payload["entity_id"], payload["action"], success=payload["success"], label=payload.get("friendly_name"))
except (KeyError, ValueError, TypeError):
return None
if event not in RESPONSE_EVENTS:
return None
# Limited best-effort textual illustration. These are NEVER confirmed actions.
text = payload if isinstance(payload, str) else (
next((payload[k] for k in ("text", "reply", "response", "answer", "content") if isinstance(payload.get(k), str)), "")
if isinstance(payload, dict) else ""
)
if not text or len(text) > 12000:
return None
# Only extract an actual recipe headline from the assistant response.
match = re.search(r"(?:recette\s+(?:de\s+|d['’]\s*)|préparons\s+(?:une?\s+))((?:tarte|gâteau|quiche|salade|soupe|crêpes?|pâtes?)\s+(?:aux?\s+|à\s+la?\s+|de\s+)[^\n.!?:]{2,55})", text, flags=re.I)
if match:
ingredients, steps = _recipe_sections(text)
return make_recipe_card(match.group(1).strip(), ingredients=ingredients, steps=steps)
# Confirmed-sounding text remains marked "reported" (not verified by HA).
match = re.search(r"\b(?:j['’]ai\s+|c['’]est\s+)(éteint|allumé)\s+(?:la\s+|les\s+|le\s+)(lumière|lampes?|éclairage)\s+(?:du|de la|des|dans le)\s+([a-zÀ-ÿ\- ]{2,45})", text, re.I)
if match:
room = match.group(3).strip().split("\n")[0].strip(" .!?,")
if len(room) > 30: room = room[:30]
return validate_card({"schema": "dona.visual.v1", "type": "action_state", "status": "reported",
"title": room.capitalize(), "subtitle": "Lumière annoncée " + ("éteinte" if match.group(1).lower() == "éteint" else "allumée") + " · non vérifié",
"icon": "lightbulb-off" if match.group(1).lower() == "éteint" else "lightbulb-on", "source": "hermes"})
return None
def lookup_commons(query: str) -> dict[str, str] | None:
"""Server searches only commons.wikimedia.org; no arbitrary URL fetches/SSRF."""
query = _short(query, 80)
if not query or len(query) < 3 or not WEB_IMAGE_ENABLED:
return None
# Common French food queries are easier to illustrate in Commons English search.
search_query = {"tarte aux pommes": "apple tart", "gâteau au chocolat": "chocolate cake"}.get(query.casefold(), query)
key = query.casefold()
now = time.time()
with _cache_lock:
found = _cache.get(key)
if found and found[0] > now:
return copy.deepcopy(found[1]) if found[1] else None
# Image search is opportunistic; no action depends on a network success.
params = urllib.parse.urlencode({"action": "query", "generator": "search", "gsrsearch": f"filetype:bitmap {search_query}",
"gsrnamespace": "6", "gsrlimit": "8", "prop": "imageinfo", "iiprop": "url|extmetadata",
"iiurlwidth": "900", "format": "json", "formatversion": "2"})
req = urllib.request.Request("https://commons.wikimedia.org/w/api.php?" + params,
headers={"User-Agent": "DonaVisuals/1.0 (local voice assistant; contact: none)", "Accept": "application/json"})
result = None
try:
with urllib.request.urlopen(req, timeout=4) as response:
raw = response.read(250_000)
pages = json.loads(raw).get("query", {}).get("pages", [])
for page in pages:
info = (page.get("imageinfo") or [{}])[0]
url = _safe_web_url(info.get("thumburl") or info.get("url"))
if not url:
continue
metadata = info.get("extmetadata") or {}
# Simple attribution link; no raw HTML from metadata rendered.
source_url = _safe_web_url(info.get("descriptionurl"))
result = {"url": url, "source_url": source_url or "https://commons.wikimedia.org", "attribution": "Wikimedia Commons — consulter la licence de l’image"}
break
except (OSError, ValueError, KeyError, TypeError):
pass
with _cache_lock:
if len(_cache) > 180: _cache.clear()
_cache[key] = (now + (3600 * 12 if result else 600), result or {})
return result
def register_dona_visuals(app: Any, socketio: Any = None) -> None:
"""Register read-only endpoints and optional Socket.IO emit bridge."""
if getattr(app, "_dona_visuals_registered", False):
return
from flask import jsonify, request
app._dona_visuals_registered = True
@app.get("/api/dona-visuals/status")
def dona_visuals_status():
return jsonify({"status": "ok", "version": VERSION, "event": EVENT, "web_images": WEB_IMAGE_ENABLED, "socket_bridge": socketio is not None})
@app.get("/api/dona-visuals/image")
def dona_visuals_image():
query = request.args.get("q", "")
if not isinstance(query, str) or len(query) > 80 or len(query) < 3:
return jsonify({"error": "Invalid image query"}), 400
# Short burst limit, bounded memory, no persistent client identifiers.
remote = _short(request.remote_addr or "unknown", 48)
now = time.monotonic()
with _request_lock:
if len(_request_limits) > 500:
_request_limits.clear()
start, count = _request_limits.get(remote, (now, 0))
if now - start > 60:
start, count = now, 0
_request_limits[remote] = (start, count + 1)
limited = count >= 20
if limited:
return jsonify({"error": "Too many image requests"}), 429
image = lookup_commons(query)
return jsonify(image or {"url": None})
@app.get("/api/dona-visuals/demo/<kind>")
def dona_visuals_demo(kind):
if kind == "light":
return jsonify(make_action_card("light.salon", "turn_off", success=True, label="Salon"))
if kind == "recipe":
return jsonify(make_recipe_card("Tarte aux pommes", ingredients=["4 pommes", "1 pâte feuilletée", "40 g de sucre", "20 g de beurre"],
steps=["Préchauffer le four à 180 °C", "Étaler la pâte et disposer les pommes", "Cuire environ 35 minutes"], duration="45 min"))
return jsonify({"error": "Unknown demo"}), 404
if socketio is None or getattr(socketio, "_dona_visuals_wrapped", False):
return
original_emit = socketio.emit
@functools.wraps(original_emit)
def emit_with_visual(event, *args, **kwargs):
result = original_emit(event, *args, **kwargs)
try:
payload = args[0] if args else kwargs.get("data")
card = extract_card(event, payload)
if card:
opts = {k: kwargs[k] for k in ("to", "room", "namespace", "skip_sid") if k in kwargs}
original_emit(EVENT, card, **opts)
except Exception as exc: # NEVER affect existing Hermes/HA event delivery
app.logger.warning("[DONA-VISUAL] Visual adapter ignored exception: %s", exc)
return result
socketio.emit = emit_with_visual
socketio._dona_visuals_wrapped = True
def publish_visual(socketio: Any, card: dict[str, Any], *, to: str | None = None) -> None:
"""Call from verified business events, after the action completed successfully."""
kwargs = {"to": to} if to else {}
socketio.emit(EVENT, validate_card(card), **kwargs)
+10 -7
View File
@@ -1,5 +1,6 @@
"""Pont Alfred -> Hermes avec session persistante."""
from services.dona_hermes_stream import run_jsonl_command as _dona_run_jsonl_command # DONA_HERMES_PERF_V1
import fcntl
import json
@@ -379,13 +380,9 @@ def _call_hermes(
])
try:
process = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=False
# DONA_HERMES_PERF_V1: conserver la session, transmettre les deltas en temps réel
process = _dona_run_jsonl_command(
cmd, prompt, _hermes_timeout()
)
except subprocess.TimeoutExpired as exc:
@@ -428,6 +425,12 @@ def _call_hermes(
def ask_hermes(text: str) -> str:
# DONA_UNIFIED_ROUTING_V4 — tentative de tableau direct, sinon Hermes intact
from services.dona_unified_router_v4 import try_route as _dona_v4_try_route
_dona_v4_quick = _dona_v4_try_route(text)
if _dona_v4_quick is not None:
return _dona_v4_quick
prompt = _build_prompt(text)
# Ancien fonctionnement sans session
@@ -0,0 +1,23 @@
/* Lightweight preview: pointer-events disabled, no changes to Dona layout */
#dona-hermes-live-v1 {
position: fixed;
z-index: 999;
inset: auto 14px 20px auto;
width: min(480px, calc(100vw - 28px));
max-height: min(23vh, 185px);
padding: 10px 13px;
border-radius: 14px;
border: 1px solid rgba(143,206,246,.32);
background: rgba(9, 19, 38, .84);
color: #f1f7ff;
backdrop-filter: blur(10px);
box-shadow: 0 8px 22px rgba(0,0,0,.15);
pointer-events: none;
overflow: hidden;
font: 13px/1.4 system-ui, sans-serif;
}
#dona-hermes-live-v1[hidden] { display: none !important; }
#dona-hermes-live-v1 .dona-hlive-title { color: #80d8ff; font-weight: 650; margin-bottom: 3px; }
#dona-hermes-live-v1 .dona-hlive-text { font-size: 12px; white-space: pre-wrap; overflow: hidden; overflow-wrap: anywhere; }
@media (max-width: 580px) { #dona-hermes-live-v1 { bottom: 10px; right: 10px; max-height: 15vh; } }
@media (prefers-reduced-motion: reduce) { #dona-hermes-live-v1 { backdrop-filter: none; } }
+21
View File
@@ -0,0 +1,21 @@
/* Lightweight non-modal dock: no backdrop, UI remains usable. */
#dona-visual-dock{position:fixed;left:18px;bottom:22px;width:min(360px,calc(100vw - 36px));z-index:38;pointer-events:none;font:inherit;color:#f4f6ff;}
#dona-visual-dock .dvc-card{position:relative;pointer-events:auto;background:rgba(20,27,47,.94);border:1px solid rgba(134,171,225,.25);box-shadow:0 14px 35px rgba(0,0,0,.38);border-radius:18px;overflow:hidden;max-height:min(70vh,650px);overflow-y:auto;backdrop-filter:blur(12px);padding:16px 17px 15px;margin-top:10px;}
#dona-visual-dock .dvc-head{display:flex;align-items:center;gap:12px;padding-right:24px;}
#dona-visual-dock .dvc-icon{width:48px;height:48px;display:grid;place-items:center;flex-shrink:0;border-radius:14px;background:rgba(120,148,215,.14);}
#dona-visual-dock .dvc-icon svg{width:29px;height:29px;stroke:#b5d5ff;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;}
#dona-visual-dock .dvc-card[data-status="confirmed"] .dvc-icon svg{stroke:#a9e8c8;}
#dona-visual-dock .dvc-card[data-status="error"] .dvc-icon svg{stroke:#ffb3b9;}
#dona-visual-dock .dvc-title{margin:0;font-weight:650;font-size:17px;line-height:1.25;overflow-wrap:anywhere;}
#dona-visual-dock .dvc-subtitle{margin:4px 0 0;font-size:12px;opacity:.80;line-height:1.4;overflow-wrap:anywhere;}
#dona-visual-dock .dvc-close{position:absolute;top:10px;right:10px;width:29px;height:29px;background:rgba(255,255,255,.06);color:white;border:0;border-radius:10px;font-size:20px;cursor:pointer;}
#dona-visual-dock .dvc-image{width:100%;max-height:190px;object-fit:cover;border-radius:11px;margin:12px 0 0;display:none;}
#dona-visual-dock .dvc-image[src]{display:block;}
#dona-visual-dock .dvc-meta{opacity:.75;font-size:12px;margin-top:12px;}
#dona-visual-dock .dvc-section{margin-top:12px;font-size:13px;}
#dona-visual-dock .dvc-section strong{display:block;margin-bottom:5px;}
#dona-visual-dock ul,#dona-visual-dock ol{margin:4px 0 0;padding-left:20px;}
#dona-visual-dock li{margin:4px 0;}
#dona-visual-dock a{color:#b0d4ff;font-size:11px;}
@media(max-width:600px){#dona-visual-dock{left:10px;bottom:12px;width:min(330px,calc(100vw - 20px));}}
@media(prefers-reduced-motion:no-preference){#dona-visual-dock .dvc-card{animation:dvc-in .24s ease-out;}@keyframes dvc-in{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}}
+60 -12
View File
@@ -10,6 +10,27 @@
};
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):[];}
@@ -50,10 +71,10 @@
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;
function schedule(){stopTimer();if(!opened||!active||!['spotify','homeassistant','lights','proxmox'].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';}
function originLabel(origin){return ({spotify:'Spotify · direct',homeassistant:'Home Assistant · direct',lights:'Home Assistant · direct',hermes:'Hermes · MCP',fallback:'Hermes · réponse texte',proxmox:'Proxmox · API directe' /* DONA_DYNAMIC_PROXMOX_V3 */})[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);
@@ -128,18 +149,30 @@
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)throw Error(obj.error||'HTTP '+res.status);return obj;
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(busy)return;busy=true;status('Dona prépare le tableau…');
try{await post('/api/dona/dynamic/create',{prompt:query});status('Dona consulte les données ; la vue apparaîtra ici.');}
catch(e){status(e.message||'Demande impossible.');}
finally{busy=false;}
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(['spotify','homeassistant','lights','proxmox'].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');
@@ -163,14 +196,29 @@
primary.prepend(b);
}
let tries=0;const timer=setInterval(()=>{initButton();if($('d8Open')||++tries>70)clearInterval(timer);},150);
// DONA_UNIFIED_ROUTING_V4 — conserver le tableau quand Dona confirme à voix haute.
let v4IgnoreNextTextUntil=0;
const socket=window.alfredSocket;
socket?.on('alfred_render',data=>{
if(data?.type==='dona_dynamic')render(data.dashboard);
else if(opened && data?.type!=='dona_dynamic')close();
if(data?.type==='dona_dynamic' && data.dona_fast_v4===true)
v4IgnoreNextTextUntil=Date.now()+15000;
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'){
if(v4IgnoreNextTextUntil && Date.now()<v4IgnoreNextTextUntil){
v4IgnoreNextTextUntil=0; // Ignorer UNE confirmation de la commande directe.
}else{v4IgnoreNextTextUntil=0;close();}
}
});
socket?.on('alfred_state',data=>{
if(opened && data?.mode==='error')status('Erreur lors de la préparation du tableau.');
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);});
document.addEventListener('visibilitychange',()=>{if(!document.hidden&&opened&&active&&['spotify','homeassistant','lights','proxmox'].includes(active.origin))reloadLive(false);});
window.DonaDynamicV8={open,render,close};
})();
@@ -0,0 +1,56 @@
/* Dona Hermes CLI streaming overlay. Unobtrusive; never modifies existing cards. */
(() => {
'use strict';
if (typeof window.io !== 'function' || window.__donaHermesStreamV1) return;
window.__donaHermesStreamV1 = true;
const el = document.createElement('aside');
el.id = 'dona-hermes-live-v1';
el.setAttribute('aria-live', 'polite');
el.setAttribute('aria-label', 'Progression de la réponse de Dona');
el.hidden = true;
const label = document.createElement('div');
label.className = 'dona-hlive-title';
const body = document.createElement('div');
body.className = 'dona-hlive-text';
el.append(label, body);
document.body.appendChild(el);
let activeId = null, contents = '', closeTimer = null;
const close = (delay = 1000) => {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => { el.hidden = true; activeId = null; }, delay);
};
const socket = window.io({ reconnection: true });
socket.on('dona_hermes_stream', (event) => {
if (!event || !event.type || !event.id) return;
if (event.type === 'start') {
activeId = event.id;
contents = '';
clearTimeout(closeTimer);
label.textContent = 'Dona réfléchit…';
body.textContent = '';
el.hidden = false;
return;
}
if (event.id !== activeId) return;
if (event.type === 'first_text') {
label.textContent = 'Dona répond…';
} else if (event.type === 'delta') {
// Avoid unlimited DOM growth; normal Dona cards remain the final source.
contents = (contents + String(event.text || '')).slice(-1200);
body.textContent = contents;
} else if (event.type === 'tool') {
label.textContent = 'Dona utilise un outil…';
body.textContent = String(event.name || 'Outil en cours');
contents = '';
} else if (event.type === 'tool_done') {
label.textContent = 'Dona prépare sa réponse…';
} else if (event.type === 'end') {
close(1100);
} else if (event.type === 'error') {
label.textContent = 'Traitement interrompu';
close(2300);
}
});
socket.on('disconnect', () => { el.hidden = true; activeId = null; });
})();
+95
View File
@@ -0,0 +1,95 @@
/* Dona Visual Context v1 — safe DOM-only renderer, no HTML from Hermes. */
(() => {
"use strict";
const EVENT = 'dona_visual_card';
const ACCEPT = new Set(['action_state', 'recipe', 'media', 'weather', 'calendar', 'info']);
const PATH = (document.currentScript && document.currentScript.src) || '';
const SVG = {
'lightbulb-on': '<path d="M9 18h6m-5 4h4m-5-7a7 7 0 1 1 6 0c-.6.5-1 1.3-1 2H10c0-.7-.4-1.5-1-2Z"/><path d="M12 1v2M4 4l2 2M20 4l-2 2M1 12h2M21 12h2"/>',
'lightbulb-off': '<path d="M9 18h6m-5 4h4M5 5l14 14M10 15c-2.8-2.8-3.4-6.2-.8-9.1 3.1-3 7.7-2 9.3 1.4"/><path d="M12 1v2M1 12h2M21 12h2"/>',
'chef-hat': '<path d="M6 13a4 4 0 0 1-2-7 4 4 0 0 1 6-2 4.5 4.5 0 0 1 8 2 4 4 0 0 1 0 7v7H6zM6 16h12"/>',
'music': '<path d="M10 19V5l11-2v14M10 9l11-2"/><ellipse cx="7" cy="19" rx="3" ry="2"/><ellipse cx="18" cy="17" rx="3" ry="2"/>',
'cloud-sun': '<circle cx="18" cy="6" r="3"/><path d="M18 1v1M18 10v1M13 6h1M22 6h1M4 18a5 5 0 0 1 5-5 6 6 0 0 1 11 3 4 4 0 0 1-1 7H7a4 4 0 0 1-3-5Z"/>',
'calendar': '<rect x="3" y="5" width="18" height="17" rx="2"/><path d="M7 2v6M17 2v6M3 11h18"/>',
'check': '<path d="m5 12 4 4L19 6"/>',
'power': '<path d="M12 2v10M5 5a10 10 0 1 0 14 0"/>',
'alert': '<path d="M12 2 1 22h22L12 2Zm0 8v5m0 3v1"/>',
'thermometer': '<path d="M10 14V5a3 3 0 0 1 6 0v9a5 5 0 1 1-6 0Z"/>',
'lock': '<rect x="5" y="10" width="14" height="12" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
'unlock': '<rect x="5" y="10" width="14" height="12" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0"/>',
'blinds-open': '<rect x="3" y="3" width="18" height="19" rx="2"/><path d="M3 8h18M3 13h18"/>',
'blinds-closed': '<rect x="3" y="3" width="18" height="19" rx="2"/><path d="M3 8h18M3 13h18M3 18h18"/>',
'info': '<circle cx="12" cy="12" r="10"/><path d="M12 11v6M12 7v1"/>'
};
const el = (tag, cls, txt) => { const x = document.createElement(tag); if (cls) x.className = cls; if (txt !== undefined) x.textContent = String(txt); return x; };
const safeUrl = value => {
if (typeof value !== 'string' || value.length > 2048) return null;
try {
const u = new URL(value, location.href);
if (u.origin === location.origin && value.startsWith('/') && !value.startsWith('//')) return u.href;
if (u.protocol === 'https:' && ['upload.wikimedia.org', 'commons.wikimedia.org'].includes(u.hostname)) return u.href;
} catch (_e) { /* ignore */ }
return null;
};
function dock() {
let node = document.getElementById('dona-visual-dock');
if (!node) {node = el('aside'); node.id = 'dona-visual-dock'; node.setAttribute('aria-live', 'polite'); node.setAttribute('aria-label', 'Illustrations Dona'); document.body.appendChild(node);}
return node;
}
function addSection(card, heading, values, ordered) {
if (!Array.isArray(values) || !values.length) return;
const sec = el('div', 'dvc-section'), strong = el('strong', '', heading), list = el(ordered ? 'ol' : 'ul');
sec.appendChild(strong); values.slice(0, ordered ? 15 : 20).forEach(item => {
if (typeof item === 'string') list.appendChild(el('li', '', item));
}); sec.appendChild(list); card.appendChild(sec);
}
function imageFor(card, image) {
if (!image || typeof image !== 'object') return;
const img = el('img', 'dvc-image'); img.alt = String(image.alt || 'Illustration').slice(0, 140); img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
const apply = url => {const safe = safeUrl(url); if (safe) {img.src = safe; img.onerror = () => img.removeAttribute('src');}};
if (image.source === 'web' && image.query) {
const endpoint = '/api/dona-visuals/image?q=' + encodeURIComponent(String(image.query).slice(0, 80));
fetch(endpoint, {credentials: 'same-origin'}).then(r => r.ok ? r.json() : null).then(data => {
if (!data || !img.isConnected) return;
apply(data.url);
if (data.source_url) {
const source = safeUrl(data.source_url);
if (source) { const a = el('a', '', data.attribution || 'Source : Wikimedia Commons'); a.href = source; a.target = '_blank'; a.rel = 'noopener noreferrer'; card.appendChild(a); }
}
}).catch(() => {});
} else if (image.url && ['local', 'comfyui', 'web'].includes(image.source)) apply(image.url);
card.appendChild(img);
}
function render(payload) {
if (!payload || payload.schema !== 'dona.visual.v1' || !ACCEPT.has(payload.type) || typeof payload.title !== 'string') return;
const root = dock(); root.replaceChildren(); // one context card; no pile of overlays
const card = el('section', 'dvc-card'); card.dataset.status = String(payload.status || 'informational');
const close = el('button', 'dvc-close', '×'); close.type = 'button'; close.setAttribute('aria-label', 'Fermer la carte'); close.addEventListener('click', () => root.replaceChildren()); card.appendChild(close);
const head = el('div', 'dvc-head'), icon = el('div', 'dvc-icon'), svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');
svg.innerHTML = SVG[payload.icon] || SVG.info; // only trusted local constant strings
icon.appendChild(svg); head.appendChild(icon);
const texts = el('div'); texts.appendChild(el('h3', 'dvc-title', payload.title));
if (payload.subtitle) texts.appendChild(el('p', 'dvc-subtitle', payload.subtitle));
head.appendChild(texts); card.appendChild(head);
imageFor(card, payload.image);
if (payload.duration) card.appendChild(el('div', 'dvc-meta', 'Durée : ' + payload.duration));
addSection(card, 'Ingrédients', payload.ingredients, false);
addSection(card, 'Préparation', payload.steps, true);
addSection(card, 'Détails', payload.details, false);
root.appendChild(card);
if (payload.type === 'action_state') window.setTimeout(() => { if (root.contains(card)) card.remove(); }, 13000);
}
window.DonaVisuals = Object.freeze({render});
function init() {
if (typeof window.io === 'function') {
try { const socket = window.io(); socket.on(EVENT, render); }
catch (err) { console.warn('[DONA-VISUAL] Cannot attach socket:', err); }
} else console.warn('[DONA-VISUAL] Socket.IO client not loaded');
const params = new URLSearchParams(location.search);
const demo = params.get('dona_visual_demo');
if (['light', 'recipe'].includes(demo)) fetch('/api/dona-visuals/demo/' + demo).then(r => r.json()).then(render).catch(() => {});
}
if (document.readyState === 'complete') init(); else window.addEventListener('load', init, {once: true});
})();
+8
View File
@@ -55,6 +55,10 @@
<link rel="stylesheet" href="/static/css/dona-waves-v1.css?v=1">
<!-- DONA_REPLY_FLOATING_V1 -->
<link rel="stylesheet" href="/static/css/dona-reply-floating-v1.css?v=1">
<!-- DONA_HERMES_PERF_V1 -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/dona-hermes-stream-v1.css') }}">
<!-- DONA VISUAL CSS V1 -->
<link rel="stylesheet" href="/static/css/dona-visuals-v1.css">
</head>
<body>
<main class="alfred-shell">
@@ -262,5 +266,9 @@
<script defer src="/static/js/dona-waves-v1.js?v=1"></script>
<!-- DONA_REPLY_FLOATING_V1 -->
<script defer src="/static/js/dona-reply-floating-v1.js?v=1"></script>
<!-- DONA_HERMES_PERF_V1 -->
<script defer src="{{ url_for('static', filename='js/dona-hermes-stream-v1.js') }}"></script>
<!-- DONA VISUAL JS V1 -->
<script defer src="/static/js/dona-visuals-v1.js"></script>
</body>
</html>
@@ -0,0 +1,936 @@
import os, threading, hmac
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
# Charger la configuration AVANT les imports des services
load_dotenv("/opt/alfred/.env")
load_dotenv(
"/opt/alfred/app/config/control_center.env",
override=True
)
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
from services.piper_tts import stop_playback as duplex_stop_piper
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import dona_clock_local as _dona_clock_local # DONA_CLOCK_SCULPTURE_V11
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
from services import alfred_features # ALFRED_FEATURES_V45
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
from services import camera_settings, ha_weather, weather_outfits
load_dotenv()
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
# DONA_HERMES_PERF_V1: progression non bloquante des réponses Hermes
from services.dona_hermes_stream import configure_emitter as _dona_stream_emitter
_dona_stream_emitter(lambda event: socketio.emit("dona_hermes_stream", event))
# DONA_UNIFIED_ROUTING_V4 — tableaux standards depuis les requêtes vocales/écrites
from services.dona_unified_router_v4 import configure_emitter as _dona_v4_emitter
_dona_v4_emitter(lambda event: socketio.emit('alfred_render', event))
# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur.
from services import piper_tts as _dona_waves_piper
_dona_waves_piper.dona_set_level_callback(
lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)})
)
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
# Service temps autonome (survit aux conversations Hermes)
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
on_interrupt=duplex_stop_piper,
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
camera_settings.restore_saved_settings()
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
if not conversation_lock.acquire(blocking=False):
return
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
from services.audio import STTResult
r = STTResult(text=injected_text)
else:
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
# DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée.
_dona_v93_original_vosk = r.text
_dona_v93_ack = _dona_learning_v93.voice_feedback(r.text)
if _dona_v93_ack is not None:
socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack})
if not audio_settings.speaker_muted():
_dona_v93_done = threading.Event()
if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set):
_dona_v93_done.wait(timeout=120)
emit_state('idle',partial='')
return
r.text = _dona_learning_v93.correct_transcript(r.text)
if r.text != _dona_v93_original_vosk:
socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text})
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# DONA_CLOCK_SCULPTURE_V11 : heure/date locales AVANT Hermes.
_dona_local_clock_reply = _dona_clock_local.answer(r.text)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,_dona_local_clock_reply))
if not audio_settings.speaker_muted():
emit_state('speaking')
_dona_clock_done=threading.Event()
_dona_clock_started=speak_async(_dona_local_clock_reply,
on_end=lambda: (_dona_clock_done.set(),
socketio.emit('alfred_tts_finished', {'source':'voice'})))
if _dona_clock_started:_dona_clock_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
if _dona_vision_v95.is_camera_question(r.text):
try:
_vision_answer = _dona_vision_monitor.analyze(r.text)
except RuntimeError as _vision_exc:
_vision_answer = str(_vision_exc)
except Exception as _vision_exc:
print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True)
_vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer))
if _vision_answer and not audio_settings.speaker_muted():
emit_state('speaking')
_vision_done=threading.Event()
if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_FILES_V91 : questions explicites sur la pièce jointe active.
if _dona_files_v91.is_file_question(r.text):
_file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech))
if _file_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_file_done = threading.Event()
if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120)
emit_state('idle', partial='')
return
# DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes.
if _dona_suite_v9.is_request(r.text):
_v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech))
if _v9_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_v9_done=threading.Event()
_v9_ok=speak_async(_v9_speech,on_end=_v9_done.set)
if _v9_ok:_v9_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM.
if _dona_nav.is_request(r.text):
_nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply))
if _nav_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_nav_done=threading.Event()
_nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'})))
if _nav_ok:_nav_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix.
if _dona_youtube.is_search(r.text):
_speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech))
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_evt=threading.Event()
_ok=speak_async(_speech,on_end=_evt.set)
if _ok: _evt.wait(timeout=120)
emit_state('idle',partial='')
return
if _dona_youtube.is_control(r.text,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(r.text,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
if _dona_dynamic.is_dynamic_request(r.text) or _dona_json_autorender.is_monitor_request(r.text):
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic', 'dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render', _payload)
if not audio_settings.speaker_muted():
emit_state('speaking')
_finished = threading.Event()
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
if _started: _finished.wait(timeout=120)
emit_state('idle', partial='')
return
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
done.wait(timeout=120)
emit_state("idle", partial="")
return
# ALFRED_LISTES_GLASS_V1_VOICE_START
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
try:
_lists_reply = _alfred_handle_list_voice(
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
except Exception as _lists_error:
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
if _lists_reply is not None:
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply))
_lists_display = {'type':'text', 'text':_lists_reply}
with STATE_LOCK:
STATE['content'] = _lists_display
socketio.emit('alfred_render', _lists_display)
if _lists_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_lists_done = threading.Event()
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_lists_done.wait(timeout=120)
emit_state('idle', partial='')
return
# ALFRED_LISTES_GLASS_V1_VOICE_END
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(r.text))
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
display, speech = _dona_json_autorender.convert(answer, r.text, display, speech)
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
alfred_context_images.schedule_context_image(
r.text, speech, socketio,
lambda: STATE.get("transcript") == r.text,
) # ALFRED_TIMERS_CONTEXT_V1
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
alfred_features.publish_voice_display(display)
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
try:
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
if started:
done.wait(timeout=120)
finally:
if not duplex_monitor.interrupted():
duplex_monitor.stop()
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
if duplex_monitor.interrupted():
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
duplex_monitor.stop()
else:
# A few wake-free seconds after the assistant finishes speaking.
pending_duplex_text = duplex_monitor.listen_conversation()
if pending_duplex_text:
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
# Only the voice is cancelled; the original Hermes job is untouched.
wake.pause()
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
daemon=True, name='dona-duplex-followup').start()
elif not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@app.get('/api/duplex/status')
def duplex_status():
return jsonify(duplex_monitor.status())
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
def _alfred_dashboard_quick_command(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _run():
try:
wake.pause()
with STATE_LOCK:
_image_revision_before = IMAGE_REVISION
emit_state('thinking', transcript=prompt, partial='',
content={'type':'text', 'text':prompt})
# DONA_JSON_AUTORENDER_V1: operational shortcuts use the isolated
# dashboard session; ordinary shortcuts remain regular Hermes calls.
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
_display = {'type':'dona_dynamic','dashboard':_board}
_speech = 'Voici le tableau de bord : ' + _board['title']
else:
_answer = ask_hermes(prompt)
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
with STATE_LOCK:
if IMAGE_REVISION == _image_revision_before:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_done = threading.Event()
speak_async(_speech, on_end=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_done.wait(timeout=120)
emit_state('idle', partial='')
except Exception as _exc:
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
emit_state('error', content={'type':'text', 'text':str(_exc)})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
return True
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
# ALFRED_ADMIN_GLASS_MAISON_V1_START
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
_alfred_register_home_glass(app)
# ALFRED_ADMIN_GLASS_MAISON_V1_END
# ALFRED_LISTES_GLASS_V1_BACKEND_START
from services.alfred_lists import register_lists as _alfred_register_lists
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
_alfred_register_lists(app, socketio)
# ALFRED_LISTES_GLASS_V1_BACKEND_END
# ALFRED_GLASS_V2_BACKEND_START
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
_alfred_register_logs_v2(app)
# ALFRED_GLASS_V2_BACKEND_END
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
_alfred_identity_register(app)
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
_alfred_developer_register(app)
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
_register_hermes_new_session(app)
# DONA_FAMILY_UI_V1_START
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
def _dona_submit_typed(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _dona_run_typed():
try:
wake.pause()
with STATE_LOCK:
_before_image = IMAGE_REVISION
emit_state('thinking', partial='', transcript=prompt,
content={'type': 'text', 'text': prompt})
# DONA_CLOCK_SCULPTURE_V11 : même traitement depuis Écrire à Dona / raccourcis.
_dona_local_clock_reply = _dona_clock_local.answer(prompt)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_dona_local_clock_reply))
emit_state('idle',partial='')
return
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
if _dona_vision_v95.is_camera_question(prompt):
try:
_vision_answer=_dona_vision_monitor.analyze(prompt)
except Exception as _vision_exc:
_vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer))
emit_state('idle',partial='')
return
# DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe.
if _dona_files_v91.is_file_question(prompt):
_dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK)
emit_state('idle',partial='')
return
# DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes.
if _dona_suite_v9.is_request(prompt):
_v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes.
if _dona_nav.is_request(prompt):
_v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
if _dona_youtube.is_search(prompt):
_v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
if _dona_youtube.is_control(prompt,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(prompt,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render',_payload)
emit_state('idle', partial='')
return
_answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt))
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
# DONA_LEARNING_V93 : évaluation de l’échange écrit.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_speech))
# Deliberately no speak_async(): typed interaction is silent.
emit_state('idle', partial='')
except Exception as exc:
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
import traceback
traceback.print_exc()
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
return True
from services.dona_family_ui import register_dona_family as _dona_register_family
_dona_register_family(app, adult_command=_dona_submit_typed)
# DONA_FAMILY_UI_V1_END
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
from services import dona_dynamic as _dona_dynamic
# DONA_JSON_AUTORENDER_V1 : rendu automatique JSON Hermes, demandes et raccourcis
from services import dona_json_autorender_v1 as _dona_json_autorender
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
from services import dona_dashboard_agent as _dona_dashboard_agent
def _dona_dynamic_submit(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _work():
try:
wake.pause()
emit_state('thinking',transcript=prompt,partial='')
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
_payload={'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content']=_payload
socketio.emit('alfred_render',_payload)
emit_state('idle',partial='')
except Exception as exc:
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
return True
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in.
from services import dona_vision_v95 as _dona_vision_v95
_dona_vision_monitor = _dona_vision_v95.register(app, socketio)
# DONA_LEARNING_V93 : retours explicites et corrections approuvées.
from services import dona_learning_v93 as _dona_learning_v93
_dona_learning_v93.register(app)
# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur.
from services import dona_shortcuts_v92 as _dona_shortcuts_v92
_dona_shortcuts_v92.register(app)
# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée.
from services import dona_files_v91 as _dona_files_v91
_dona_files_v91.register_files(app)
# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines.
from services import dona_suite_v9 as _dona_suite_v9
_dona_suite_v9.register_suite(app,socketio)
# DONA_NAV_V83: géocodage, carte et transfert Waze.
from services import dona_navigation as _dona_nav
_dona_nav.register_navigation(app,socketio)
# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé.
from services import dona_youtube as _dona_youtube
_dona_youtube.register_youtube(app,socketio)
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
_register_dona_spotify(app)
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
from services.dona_face import (register_face as _dona_register_face,
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,270 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
<!-- ALFRED_MAIN_VISUAL_V1 -->
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
<!-- ALFRED_LISTES_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
<!-- ALFRED_GLASS_V2 -->
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
<!-- ALFRED_REPONSE_CLOSE_V1 -->
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
<!-- DONA_FAMILY_UI_V1 -->
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
<!-- DONA_V7_RELEASE -->
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
<!-- DONA_V8_DYNAMIC -->
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
<!-- DONA_V8_1_ISOLATION -->
<link rel="stylesheet" href="/static/css/dona-header-v8-1.css?v=81">
<!-- DONA_YOUTUBE_V82 -->
<link rel="stylesheet" href="/static/css/dona-youtube-v8-2.css?v=82">
<!-- DONA_NAV_V83 -->
<link rel="stylesheet" href="/static/css/dona-navigation-v8-3.css?v=83">
<!-- DONA_SUITE_V9 -->
<link rel="stylesheet" href="/static/css/dona-suite-v9.css?v=90">
<!-- DONA_FILES_V91 -->
<link rel="stylesheet" href="/static/css/dona-files-v9-1.css?v=91">
<!-- DONA_UI_V92 -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2.css?v=92">
<!-- DONA_UI_V921_CLOSE -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2-1-close.css?v=921">
<!-- DONA_LEARNING_V93 -->
<link rel="stylesheet" href="/static/css/dona-learning-v9-3.css?v=93">
<!-- DONA_VISION_V95 -->
<link rel="stylesheet" href="/static/css/dona-vision-v9-5.css?v=95">
<!-- DONA_WAVES_V1 -->
<link rel="stylesheet" href="/static/css/dona-waves-v1.css?v=1">
<!-- DONA_REPLY_FLOATING_V1 -->
<link rel="stylesheet" href="/static/css/dona-reply-floating-v1.css?v=1">
<!-- DONA_HERMES_PERF_V1 -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/dona-hermes-stream-v1.css') }}">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
<path d="m16.5 10 5-3v10l-5-3"/>
</svg>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
<div id="alfredTimersList"></div>
</aside>
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
aria-modal="true" hidden>
<article class="alfred-context-card">
<button id="alfredContextClose" class="alfred-context-close" type="button"
aria-label="Fermer l'illustration" title="Fermer">×</button>
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
<div class="alfred-context-details">
<h2 id="alfredContextTitle"></h2>
<p id="alfredContextText"></p>
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
</div>
</article>
</div>
<script src="/static/js/alfred-timers-context.js"></script>
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
<script src="/static/js/alfred-main-v1.js?v=1"></script>
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<script src="/static/js/alfred-maison-card.js?v=1"></script>
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
<script src="/static/js/alfred-main-v2.js?v=2"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
<script defer src="/static/js/dona-spotify.js?v=6"></script>
<!-- dona-ui-fix start -->
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
<!-- dona-ui-fix end -->
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
<script defer src="/static/js/dona-header-v8-1.js?v=81"></script>
<script defer src="/static/js/dona-youtube-v8-2.js?v=82"></script>
<script defer src="/static/js/dona-navigation-v8-3.js?v=83"></script>
<script defer src="/static/js/dona-suite-v9.js?v=90"></script>
<script defer src="/static/js/dona-files-v9-1.js?v=91"></script>
<script defer src="/static/js/dona-ui-v9-2.js?v=92"></script>
<script defer src="/static/js/dona-ui-v9-2-1-close.js?v=921"></script>
<script defer src="/static/js/dona-learning-v9-3.js?v=93"></script>
<script defer src="/static/js/dona-vision-v9-5.js?v=95"></script>
<!-- DONA_WAVES_V1 -->
<script defer src="/static/js/dona-waves-v1.js?v=1"></script>
<!-- DONA_REPLY_FLOATING_V1 -->
<script defer src="/static/js/dona-reply-floating-v1.js?v=1"></script>
<!-- DONA_HERMES_PERF_V1 -->
<script defer src="{{ url_for('static', filename='js/dona-hermes-stream-v1.js') }}"></script>
</body>
</html>