diff --git a/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/manifest.json b/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/manifest.json new file mode 100644 index 0000000..9be667d --- /dev/null +++ b/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/manifest.json @@ -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 + } + } +} \ No newline at end of file diff --git a/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/original/services/dona_hermes_stream.py b/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/original/services/dona_hermes_stream.py new file mode 100644 index 0000000..7334ea2 --- /dev/null +++ b/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/original/services/dona_hermes_stream.py @@ -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 diff --git a/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/original/static/js/dona-dynamic-v8.js b/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/original/static/js/dona-dynamic-v8.js new file mode 100644 index 0000000..92de7a1 --- /dev/null +++ b/alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/original/static/js/dona-dynamic-v8.js @@ -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}; +})(); diff --git a/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/manifest.json b/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/manifest.json new file mode 100644 index 0000000..5aae3de --- /dev/null +++ b/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/manifest.json @@ -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 + } + } +} \ No newline at end of file diff --git a/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/original/services/dona_dynamic.py b/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/original/services/dona_dynamic.py new file mode 100644 index 0000000..32adf6d --- /dev/null +++ b/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/original/services/dona_dynamic.py @@ -0,0 +1,294 @@ +"""Dona V8: trusted data-only dashboard protocol; no arbitrary HTML/JS/code execution. + +LLM can suggest *data* and layout from an explicit user request; server validates shape. +Client renders with DOM textContent only. Source tools stay in existing Hermes/MCP. +""" +from __future__ import annotations +import json +import re +import math +import unicodedata +from datetime import datetime, timezone +from urllib.parse import urlsplit +from flask import Blueprint, jsonify, request + +MAX_PROMPT = 500 +MAX_RESPONSE = 120000 +CARD_TYPES = frozenset({'metric','text','list','table','bars','light'}) + + +def is_dynamic_request(text): + q = str(text or '').casefold() + return bool(re.search(r'(tableau de bord|tableau dynamique|graphique|visualise|visualiser|vue synth[eé]tique|affiche.{0,30}sous forme de (tableau|cartes|graphique)|cr[eé]e.{0,30}(un [eé]cran|une interface)|(?:montre|affiche|pr[eé]sente)(?:[ -]moi)? .{0,65}(?:[eé]tat|statistiques|performances|proxmox|wazuh|serveurs?))', q)) + + +def _short(value, limit=200): + if isinstance(value, (dict, list)): + return '' + return str(value if value is not None else '').replace('\x00','').strip()[:limit] + + +def _numeric(value): + if isinstance(value, bool): return None + if isinstance(value, str): value = value.strip().replace(',', '.') + try: number = float(value) + except (ValueError, TypeError, OverflowError): return None + return number if math.isfinite(number) and -1e10 < number < 1e10 else None + + +def _table_row(row, columns): + if isinstance(row, list): + return [_short(v, 140) for v in row[:len(columns)]] + if not isinstance(row, dict): return None + def key(v): + return ''.join(c for c in unicodedata.normalize('NFD', str(v).casefold()) + if unicodedata.category(c) != 'Mn').strip() + obj = {key(k):v for k,v in row.items()} + aliases = {'nom':('name','id'), 'etat':('state','status'), + 'statut':('status','state'), 'noeud':('node',)} + return [_short(next((obj[k] for k in (key(col),)+aliases.get(key(col),()) if k in obj), ''), 140) + for col in columns] + + +def _repair_brackets(text): + # Répare uniquement les fermetures ]/} confondues, hors chaînes JSON. + stack, output = [], [] + quoted = escaped = False + for char in text: + if quoted: + if escaped: escaped = False + elif char == "\\": escaped = True + elif char == '"': quoted = False + elif char == '"': quoted = True + elif char in '{[': stack.append(char) + elif char in '}]': + if not stack: return text + char = '}' if stack.pop() == '{' else ']' + output.append(char) + return ''.join(output) + + +def normalize_dashboard(raw, *, prompt='', origin='hermes'): + """Reject unsupported widgets/fields. No actions, URLs, HTML or code accepted.""" + if not isinstance(raw, dict): + raise ValueError('Tableau de bord non structuré.') + cards = raw.get('cards') + if not isinstance(cards,list): + raise ValueError('Liste de cartes absente.') + result = [] + for c in cards[:16]: + if not isinstance(c,dict): + continue + kind = _short(c.get('type') or c.get('kind'),20) + if kind not in CARD_TYPES or (kind=='light' and origin != 'lights'): + continue + card = {'type':kind,'title':_short(c.get('title') or c.get('label') or '',100)} + if kind == 'light': + card.update(entity_id=_short(c.get('entity_id'),120), state=_short(c.get('state'),24)) + if not re.fullmatch(r'light\.[a-z0-9_]+',card['entity_id']):continue + elif kind == 'metric': + card.update(value=_short(c.get('value'),80),unit=_short(c.get('unit'),24), + detail=_short(c.get('detail'),150),status=_short(c.get('status'),20)) + elif kind == 'text': + card['text'] = _short(c.get('text'),2200) + elif kind == 'list': + card['items'] = [_short(x,300) for x in (c.get('items') or [])[:32] if not isinstance(x,(dict,list))] if isinstance(c.get('items'),list) else [] + elif kind == 'table': + cols = c.get('columns') or [] + card['columns'] = [_short(x,50) for x in cols[:6]] if isinstance(cols,list) else [] + rows = c.get('rows') or [] + card['rows'] = [v for v in (_table_row(r, card['columns']) for r in rows[:35]) if v is not None] if isinstance(rows,list) else [] + if not card['columns']: continue + elif kind == 'bars': + items=c.get('items') or [] + card['items'] = [{'label':_short(x.get('label'),80),'value':_numeric(x.get('value')),'unit':_short(x.get('unit'),12)} for x in items[:22] if isinstance(x,dict) and _numeric(x.get('value')) is not None] if isinstance(items,list) else [] + result.append(card) + if not result: + raise ValueError('Aucune carte exploitable.') + return { + 'schema':'dona.dashboard.v1', 'title':_short(raw.get('title') or 'Tableau dynamique',110), + 'subtitle':_short(raw.get('subtitle') or '',240), 'prompt':_short(prompt,MAX_PROMPT), + 'origin':_short(origin,24), 'generated_at':datetime.now(timezone.utc).isoformat(), + 'cards':result, + } + + +def extract_dashboard(text): + """Take JSON anywhere in a model response, including fenced JSON.""" + if not isinstance(text,str) or len(text)>MAX_RESPONSE: + raise ValueError('Réponse trop volumineuse ou invalide.') + text = text.replace(' ', ' ').replace(' ', ' ') + decoder=json.JSONDecoder() + for i,char in enumerate(text): + if char != '{':continue + try: + obj,_=decoder.raw_decode(text,i) + except ValueError: + continue + if isinstance(obj,dict) and isinstance(obj.get('cards'),list): + return obj + # Seconde passe : réparation contrôlée des fermetures ] et }. + last = text.rfind('}') + if last >= 0: + for i, c in enumerate(text): + if c != '{' or '"cards"' not in text[i:min(last+1, i+3000)]: continue + candidate = _repair_brackets(text[i:last+1]) + try: obj, _ = decoder.raw_decode(candidate) + except ValueError: continue + if isinstance(obj,dict) and isinstance(obj.get('cards'),list): return obj + raise ValueError('Aucune structure JSON exploitable dans la réponse Hermes.') + + +def _fallback(answer,prompt): + """Show the actual Hermes answer rather than inventing measurements.""" + safe = _short(answer,6500) + if not safe: + safe='Hermes n’a pas fourni de données pour construire ce tableau.' + return normalize_dashboard({ + 'title':'Résultat · '+_short(prompt,75), + 'subtitle':'Affichage textuel : Hermes n’a pas renvoyé de cartes structurées.', + 'cards':[{'type':'text','title':'Réponse Hermes','text':safe[:2200]}] + + ([{'type':'text','title':'Suite de la réponse','text':safe[2200:4400]}] if len(safe)>2200 else []) + + ([{'type':'text','title':'Fin de la réponse','text':safe[4400:6500]}] if len(safe)>4400 else []), + },prompt=prompt,origin='fallback') + + +def _live_spotify(prompt): + from services import dona_spotify + raw=dona_spotify._call('GET','/me/player') + devices=dona_spotify._call('GET','/me/player/devices').get('devices') or [] + pub=dona_spotify._public_state(raw,[{'name':x.get('name',''), 'is_active':bool(x.get('is_active'))} for x in devices if isinstance(x,dict)]) + cards=[{'type':'metric','title':'Lecture','value':'En cours' if pub['is_playing'] else 'À l’arrêt'}, + {'type':'text','title':'Morceau','text':(pub['title']+' — '+pub['artist']).strip(' —') or 'Aucun morceau actuellement sélectionné.'}, + {'type':'list','title':'Appareils Spotify Connect','items':[(x.get('name') or 'Sans nom') + (' · actif' if x.get('is_active') else '') for x in pub['devices']]}] + return normalize_dashboard({'title':'Spotify Connect','subtitle':'Données directes de Spotify · actualisation sans LLM','cards':cards},prompt=prompt,origin='spotify') + + +def _live_temperatures(prompt): + from services import ha_views + raw=ha_views.get_temperatures() + cards=[{'type':'metric','title':'Capteurs disponibles','value':str(len(raw))}, + {'type':'table','title':'Températures Home Assistant','columns':['Capteur','Température'], + 'rows':[[x.get('name','Capteur'),str(x.get('value','—'))+' '+str(x.get('unit',''))] for x in raw[:35] if isinstance(x,dict)]}] + return normalize_dashboard({'title':'Températures de la maison','subtitle':'Données directes de Home Assistant','cards':cards},prompt=prompt,origin='homeassistant') + + +def _live_lights(prompt): + from services import ha_views + states=ha_views.states() or [] + cards=[{'type':'metric','title':'Lumières allumées', + 'value':str(sum(1 for x in states if isinstance(x,dict) and x.get('entity_id','').startswith('light.') and x.get('state')=='on'))}] + for x in states: + if not isinstance(x,dict):continue + eid=x.get('entity_id','') + if not isinstance(eid,str) or not re.fullmatch(r'light\.[a-z0-9_]+',eid):continue + attrs=x.get('attributes') or {} + cards.append({'type':'light','title':attrs.get('friendly_name') or eid,'entity_id':eid,'state':x.get('state','unknown')}) + if len(cards)>=16:break + return normalize_dashboard({'title':'Éclairage de la maison', + 'subtitle':'Données Home Assistant en direct · les interrupteurs agissent sur les lumières', + 'cards':cards},prompt=prompt,origin='lights') + + +def choose_live_source(prompt): + q = str(prompt).casefold() + if re.search(r'\bspotify\b|\blecteur musical\b',q) and not re.search(r'\b(et|avec|ainsi que)\b.{0,25}\b(proxmox|wazuh|home assistant|temp[eé]rature)',q): + return 'spotify' + if re.search(r'\blumi[eè]res?\b|\b[eé]clairage\b|\blampes?\b',q) and not re.search(r'\b(proxmox|wazuh|spotify|serveur)\b',q): + return 'lights' + if re.search(r'\btemp[eé]ratures?\b',q) and not re.search(r'\b(proxmox|wazuh|spotify|serveur)\b',q): + return 'temperatures' + return None + + +def build_dashboard(prompt, ask_hermes): + """Calls existing MCP through Hermes only when no suitable direct read API exists.""" + if not isinstance(prompt,str) or not 2 <= len(prompt.strip()) <= MAX_PROMPT: + raise ValueError('Demande de 2 à 500 caractères requise.') + source=choose_live_source(prompt) + if source=='spotify':return _live_spotify(prompt) + if source=='temperatures':return _live_temperatures(prompt) + if source=='lights':return _live_lights(prompt) + instruction=( + 'Demande originale : '+prompt+'\n\n' + 'Interroge d’abord les outils disponibles (MCP) pour obtenir les données réelles. ' + 'N’invente jamais de serveurs, valeurs, statuts, nombres ou périodes. ' + 'Si les outils échouent, affiche des cartes texte indiquant les données indisponibles. ' + 'Réponds UNIQUEMENT par un objet JSON valide, sans balises Markdown ni commentaire. ' + 'Schéma: {"title":"...","subtitle":"...","cards":[ ' + '{"type":"metric","title":"Nœuds actifs","value":"5/5","detail":"..."}, ' + '{"type":"list","title":"Nœuds","items":["nom — état"]}, ' + '{"type":"table","title":"Machines","columns":["Nom","État"],"rows":[["...","..."]]}, ' + '{"type":"bars","title":"CPU (%)","items":[{"label":"nœud","value":42}]}, ' + '{"type":"text","title":"Remarques","text":"..."} ]}. ' + 'N’utilise que les types metric, list, table, bars ou text, uniquement les cartes pertinentes. ' + 'Les exemples du schéma ci-dessus sont illustratifs, pas des valeurs à reproduire. ' + 'N’inclus aucun HTML, JavaScript, lien ou action exécutable.' + ) + answer=ask_hermes(instruction) + if isinstance(answer,dict):answer=str(answer.get('text') or answer.get('speech') or '') + try: + return normalize_dashboard(extract_dashboard(answer),prompt=prompt) + except ValueError: + return _fallback(answer,prompt) + + +def _same_origin(): + if request.headers.get('X-Alfred-Request')!='1' or not request.is_json:return False + origin=request.headers.get('Origin') + if origin: + p=urlsplit(origin) + return p.scheme in ('http','https') and p.netloc==request.host + return request.headers.get('Sec-Fetch-Site','same-origin') in ('same-origin','none') + + +def register_dynamic(app, submit): + bp=Blueprint('dona_dynamic_v8',__name__) + @bp.get('/api/dona/dynamic/live') + def live(): + source=request.args.get('source','') + try: + if source=='spotify':board=_live_spotify('Tableau Spotify') + elif source=='homeassistant':board=_live_temperatures('Tableau températures') + elif source=='lights':board=_live_lights('Tableau lumières') + else:return jsonify(ok=False,error='Source invalide.'),400 + response=jsonify(ok=True,dashboard=board) + response.headers['Cache-Control']='no-store, private' + return response + except Exception: + app.logger.exception('Dona dynamic live') + return jsonify(ok=False,error='Données momentanément indisponibles.'),503 + + @bp.post('/api/dona/dynamic/lights/toggle') + def lights_toggle(): + if not _same_origin():return jsonify(ok=False,error='Requête non autorisée.'),403 + data=request.get_json(silent=True) + eid=data.get('entity_id') if isinstance(data,dict) else None + if not isinstance(eid,str) or not re.fullmatch(r'light\.[a-z0-9_]{1,100}',eid): + return jsonify(ok=False,error='Entité invalide.'),400 + try: + from services import ha_views + # Refuse une entité qui n'existe pas dans HA. + if not any(x.get('entity_id')==eid and x.get('state') in ('on','off') for x in (ha_views.states() or []) if isinstance(x,dict)): + return jsonify(ok=False,error='Lumière absente de Home Assistant.'),404 + ha_views._request('POST','/api/services/light/toggle',payload={'entity_id':eid}) + return jsonify(ok=True) + except Exception: + app.logger.exception('Dona dynamic light action') + return jsonify(ok=False,error='Home Assistant n’a pas exécuté la commande.'),503 + + @bp.post('/api/dona/dynamic/create') + def create(): + if not _same_origin():return jsonify(ok=False,error='Origine non autorisée.'),403 + if request.content_length and request.content_length>3000:return jsonify(ok=False,error='Requête trop volumineuse.'),413 + body=request.get_json(silent=True) + prompt=body.get('prompt') if isinstance(body,dict) else None + if not isinstance(prompt,str) or not 2<=len(prompt.strip())<=MAX_PROMPT: + return jsonify(ok=False,error='Demande de 2 à 500 caractères requise.'),400 + try:queued=submit(prompt.strip()) + except Exception: + app.logger.exception('Dona dynamic submit') + return jsonify(ok=False,error='Impossible de créer la vue.'),503 + if not queued:return jsonify(ok=False,error='Une autre demande est en cours.'),409 + return jsonify(ok=True,queued=True),202 + app.register_blueprint(bp) diff --git a/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/original/static/js/dona-dynamic-v8.js b/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/original/static/js/dona-dynamic-v8.js new file mode 100644 index 0000000..4024e91 --- /dev/null +++ b/alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/original/static/js/dona-dynamic-v8.js @@ -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}; +})(); diff --git a/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/manifest.json b/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/manifest.json new file mode 100644 index 0000000..4e5d2f3 --- /dev/null +++ b/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/manifest.json @@ -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" + } + } +} \ No newline at end of file diff --git a/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/app.py b/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/app.py new file mode 100644 index 0000000..8f219d9 --- /dev/null +++ b/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/app.py @@ -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//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) + diff --git a/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/services/hermes_client.py b/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/services/hermes_client.py new file mode 100644 index 0000000..f8c5175 --- /dev/null +++ b/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/services/hermes_client.py @@ -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 diff --git a/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/templates/index.html b/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/templates/index.html new file mode 100644 index 0000000..b57194a --- /dev/null +++ b/alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/templates/index.html @@ -0,0 +1,266 @@ + + + + + + + {{ name }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/manifest.json b/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/manifest.json new file mode 100644 index 0000000..9f21057 --- /dev/null +++ b/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/manifest.json @@ -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 + } + } +} \ No newline at end of file diff --git a/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/app.py b/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/app.py new file mode 100644 index 0000000..10accea --- /dev/null +++ b/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/app.py @@ -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//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) + diff --git a/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/services/hermes_client.py b/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/services/hermes_client.py new file mode 100644 index 0000000..7198893 --- /dev/null +++ b/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/services/hermes_client.py @@ -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 diff --git a/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/static/js/dona-dynamic-v8.js b/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/static/js/dona-dynamic-v8.js new file mode 100644 index 0000000..ba8fa94 --- /dev/null +++ b/alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/static/js/dona-dynamic-v8.js @@ -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}; +})(); diff --git a/alfred/app/.dona_visual_context_v1.json b/alfred/app/.dona_visual_context_v1.json new file mode 100644 index 0000000..62b118b --- /dev/null +++ b/alfred/app/.dona_visual_context_v1.json @@ -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" +} \ No newline at end of file diff --git a/alfred/app/app.py b/alfred/app/app.py index 8f219d9..0e350ca 100644 --- a/alfred/app/app.py +++ b/alfred/app/app.py @@ -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) diff --git a/alfred/app/cache/weather_outfit_cache.json b/alfred/app/cache/weather_outfit_cache.json index 672df6f..68dfe88 100644 --- a/alfred/app/cache/weather_outfit_cache.json +++ b/alfred/app/cache/weather_outfit_cache.json @@ -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" } } \ No newline at end of file diff --git a/alfred/app/cache/weather_outfit_images/3df6376c38a741020f3a1be98423f62edfc7a5c67e13cdf8a38be151ca19d996.jpg b/alfred/app/cache/weather_outfit_images/3df6376c38a741020f3a1be98423f62edfc7a5c67e13cdf8a38be151ca19d996.jpg new file mode 100644 index 0000000..86c7ff6 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/3df6376c38a741020f3a1be98423f62edfc7a5c67e13cdf8a38be151ca19d996.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/40aa2eb3e1b0aca2c1408757099a86a94893b931ac43cb404e916374d92e89b8.jpg b/alfred/app/cache/weather_outfit_images/40aa2eb3e1b0aca2c1408757099a86a94893b931ac43cb404e916374d92e89b8.jpg new file mode 100644 index 0000000..65f7bac Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/40aa2eb3e1b0aca2c1408757099a86a94893b931ac43cb404e916374d92e89b8.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/6b23e886b9faf492bc345e414eb490848988448afdf31169a3261dee0d407d27.jpg b/alfred/app/cache/weather_outfit_images/6b23e886b9faf492bc345e414eb490848988448afdf31169a3261dee0d407d27.jpg new file mode 100644 index 0000000..b8d974b Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/6b23e886b9faf492bc345e414eb490848988448afdf31169a3261dee0d407d27.jpg differ diff --git a/alfred/app/config/hermes_session.spotify.json b/alfred/app/config/hermes_session.spotify.json new file mode 100644 index 0000000..56d0d01 --- /dev/null +++ b/alfred/app/config/hermes_session.spotify.json @@ -0,0 +1,3 @@ +{ + "session_id": "local-906583bb7279defc4c4c1a2859fa1369a456fe1544c5c1bfddb1c644dbdce63d" +} diff --git a/alfred/app/config/hermes_session.spotify.json.lock b/alfred/app/config/hermes_session.spotify.json.lock new file mode 100644 index 0000000..e69de29 diff --git a/alfred/app/data/alfred_features.sqlite3 b/alfred/app/data/alfred_features.sqlite3 index e54fec0..e0a89dd 100644 Binary files a/alfred/app/data/alfred_features.sqlite3 and b/alfred/app/data/alfred_features.sqlite3 differ diff --git a/alfred/app/data/dona-learning-v93.sqlite3 b/alfred/app/data/dona-learning-v93.sqlite3 index ca4371d..20c25ba 100644 Binary files a/alfred/app/data/dona-learning-v93.sqlite3 and b/alfred/app/data/dona-learning-v93.sqlite3 differ diff --git a/alfred/app/services/__pycache__/dona_dynamic.cpython-313.pyc b/alfred/app/services/__pycache__/dona_dynamic.cpython-313.pyc index fd68713..82c2577 100644 Binary files a/alfred/app/services/__pycache__/dona_dynamic.cpython-313.pyc and b/alfred/app/services/__pycache__/dona_dynamic.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/dona_hermes_stream.cpython-313.pyc b/alfred/app/services/__pycache__/dona_hermes_stream.cpython-313.pyc new file mode 100644 index 0000000..27dbb66 Binary files /dev/null and b/alfred/app/services/__pycache__/dona_hermes_stream.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/dona_proxmox_direct.cpython-313.pyc b/alfred/app/services/__pycache__/dona_proxmox_direct.cpython-313.pyc new file mode 100644 index 0000000..c324da7 Binary files /dev/null and b/alfred/app/services/__pycache__/dona_proxmox_direct.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/dona_unified_router_v4.cpython-313.pyc b/alfred/app/services/__pycache__/dona_unified_router_v4.cpython-313.pyc new file mode 100644 index 0000000..d1484f0 Binary files /dev/null and b/alfred/app/services/__pycache__/dona_unified_router_v4.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc b/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc index 85eb689..f1a2913 100644 Binary files a/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc and b/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc differ diff --git a/alfred/app/services/dona_dynamic.py b/alfred/app/services/dona_dynamic.py index 32adf6d..207b643 100644 --- a/alfred/app/services/dona_dynamic.py +++ b/alfred/app/services/dona_dynamic.py @@ -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' diff --git a/alfred/app/services/dona_hermes_stream.py b/alfred/app/services/dona_hermes_stream.py new file mode 100644 index 0000000..4a22be1 --- /dev/null +++ b/alfred/app/services/dona_hermes_stream.py @@ -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 diff --git a/alfred/app/services/dona_proxmox_direct.py b/alfred/app/services/dona_proxmox_direct.py new file mode 100644 index 0000000..4423c15 --- /dev/null +++ b/alfred/app/services/dona_proxmox_direct.py @@ -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()) diff --git a/alfred/app/services/dona_unified_router_v4.py b/alfred/app/services/dona_unified_router_v4.py new file mode 100644 index 0000000..05ae3aa --- /dev/null +++ b/alfred/app/services/dona_unified_router_v4.py @@ -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] diff --git a/alfred/app/services/dona_visuals/__init__.py b/alfred/app/services/dona_visuals/__init__.py new file mode 100644 index 0000000..0b6b188 --- /dev/null +++ b/alfred/app/services/dona_visuals/__init__.py @@ -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/") + 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) diff --git a/alfred/app/services/dona_visuals/__pycache__/__init__.cpython-313.pyc b/alfred/app/services/dona_visuals/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..14014a1 Binary files /dev/null and b/alfred/app/services/dona_visuals/__pycache__/__init__.cpython-313.pyc differ diff --git a/alfred/app/services/hermes_client.py b/alfred/app/services/hermes_client.py index f8c5175..aaa90a5 100644 --- a/alfred/app/services/hermes_client.py +++ b/alfred/app/services/hermes_client.py @@ -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 diff --git a/alfred/app/static/css/dona-hermes-stream-v1.css b/alfred/app/static/css/dona-hermes-stream-v1.css new file mode 100644 index 0000000..f1b9557 --- /dev/null +++ b/alfred/app/static/css/dona-hermes-stream-v1.css @@ -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; } } diff --git a/alfred/app/static/css/dona-visuals-v1.css b/alfred/app/static/css/dona-visuals-v1.css new file mode 100644 index 0000000..071ca55 --- /dev/null +++ b/alfred/app/static/css/dona-visuals-v1.css @@ -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)}}} diff --git a/alfred/app/static/js/dona-dynamic-v8.js b/alfred/app/static/js/dona-dynamic-v8.js index 92de7a1..0e9db61 100644 --- a/alfred/app/static/js/dona-dynamic-v8.js +++ b/alfred/app/static/js/dona-dynamic-v8.js @@ -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(){ - 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}; })(); diff --git a/alfred/app/static/js/dona-hermes-stream-v1.js b/alfred/app/static/js/dona-hermes-stream-v1.js new file mode 100644 index 0000000..f3e9683 --- /dev/null +++ b/alfred/app/static/js/dona-hermes-stream-v1.js @@ -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; }); +})(); diff --git a/alfred/app/static/js/dona-visuals-v1.js b/alfred/app/static/js/dona-visuals-v1.js new file mode 100644 index 0000000..b33aa46 --- /dev/null +++ b/alfred/app/static/js/dona-visuals-v1.js @@ -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': '', + 'lightbulb-off': '', + 'chef-hat': '', + 'music': '', + 'cloud-sun': '', + 'calendar': '', + 'check': '', + 'power': '', + 'alert': '', + 'thermometer': '', + 'lock': '', + 'unlock': '', + 'blinds-open': '', + 'blinds-closed': '', + 'info': '' + }; + 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}); +})(); diff --git a/alfred/app/templates/index.html b/alfred/app/templates/index.html index b57194a..8b42681 100644 --- a/alfred/app/templates/index.html +++ b/alfred/app/templates/index.html @@ -55,6 +55,10 @@ + + + +
@@ -262,5 +266,9 @@ + + + + diff --git a/alfred/backups/dona-visual-context/20261102T071929Z-373243/app.py b/alfred/backups/dona-visual-context/20261102T071929Z-373243/app.py new file mode 100644 index 0000000..b440579 --- /dev/null +++ b/alfred/backups/dona-visual-context/20261102T071929Z-373243/app.py @@ -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//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) + diff --git a/alfred/backups/dona-visual-context/20261102T071929Z-373243/templates/index.html b/alfred/backups/dona-visual-context/20261102T071929Z-373243/templates/index.html new file mode 100644 index 0000000..e638596 --- /dev/null +++ b/alfred/backups/dona-visual-context/20261102T071929Z-373243/templates/index.html @@ -0,0 +1,270 @@ + + + + + + + {{ name }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +