From c2a4f7eb2680d8b5946e1906492e7f68e06d4f97 Mon Sep 17 00:00:00 2001 From: pporcheret Date: Mon, 2 Nov 2026 08:50:05 +0100 Subject: [PATCH] debut illustration --- .../20261101T161217190652Z/manifest.json | 15 + .../original/services/dona_hermes_stream.py | 205 ++++ .../original/static/js/dona-dynamic-v8.js | 176 ++++ .../20261101T162604315114Z/manifest.json | 20 + .../original/services/dona_dynamic.py | 294 ++++++ .../original/static/js/dona-dynamic-v8.js | 216 ++++ .../20261101T152747103028Z/manifest.json | 32 + .../20261101T152747103028Z/original/app.py | 929 +++++++++++++++++ .../original/services/hermes_client.py | 480 +++++++++ .../original/templates/index.html | 266 +++++ .../20261101T165809642643Z/manifest.json | 25 + .../20261101T165809642643Z/original/app.py | 933 +++++++++++++++++ .../original/services/hermes_client.py | 477 +++++++++ .../original/static/js/dona-dynamic-v8.js | 216 ++++ alfred/app/.dona_visual_context_v1.json | 21 + alfred/app/app.py | 17 + alfred/app/cache/weather_outfit_cache.json | 87 ++ ...23f62edfc7a5c67e13cdf8a38be151ca19d996.jpg | Bin 0 -> 19787 bytes ...9a86a94893b931ac43cb404e916374d92e89b8.jpg | Bin 0 -> 24950 bytes ...b490848988448afdf31169a3261dee0d407d27.jpg | Bin 0 -> 26147 bytes alfred/app/config/hermes_session.spotify.json | 3 + .../config/hermes_session.spotify.json.lock | 0 alfred/app/data/alfred_features.sqlite3 | Bin 69632 -> 69632 bytes alfred/app/data/dona-learning-v93.sqlite3 | Bin 45056 -> 53248 bytes .../__pycache__/dona_dynamic.cpython-313.pyc | Bin 26255 -> 27606 bytes .../dona_hermes_stream.cpython-313.pyc | Bin 0 -> 12838 bytes .../dona_proxmox_direct.cpython-313.pyc | Bin 0 -> 19319 bytes .../dona_unified_router_v4.cpython-313.pyc | Bin 0 -> 7042 bytes .../__pycache__/hermes_client.cpython-313.pyc | Bin 13719 -> 13900 bytes alfred/app/services/dona_dynamic.py | 29 + alfred/app/services/dona_hermes_stream.py | 248 +++++ alfred/app/services/dona_proxmox_direct.py | 234 +++++ alfred/app/services/dona_unified_router_v4.py | 137 +++ alfred/app/services/dona_visuals/__init__.py | 317 ++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 22529 bytes alfred/app/services/hermes_client.py | 17 +- .../app/static/css/dona-hermes-stream-v1.css | 23 + alfred/app/static/css/dona-visuals-v1.css | 21 + alfred/app/static/js/dona-dynamic-v8.js | 72 +- alfred/app/static/js/dona-hermes-stream-v1.js | 56 ++ alfred/app/static/js/dona-visuals-v1.js | 95 ++ alfred/app/templates/index.html | 8 + .../20261102T071929Z-373243/app.py | 936 ++++++++++++++++++ .../templates/index.html | 270 +++++ 44 files changed, 6856 insertions(+), 19 deletions(-) create mode 100644 alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/manifest.json create mode 100644 alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/original/services/dona_hermes_stream.py create mode 100644 alfred/app/.dona_dynamic_perf_v2_backups/20261101T161217190652Z/original/static/js/dona-dynamic-v8.js create mode 100644 alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/manifest.json create mode 100644 alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/original/services/dona_dynamic.py create mode 100644 alfred/app/.dona_dynamic_proxmox_v3_backups/20261101T162604315114Z/original/static/js/dona-dynamic-v8.js create mode 100644 alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/manifest.json create mode 100644 alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/app.py create mode 100644 alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/services/hermes_client.py create mode 100644 alfred/app/.dona_hermes_perf_v1_backups/20261101T152747103028Z/original/templates/index.html create mode 100644 alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/manifest.json create mode 100644 alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/app.py create mode 100644 alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/services/hermes_client.py create mode 100644 alfred/app/.dona_unified_routing_v4_backups/20261101T165809642643Z/original/static/js/dona-dynamic-v8.js create mode 100644 alfred/app/.dona_visual_context_v1.json create mode 100644 alfred/app/cache/weather_outfit_images/3df6376c38a741020f3a1be98423f62edfc7a5c67e13cdf8a38be151ca19d996.jpg create mode 100644 alfred/app/cache/weather_outfit_images/40aa2eb3e1b0aca2c1408757099a86a94893b931ac43cb404e916374d92e89b8.jpg create mode 100644 alfred/app/cache/weather_outfit_images/6b23e886b9faf492bc345e414eb490848988448afdf31169a3261dee0d407d27.jpg create mode 100644 alfred/app/config/hermes_session.spotify.json create mode 100644 alfred/app/config/hermes_session.spotify.json.lock create mode 100644 alfred/app/services/__pycache__/dona_hermes_stream.cpython-313.pyc create mode 100644 alfred/app/services/__pycache__/dona_proxmox_direct.cpython-313.pyc create mode 100644 alfred/app/services/__pycache__/dona_unified_router_v4.cpython-313.pyc create mode 100644 alfred/app/services/dona_hermes_stream.py create mode 100644 alfred/app/services/dona_proxmox_direct.py create mode 100644 alfred/app/services/dona_unified_router_v4.py create mode 100644 alfred/app/services/dona_visuals/__init__.py create mode 100644 alfred/app/services/dona_visuals/__pycache__/__init__.cpython-313.pyc create mode 100644 alfred/app/static/css/dona-hermes-stream-v1.css create mode 100644 alfred/app/static/css/dona-visuals-v1.css create mode 100644 alfred/app/static/js/dona-hermes-stream-v1.js create mode 100644 alfred/app/static/js/dona-visuals-v1.js create mode 100644 alfred/backups/dona-visual-context/20261102T071929Z-373243/app.py create mode 100644 alfred/backups/dona-visual-context/20261102T071929Z-373243/templates/index.html 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 0000000000000000000000000000000000000000..86c7ff65ed2c1fc85c9c82925e1377a6fa34c8ea GIT binary patch literal 19787 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

s^`c>jkmzle?4zk+zi4&2U+#Jwtxdrcn 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..65f7bace09985e6f5223456aba5c6f0b78f5cb5b GIT binary patch literal 24950 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 Z&lThAg(n?ya)m2N3Uk6I%)bAf`7ecm(n|mU literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b8d974bed0331e51512ea21ce005ea782e70ee8f GIT binary patch literal 26147 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 z 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 bVJd{omy~9;ydjxCd~jQg3wCM7|IYm{h+$%g literal 0 HcmV?d00001 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 e54fec039db1a5123ad2d8a70d3a52574401433c..e0a89dd16e2ea455b9972156d52a10dea189f9df 100644 GIT binary patch literal 69632 zcmeI*&2JmW83u4tvYlGC%ovEF9#UkW94N6AL48?@fIw9g1$8RPiA^I3ph8&f4xN$7 z-Sy5cAvN%!LD3lKp*i%>LxJ{IseeQ+gpWP<(4JbL=N=2>lINWj^%>blVziO+P+n8y z?#{mRo8`*;zGM6D`^zTPsu?A1FIAI;mkWJ;g`X%@C=_Z~u4B23v}_ zPkF8tYQK0mSo&|_;?*aGYmZBR9em~5&+#oE6rcbFC_n)UP=Epypa2EFnZU}$OGD+M z;sX-~ddv5cz^-R;;H5fnFD@J^EXEJc3Ah)(NEv$e`#oF zsQ9Y~skhP6ZU^_3T0d?$Q0@Yahu%X_&b8BVc&PVEJOCMF0#FoFk zdb^=k?=3G^Rcg|fKKAB`);6MG`{*|6?WK1X?kzV|xm;B?_0r7d8ytH7L^FZ0am(9Y z-?UM9Z0k=Rp`FyT57zf0ub$ZZ0Ox2ZRi(FLlV}Szx8}&6qoWrt4ZS&3EVyy84_mm? zT=%jxaoLKS$!1aXsvN zu)mG6#6LcCDo&zyoI4yG>tu_u*8a!G#sDMmtP#>l5+%oXJJR~-Xy4Pko!$Dldy;$k zAIOJo5KHmOzYF-q2L&iV0SZun0u-PC1t@SX1Rf6+2Uq6jUmN=6jbGpU?E2svv-O!b zW^`RoZ}?MF8~*IZ#;i9zTb~VPCa3GOQxo-xn`7hkv5A|F`uN<$Z3R&nc6ZTmP|gqGj@CN@?kd$LTEKy@ zowP1(w8|#XVQQMj3vt{n&rcF$PZ2PkVLeUtIR2!y7)ljzzf!|8Uchv(FAs<+bN z>fSS6pR3VZ3)jMPIex>B25@~Vmi|$|FFq(h0SZun0u-PC1t>rP3Q&Lo6gYzdulKK9 zepaSW>i>QHVd2W(`@_uGZIPfwB0YbN znMlY!2fC*I^T*%+ZeUs7Ix^sQjq2$Q(zRNMbW^)9DCNLi>HP{+CeruY@wm0y0-cy&< zj&*{0Z<0ip9QoWp1v9%UmDPh|OPEq1h_a4UD_qzk0BL)nX##3*{b;39Rk6;}vF=|a z#g5SBWm0o3u1E<02f$peveXAK)eYN76Ot@P4&L{(SXvT{ZJTy?+IUwYk`uv%nXC)S z7+@SQ!Q47moIqg#XGv%hx0_7&!!8l}m`kJUonD)GJ|ynRl(OD1%3uek*#vY82Sw9T zi$F$S!rwo^fQ&KOK#j(avRK6}Of7W93RFSzQ%Lr}`O_PTsHG9fU84|HN29Xi1|dPo z2ZfXl_VVsa$1yTMlgWauD8V}4oz)m@#DL+O8yK(2fGp%?qeQAe@;Q(*<WH4YN(e zJQ7P4MQjg!6g=Oxm?3%C^zKDy$TKMg*3Zn9juM5ME^2+=8Sbd^VpP8GOotv6HT$1mtn3~*Q@p!m6 z_`waS_`mkq^_BU8O#B^t*8PBN#Ogi%B=9|`-tW)ZIQ^#%%lPugB>dd|PEW%ae)O!5 z^{MlAr2gMm`l3+!qV%`t`#Li>TMU!}6rcbFC_n)UP=Epypa2CZa83k%*gswTx*}rV zNdE_ypDW*&-~T@;l>SwEQu=dg#eF@&_lLd)e{=ZjoY1iw6rcbFC_n)UP=Epypa2CZ zK!LL+@T30u;;Hk4`d;myzkF)_|EzVD@lt>S6rcbFC_n)UP=Epypa2CZKmiI+fC3bt z00k&O0SZun0u-PC1t@T;fc!Opm$6*IautjGvw+vI$o~;o!XoQm!g3u89~7Vf1t>rP z3Q&Lo6rcbFC_n)UP=Epypa2CZKmiI+fC3bt00k&O0SZun0u-PC1t>rP3Q&Lo6rcbF zC_n)UP=Epypa2CZKmiI+fC3bt00k&O0SZun0u-PC1t>rP3Q&Lo6rcbFC_n)UP=Epy zpa2CZKmiI+fC3bt00k&O0SZun0u-PC1t>rP3Q&Lo6rcbFC_n)UP=Epypa2CZKmiI+ mfC3bt00k&O0SZun0u-PC1t>rP3Q&Lo6rcbFC_sTTC-4>5Q$AJz literal 69632 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 BIJ5u& diff --git a/alfred/app/data/dona-learning-v93.sqlite3 b/alfred/app/data/dona-learning-v93.sqlite3 index ca4371d0980f7cd725f3749c8d8d0de0e0c46a0d..20c25ba6a8311d989ae81de08ce466acd9c4552b 100644 GIT binary patch delta 6950 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 delta 1086 zcmZ8gU2GIp6u#%q?yx`Io$YpKLw~ol(Cq^DN0x_bnqU_JB~sgQq-B*l+fK)|Wp}x= zo1kfw#0T}M#$zH6#_$GDL^2OV@#hRO1uDh#$d1NJf*Dy{EGd$C9iV9AH?}s{|=yl z#}CiL0RDKm8#<6qbnj}&hWLJMX?`sreA61z+`EVPkPcdSh!&`(=yW`cH(HvuM3THl z-|#r{UjyOscH!|0(s&fdjy8cRwCTqBy>Ry*o))c5Yf95c{Er=C*Kx<>1YVd-(RG5q zOxh5{7msv4F9oXn>ewlDS!x;U%4y}o-xM#cX5*><3UG0@FHZS?QhQPC2SK_5 zZWCOY9in##hOHg(bxyhR@rqe7XMX>RCn-gY9ktrQkH@U!qr<(fCs&+#eWPhawCBSw zSQ*&t{B12j^8+y=#(9_KO6(tzg@j7Q_wzk+D-Zu9_(i^_vB5h5Rf)(wYgB}5ZB8{G lh1y%@2jCU07@2E_F6^88FxC#e>w7?kNvK8#A;DU>;9pet5KsUB 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 0000000000000000000000000000000000000000..27dbb66cb2c6d4819776c7a37c0d32aa84fe706c GIT binary patch literal 12838 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 W6U08^bETSikhpH|B?+5A#{UAp^PY|X literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c324da7aa744aa59033f50999162221c19a5247a GIT binary patch literal 19319 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 M%UwNVD5kUczx6L`5C8xG literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d1484f033fd58585fe58702bc79a482f9dc320c6 GIT binary patch literal 7042 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 literal 0 HcmV?d00001 diff --git a/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc b/alfred/app/services/__pycache__/hermes_client.cpython-313.pyc index 85eb689c0e367261dabeb6a3b07f3a55da0e7cc5..f1a29133a3ebd121c7692ae29acfcde45069484a 100644 GIT binary patch delta 3205 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 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 0000000000000000000000000000000000000000..14014a135af2b99e3272eb38c8ae2140f682a796 GIT binary patch literal 22529 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 vT~DfMK>iKge^^q 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.

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