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};
|
||||
})();
|
||||
Reference in New Issue
Block a user