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