v2.2 - Small improvement Interface change

This commit is contained in:
pporcheret
2026-10-29 11:13:02 +01:00
parent a17c3cb614
commit 7f26f9b015
271 changed files with 6207 additions and 16044 deletions
@@ -0,0 +1,95 @@
{
"installed": true,
"files": {
"app.py": {
"existed": true,
"old_sha": "b552034de304ef5203d26ed9ba5d52928974d7a001cd326f2dde3be057941631",
"new_sha": "8652f012f05fd6437f5b53fc9d3b76492b1e1b29669750fda200b00d3f078735",
"mode": 420
},
"mcp/alfred_developer_mcp.py": {
"existed": false,
"old_sha": null,
"new_sha": "f8ac5aac221f6762746af469a3ed04175101ad1ce02ed74dfbbaebd3ab140268",
"mode": 420
},
"services/alfred_developer_core.py": {
"existed": false,
"old_sha": null,
"new_sha": "339171cc1461b9bbea6265167ab8bfb08bcd300bddf489dfa44fd17143af32dd",
"mode": 420
},
"services/alfred_developer_page.py": {
"existed": false,
"old_sha": null,
"new_sha": "97006796328692616dac54bec416a5bda71ace5da9260e32e4c7695086138d76",
"mode": 420
},
"skills/alfred-developer/SKILL.md": {
"existed": false,
"old_sha": null,
"new_sha": "95ea0ceccb617a842958bae176d2d91a1e8c6a41c9b420d018bbd50547476943",
"mode": 420
},
"static/css/alfred-developer-glass.css": {
"existed": false,
"old_sha": null,
"new_sha": "e894cfc0bc18a5e941785a2bf2b5959277c691c5d9f82c64271413bd45ff4e5e",
"mode": 420
},
"static/js/alfred-developer-glass.js": {
"existed": false,
"old_sha": null,
"new_sha": "360590f0652888f13433b6dda4dd5ef13f495790114bcb376c9d5107fe03e12a",
"mode": 420
},
"static/js/alfred-maintenance-link.js": {
"existed": false,
"old_sha": null,
"new_sha": "d489ef6dbf19f842bb1ce5edc3986c1b52f5285ae352352482104f6d368e602e",
"mode": 420
},
"templates/alfred_identity_glass.html": {
"existed": true,
"old_sha": "2c912c7d23499f217e4eff4a5c7020344a9f443279443c81a875d394949de154",
"new_sha": "bd43ead3b3be510f16084f44121bf2d23b192539eab832bbc0ca60d9f040759c",
"mode": 420
},
"templates/alfred_logs_v2.html": {
"existed": true,
"old_sha": "eb3fe9bb05766e5868ab84956ee4660f0ef0d5990c499f710829bd5a40eb035e",
"new_sha": "c5b41ff1258fe9f3c2169678071673daca4b24223b541b532cb9d348529e1f6c",
"mode": 420
},
"templates/alfred_maintenance.html": {
"existed": false,
"old_sha": null,
"new_sha": "509f82fb16040eb65f174be45a5f0dc8048856980f7604e8d9ca6aedd00d4670",
"mode": 420
},
"templates/config.html": {
"existed": true,
"old_sha": "e29cba3bee6e702856f602ca48ceeb554fd4347c96dab7311999fb20fcebb2d7",
"new_sha": "22823b16a4c93b9a3fb9b69538db755509fceb8e2048097caa7965bbcb2ff44b",
"mode": 420
},
"templates/index.html": {
"existed": true,
"old_sha": "60636d8b9cbf0f08f25eb0d5e4c1f5729f8bd009c1a4dc1742939b58a0e27c75",
"new_sha": "afab6eea4bb25ba1066d5b9f0939d8e76b3daa014fd0ff029e511bebed39ce17",
"mode": 420
},
"templates/surveillance.html": {
"existed": true,
"old_sha": "9d271013722038be7ef924c4cdc032f3b9e50094a6278d46335eedba7c392bdc",
"new_sha": "8f5184d6b888f6a533e3a059ea59bf5e5bfdca06fea5e947faab2d5bcea0750a",
"mode": 420
},
"tools/alfred_deploy.py": {
"existed": false,
"old_sha": null,
"new_sha": "33fb7609545cc5634250c1361aa92ea8a99e4169ec88a8cb66f9ebb886f50f58",
"mode": 420
}
}
}
@@ -0,0 +1,580 @@
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
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 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="*")
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
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# 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
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=done.set)
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:
_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=_lists_done.set)
_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
answer = ask_hermes(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}
# 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=done.set)
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})
_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}
# 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=_done.set)
_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)
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,41 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Identité · Assistant</title>
<link rel="stylesheet" href="/static/css/alfred-identity-glass.css?v=1">
<script src="/static/js/alfred-identity-glass.js?v=1" defer></script>
</head>
<body>
<header class="id-head">
<a class="id-brand" href="/"><span class="id-letter" id="idInitial">A</span><span><strong id="idName">Assistant</strong><small>Identité et personnalité</small></span></a>
<nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity" class="active" aria-current="page">Identité</a></nav>
</header>
<main class="id-main">
<div class="id-title"><div><p class="id-eyebrow">PERSONNALISATION · HERMES</p><h1>Identité de l'assistant</h1><p>Personnalise la personnalité, le ton et les instructions permanentes transmises à Hermes.</p></div><span class="id-pill" id="idBadge">Vérification…</span></div>
<section class="id-panel" id="idLoginPanel" hidden>
<h2>Accès administrateur</h2><p>Utilise le même mot de passe que la page Configuration.</p>
<form id="idLoginForm"><label for="idPassword">Mot de passe</label><input type="password" id="idPassword" autocomplete="current-password" required><button type="submit" class="id-button primary">Se connecter</button></form>
<p id="idLoginMessage" role="alert"></p>
</section>
<section id="idEditor" hidden>
<div class="id-layout">
<div class="id-panel id-editor-card">
<div class="id-editor-title"><div><h2>Document d'identité</h2><p>Édition directe de <code>config/identity.md</code> · format Markdown</p></div><span id="idCount">0 caractère</span></div>
<label class="id-sr-only" for="idText">Instructions Hermes</label>
<textarea id="idText" spellcheck="false" autocomplete="off" aria-describedby="idEditorHelp"></textarea>
<p class="id-tip" id="idEditorHelp">Les changements du texte s'appliquent aux prochaines demandes vocales. Les sessions Hermes anciennes peuvent conserver leur propre historique.</p>
<div class="id-buttons"><button type="button" class="id-button" id="idReload">Recharger</button><button type="button" class="id-button primary" id="idSave">Enregistrer l'identité</button></div>
<p id="idMessage" role="status" aria-live="polite"></p>
</div>
<aside class="id-side">
<section class="id-panel"><p class="id-kicker">IDENTITÉ ACTIVE</p><div class="id-current"><span id="idAvatar">A</span><div><strong id="idActiveName">—</strong><small>Nom configuré dans /config</small></div></div><p>Modifier le nom dans <a href="/config">Configuration</a> synchronise automatiquement les mentions de l'assistant dans ce document.</p></section>
<section class="id-panel"><h2>Conseils de rédaction</h2><p>Décris les règles de conversation et les préférences durables. Pour les outils spécialisés, utilise les Skills Hermes.</p><div class="id-example">## Style<br>Tu réponds en français, avec des phrases naturelles.<br><br>## Outils<br>Tu confirmes une action seulement après le succès de son outil.</div></section>
<section class="id-panel"><h2>Sauvegardes</h2><p>Une copie est créée avant chaque modification dans <code>config/identity_history/</code>. Les fichiers existants et les noms techniques ne sont pas renommés.</p></section>
</aside>
</div>
</section>
</main>
</body>
</html>
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Journaux · Alfred</title>
<link rel="stylesheet" href="/static/css/alfred-logs-v2.css?v=2">
<script src="/static/js/alfred-logs-v2.js?v=2" defer></script>
</head>
<body>
<header class="lg-header">
<a href="/" class="lg-brand"><span class="lg-letter" id="lgLetter">A</span><span><strong id="lgName">Alfred</strong><small>Journaux de l'assistant</small></span></a>
<nav class="lg-nav" aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs" class="active" aria-current="page">Journaux</a></nav>
</header>
<main class="lg-shell">
<div class="lg-title"><div><p class="lg-eyebrow">OBSERVABILITÉ · ALFRED</p><h1>Journaux</h1><p>Activité du service Alfred, reconnaissance vocale, Hermes et appels d'outils lorsqu'ils sont journalisés.</p></div><div class="lg-actions"><button id="lgRefresh" type="button">Actualiser</button><button id="lgExport" type="button">Exporter en JSON</button></div></div>
<section class="lg-filters" aria-label="Filtres des événements">
<label>Recherche<input id="lgSearch" type="search" placeholder="Proxmox, Whisper, erreur…" autocomplete="off"></label>
<label>Composant<select id="lgComponent"><option value="">Tous</option><option>Vocal</option><option>Hermes</option><option>MCP</option><option>Piper</option><option>Système</option></select></label>
<label>Niveau<select id="lgLevel"><option value="">Tous</option><option>Information</option><option>Avertissement</option><option>Erreur</option></select></label>
<label>Affichage<select id="lgLimit"><option value="100">100 lignes</option><option value="220" selected>220 lignes</option><option value="400">400 lignes</option></select></label>
<label class="lg-check"><input id="lgAuto" type="checkbox" checked> Actualisation automatique (10 s)</label>
</section>
<section class="lg-table-wrap" aria-label="Événements">
<div class="lg-table-top"><span id="lgStatus" role="status" aria-live="polite">Connexion…</span><span id="lgCount"></span></div>
<div id="lgEvents" class="lg-events" aria-live="off"></div>
<p class="lg-note">Source : journal systemd <code>alfred.service</code>. Les commandes Hermes exécutées dans un autre processus ne sont visibles ici que si Alfred les journalise. Les secrets courants sont masqués, mais vérifie les données avant tout partage.</p>
</section>
</main>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
</body>
</html>
@@ -0,0 +1,115 @@
<!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="#121b25"><title>Alfred · Configuration</title>
<link rel="stylesheet" href="/static/css/alfred-admin.css">
<script defer src="/static/js/alfred-admin.js"></script>
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
</head>
<body data-page="config">
<div class="admin-layout">
<header class="admin-header">
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
</header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
<div class="two-col">
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.</small></div>
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
</section>
</div>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : reconnaissance vocale distante -->
<section class="panel admin-v2-stt-panel">
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2>Reconnaissance vocale (STT)</h2><p>Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage</p></div></div>
<div class="field"><label for="STT_ENGINE">Moteur de transcription final</label>
<select id="STT_ENGINE" data-setting><option value="vosk">Vosk local (mode historique)</option><option value="wyoming">Whisper Wyoming distant (conseillé)</option></select>
<small>Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.</small></div>
<div class="admin-v2-form-grid">
<div class="field"><label for="WYOMING_WHISPER_HOST">Serveur Wyoming Whisper</label><input id="WYOMING_WHISPER_HOST" data-setting placeholder="192.168.1.13" autocomplete="off"><small>Adresse IP ou nom DNS, sans http://</small></div>
<div class="field"><label for="WYOMING_WHISPER_PORT">Port TCP Wyoming</label><input type="number" min="1" max="65535" id="WYOMING_WHISPER_PORT" data-setting placeholder="10300"></div>
<div class="field"><label for="WYOMING_WHISPER_LANGUAGE">Langue</label><input id="WYOMING_WHISPER_LANGUAGE" data-setting maxlength="2" placeholder="fr"></div>
<div class="field"><label for="WYOMING_WHISPER_TIMEOUT">Délai maximum transcription (s)</label><input id="WYOMING_WHISPER_TIMEOUT" type="number" min="5" max="90" step="0.5" data-setting></div>
<div class="field"><label for="WYOMING_WHISPER_CONNECT_TIMEOUT">Délai de connexion (s)</label><input id="WYOMING_WHISPER_CONNECT_TIMEOUT" type="number" min="1" max="15" step="0.5" data-setting></div>
<div class="field"><label for="STT_MAX_AUDIO_SECONDS">Durée audio maximale (s)</label><input id="STT_MAX_AUDIO_SECONDS" type="number" min="5" max="60" step="1" data-setting></div>
</div>
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
</section>
<!-- ALFRED_DUPLEX_BARGE_IN_V1 -->
<section class="panel" id="alfredDuplexPanel">
<div class="panel-title"><span class="panel-symbol">≋</span><div>
<h2>Interruption vocale (duplex)</h2>
<p>Couper la parole à l'assistant pendant la lecture Piper</p>
</div></div>
<div class="field"><label for="DUPLEX_MODE">Mode d'interruption</label>
<select id="DUPLEX_MODE" data-setting>
<option value="off">Désactivé (comportement actuel)</option>
<option value="wake">Avec le nom d'activation — recommandé</option>
<option value="natural">Naturelle sans le nom — expérimental</option>
</select>
<small>En mode « avec le nom », prononce le mot de réveil configuré (Dona, si c'est ton WAKE_WORD). En mode naturel, un début de parole distinct du TTS peut interrompre la réponse.</small>
</div>
<div class="field"><label for="DUPLEX_MIC_THRESHOLD">Seuil micro (sensibilité)</label>
<input type="number" id="DUPLEX_MIC_THRESHOLD" data-setting min="0.008" max="0.30" step="0.005" placeholder="0.025">
<small>Augmenter pour limiter les déclenchements intempestifs ; valeur initiale 0.025.</small>
</div>
<!-- ALFRED_DUPLEX_CONVERSATION_V2 -->
<div class="field switch-field"><label for="DUPLEX_STOP_COMMANDS">Commandes « Stop », « Silence », « Arrête »
<small>Coupe uniquement Piper, sans nom d’activation ni annulation des tâches Hermes.</small></label>
<input id="DUPLEX_STOP_COMMANDS" type="checkbox" data-setting></div>
<div class="field switch-field"><label for="DUPLEX_HANDOFF">Nouvelle question pendant la réponse
<small>Interromps Dona et continue avec une question sans devoir répéter le nom.</small></label>
<input id="DUPLEX_HANDOFF" type="checkbox" data-setting></div>
<div class="field switch-field"><label for="DUPLEX_CONVERSATION">Mode Conversation
<small>Après une réponse Piper, une courte fenêtre permet de poser une autre question.</small></label>
<input id="DUPLEX_CONVERSATION" type="checkbox" data-setting></div>
<div class="field"><label for="DUPLEX_CONVERSATION_SECONDS">Fenêtre de conversation (secondes)</label>
<input id="DUPLEX_CONVERSATION_SECONDS" type="number" min="2" max="15" step="1" data-setting>
<small>6 s recommandées. Pour fermer cette fenêtre, attends simplement quelques secondes.</small></div>
<p class="muted microtext">Anti-écho : confirmation acoustique, comparaison avec les paroles Piper et délai de garde. Ceci ne remplace pas un vrai AEC ; le mode naturel reste expérimental.</p>
<p class="muted" style="margin-top:12px">⚠ Le mode naturel exige idéalement un microphone avec annulation d'écho acoustique. Le simple filtrage logiciel ne remplace pas un AEC. Le retour à l'écoute prend effet après l'interruption ; attends que l'assistant soit prêt avant la nouvelle question. Les réglages nécessitent un redémarrage.</p>
</section>
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div>
<script src="/static/js/alfred-admin-glass.js?v=1"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
</body>
</html>
@@ -0,0 +1,206 @@
<!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">
</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>
</body>
</html>
@@ -0,0 +1,49 @@
<!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="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script><link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
</head>
<body data-page="surveillance">
<div class="admin-layout">
<header class="admin-header"><div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div><nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance" class="selected">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav></header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">DIAGNOSTIC</p><h1>Surveillance</h1><p class="muted">État des périphériques, de l’assistant et des services locaux.</p></div><div class="page-actions"><span class="tag"><span class="pulse-dot"></span><span id="updatedAt">Chargement…</span></span><button class="button ghost" type="button" id="refreshHealth">Actualiser</button></div></div>
<section class="monitor-grid">
<article class="panel metric-card" id="cameraCard"><div class="metric-icon">▣</div><div class="metric-info"><span class="microtext muted">CAMÉRA</span><strong>Webcam</strong><span class="status-pill" id="cameraStatus">—</span><p id="cameraDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card" id="microCard"><div class="metric-icon">◉</div><div class="metric-info"><span class="microtext muted">MICROPHONE</span><strong>Capture audio</strong><span class="status-pill" id="microStatus">—</span><p id="microDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card" id="speakerCard"><div class="metric-icon">♫</div><div class="metric-info"><span class="microtext muted">HAUT-PARLEUR</span><strong>Sortie audio</strong><span class="status-pill" id="speakerStatus">—</span><p id="speakerDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card"><div class="metric-icon">◌</div><div class="metric-info"><span class="microtext muted">MOT DE RÉVEIL</span><strong id="wakeLabel">Vosk</strong><span class="status-pill" id="wakeStatus">—</span><p id="wakeDetail" class="muted">Vérification…</p></div></article>
</section>
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : services vocaux et minuteurs -->
<div class="two-col admin-v2-monitor">
<section class="panel" aria-labelledby="v2-stt-title">
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2 id="v2-stt-title">Transcription vocale</h2><p>Mode réellement actif et disponibilité du serveur Wyoming</p></div></div>
<div class="admin-v2-summary"><span class="muted">Mode STT</span><strong id="v2SttActive">—</strong><span class="status-pill" id="v2SttState">—</span></div>
<p class="muted microtext" id="v2SttDetail">Vérification…</p>
<div class="admin-v2-summary"><span class="muted">Serveur Whisper</span><strong id="v2WhisperAddress">—</strong><span class="status-pill" id="v2WhisperState">—</span></div>
<p class="muted microtext" id="v2WhisperDetail">Vérification…</p>
<div class="info-note">Wyoming est interrogé avec <code>describe</code> sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.</div>
</section>
<section class="panel" aria-labelledby="v2-timers-title">
<div class="panel-title"><span class="panel-symbol">◷</span><div><h2 id="v2-timers-title">Minuteurs et chronomètres</h2><p>État enregistré localement, indépendant des appels Hermes</p></div></div>
<div class="admin-v2-summary"><span class="muted">Gestionnaire Alfred</span><strong id="v2TimerCount">—</strong><span class="status-pill" id="v2TimerState">—</span></div>
<div id="v2TimerList" class="admin-v2-timer-list" aria-live="polite"><p class="muted microtext">Chargement…</p></div>
<div class="info-note">La présence d’un MCP dans la liste ci-dessous ne prouve pas qu’Hermes l’a appelé : consulter ses logs pour vérifier les outils exécutés.</div>
</section>
</div>
<section class="panel admin-v2-maintenance"><div class="panel-title"><span class="panel-symbol">✧</span><div><h2>Maintenance et intégrations</h2><p>Vérifications complémentaires des nouveautés</p></div></div>
<div class="admin-v2-summary"><span class="muted">Redémarrage depuis /config</span><strong id="v2RestartLabel">—</strong><span class="status-pill" id="v2RestartState">—</span></div>
<p class="muted microtext">MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans <code>/opt/hermes</code>. La validation fonctionnelle se fait avec <code>hermes mcp test</code>.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour consulter l'état d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div><script src="/static/js/alfred-admin-glass.js?v=1"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
</body></html>
@@ -0,0 +1,544 @@
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
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
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="*")
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})
# 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):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
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
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# 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
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=done.set)
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:
_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=_lists_done.set)
_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
answer = ask_hermes(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}
# 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()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
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 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/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})
_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}
# 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=_done.set)
_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)
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,24 @@
{
"version": "ALFRED_DUPLEX_BARGE_IN_V1",
"files": {
"app.py": {
"before": "a96d4d7c5a78d5076934db6033f087cccc32c99f2493cadbffbff6c26cdf0602",
"after": "f40d87d07e4b85b35bd87a681c804389b14598991b434f367445e2e575f7799a"
},
"services/piper_tts.py": {
"before": "e087c9809730b619d05104fe0f6354affad1f4c8f22430ac34acab5b3ec0b43e",
"after": "2bc8fb780da8630c64e6281ff6bd21357f5d7c20ff46cd6bbd2c2b9887a0a8b1"
},
"services/admin_console.py": {
"before": "27eef32a894d9cecc9a77e70708ae58912355c2f7cc48bd7074068950dfc6c4e",
"after": "168409185f801a9a492f2eafd2ff13c628415a57b81395d8cbc2155a7bb2f264"
},
"templates/config.html": {
"before": "c98c0314923d054e8b56251657a33bb4c949684a94faed0a9a979dfd5ed6c8db",
"after": "e45f60b54b028422a78809ba8d0b6194beb8f64c74abb562a6cc9e03db029fff"
}
},
"added": {
"services/alfred_duplex.py": "5c477fe854a84790c8929b0b30b4fa0f8e0be7cf5a3cb7f097fa790105156728"
}
}
@@ -0,0 +1,492 @@
"""Authenticated local configuration and health UI for Alfred.
Registration is additive and doesn't replace the existing audio, camera, HA,
weather or Hermes endpoints. Passwords are provisioned locally via CLI.
"""
from __future__ import annotations
import functools
import hmac
import json
import os
import re
import secrets
import shutil
import socket
import subprocess
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit
import requests
from dotenv import dotenv_values
from flask import Blueprint, current_app, jsonify, render_template, request, session
from werkzeug.security import check_password_hash
ROOT = Path(__file__).resolve().parents[1]
AUTH_FILE = ROOT / 'config' / 'admin_auth.json'
GENERAL_ENV = ROOT / 'config' / 'control_center.env'
WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env'
CONFIG_LOCK = threading.RLock()
LOGIN_LOCK = threading.Lock()
LOGIN_ATTEMPTS = {}
# Only these keys are writable; no token, command, system file or shell input.
FIELDS = {
'ALFRED_NAME': ('general', 'text'),
'WAKE_WORD': ('general', 'wake'),
'PIPER_VOICE': ('general', 'voice'),
'ALFRED_OLLAMA_URL': ('general', 'url'),
'ALFRED_COMFYUI_URL': ('weather', 'url'),
'HA_WEATHER_ENTITY': ('general', 'entity'),
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'),
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'),
'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'),
'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'),
'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'),
'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'),
'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'),
'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'),
}
DEFAULTS = {
'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred',
'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '',
'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '',
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2',
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6',
'ALFRED_WEATHER_DISPLAY_SIZE': '350',
'ALFRED_WEATHER_REVIEW': 'true',
'ALFRED_WEATHER_REFERENCE_MODE': 'auto',
'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56',
'ALFRED_WEATHER_PRESERVE_FACE': 'true',
'ALFRED_WEATHER_ACCESSORY_LIMIT': '1',
}
# ALFRED_ADMIN_SURVEILLANCE_V2 : champs du moteur STT existant.
FIELDS.update({
'STT_ENGINE': ('general', 'stt_engine'),
'WYOMING_WHISPER_HOST': ('general', 'stt_host'),
'WYOMING_WHISPER_PORT': ('general', 'stt_port'),
'WYOMING_WHISPER_LANGUAGE': ('general', 'stt_language'),
'WYOMING_WHISPER_TIMEOUT': ('general', 'stt_timeout'),
'WYOMING_WHISPER_CONNECT_TIMEOUT': ('general', 'stt_connect_timeout'),
'STT_MAX_AUDIO_SECONDS': ('general', 'stt_max_audio'),
})
DEFAULTS.update({
'STT_ENGINE': 'vosk',
'WYOMING_WHISPER_HOST': '192.168.1.13',
'WYOMING_WHISPER_PORT': '10300',
'WYOMING_WHISPER_LANGUAGE': 'fr',
'WYOMING_WHISPER_TIMEOUT': '20',
'WYOMING_WHISPER_CONNECT_TIMEOUT': '3',
'STT_MAX_AUDIO_SECONDS': '30',
})
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
def _auth_config():
try:
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
except (OSError, ValueError):
return {}
def _authenticated():
config = _auth_config()
password_hash = config.get('password_hash', '')
if not password_hash:
return False
# Changing the admin password instantly invalidates old signed sessions.
expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
return bool(session.get('alfred_admin') and
hmac.compare_digest(str(session.get('auth_version', '')), expected_version))
def _valid_csrf():
token = request.headers.get('X-Alfred-CSRF', '')
expected = session.get('alfred_csrf', '')
return bool(token and expected and hmac.compare_digest(token, expected))
def _trusted_request():
origin = request.headers.get('Origin', '')
if origin:
parsed = urlsplit(origin)
if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'):
return False
if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site':
return False
return True
def _require_admin(handler):
@functools.wraps(handler)
def wrapped(*args, **kwargs):
if not _authenticated():
return jsonify(error='Authentification administrateur nécessaire.'), 401
if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()):
return jsonify(error='Protection anti-CSRF : requête refusée.'), 403
return handler(*args, **kwargs)
return wrapped
def _validate(key, value):
if key not in FIELDS:
raise ValueError(f'Paramètre non autorisé : {key}.')
kind = FIELDS[key][1]
if kind == 'boolean':
if value not in (True, False, 'true', 'false'):
raise ValueError(f'{key} doit être vrai ou faux.')
return 'true' if value in (True, 'true') else 'false'
if isinstance(value, bool):
raise ValueError(f'Valeur invalide pour {key}.')
value = str(value).strip()
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
raise ValueError(f'Caractères interdits pour {key}.')
# ALFRED_ADMIN_SURVEILLANCE_V2: stricte validation des paramètres Whisper.
if kind == 'stt_engine':
if value not in ('vosk', 'wyoming'):
raise ValueError('Moteur STT attendu : vosk ou wyoming.')
elif kind == 'stt_host':
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-]{0,252}', value):
raise ValueError('Hôte Whisper invalide : indiquer une IP ou un nom DNS, sans protocole.')
elif kind == 'stt_port':
if not value.isdecimal() or not 1 <= int(value) <= 65535:
raise ValueError('Port Whisper invalide (1 à 65535).')
value = str(int(value))
elif kind == 'stt_language':
if not re.fullmatch(r'[a-z]{2}', value.lower()):
raise ValueError('Langue Whisper invalide (exemple : fr).')
value = value.lower()
elif kind in ('stt_timeout', 'stt_connect_timeout'):
try:
number = float(value)
except ValueError:
raise ValueError('Délai Whisper non numérique.')
bounds = (5, 90) if kind == 'stt_timeout' else (1, 15)
if not bounds[0] <= number <= bounds[1]:
raise ValueError(f'Délai Whisper entre {bounds[0]} et {bounds[1]} secondes.')
value = str(number).rstrip('0').rstrip('.') if '.' in str(number) else str(number)
elif kind == 'stt_max_audio':
if not value.isdecimal() or not 5 <= int(value) <= 60:
raise ValueError('Enregistrement entre 5 et 60 secondes.')
value = str(int(value))
elif kind == 'text':
if not 2 <= len(value) <= 30:
raise ValueError('Le nom doit contenir entre 2 et 30 caractères.')
elif kind == 'wake':
if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value):
raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.')
value = value.lower()
elif kind == 'voice':
if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value):
raise ValueError('Identifiant de voix Piper invalide.')
elif kind == 'url':
if not value:
return ''
parsed = urlsplit(value)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname or
parsed.username or parsed.password or parsed.path not in ('', '/') or
parsed.query or parsed.fragment):
raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.')
if parsed.port and not 1 <= parsed.port <= 65535:
raise ValueError('Port réseau invalide.')
value = value.rstrip('/')
elif kind == 'entity':
if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value):
raise ValueError('Attendu : weather.identifiant ou une valeur vide.')
elif kind == 'attempts':
if value not in ('1', '2', '3'):
raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.')
elif kind == 'score':
value = str(float(value))
if not 1 <= float(value) <= 10:
raise ValueError('Score entre 1 et 10.')
elif kind == 'size':
if value not in ('250', '300', '350', '400', '450', '500'):
raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.')
elif kind == 'denoise':
value = str(float(value))
if not 0.25 <= float(value) <= 0.85:
raise ValueError('Denoise entre 0.25 et 0.85.')
elif kind == 'reference_mode':
if value not in ('auto', 'required', 'off'):
raise ValueError('Mode référence : auto, required ou off.')
elif kind == 'accessory':
if value not in ('0', '1', '2'):
raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.')
return value
def _read_config():
local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {}
weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
effective = {}
for key, (group, _) in FIELDS.items():
env_file = local if group == 'general' else weather
# File values take precedence on the next restart. Show saved values.
effective[key] = str(env_file.get(key) if env_file.get(key) is not None
else os.getenv(key, DEFAULTS[key]))
return effective
def _save_env(path: Path, changes: dict):
path.parent.mkdir(parents=True, exist_ok=True)
existing = path.read_text(encoding='utf-8') if path.exists() else ''
lines = existing.splitlines()
updated = set()
result = []
for line in lines:
match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line)
if match and match.group(1) in changes:
name = match.group(1)
if name not in updated:
result.append(f'{name}={changes[name]}')
updated.add(name)
else:
result.append(line)
for key, value in changes.items():
if key not in updated:
result.append(f'{key}={value}')
tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp')
try:
with tmp.open('w', encoding='utf-8') as fp:
fp.write('\n'.join(result).rstrip() + '\n')
fp.flush()
os.fsync(fp.fileno())
os.chmod(tmp, 0o600)
tmp.replace(path)
finally:
tmp.unlink(missing_ok=True)
def _probe_http(url, *, token=None, timeout=2):
start = time.monotonic()
try:
response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {},
timeout=timeout, allow_redirects=False)
if 200 <= response.status_code < 300:
state = 'ok'
detail = f'HTTP {response.status_code}'
elif response.status_code in (401, 403):
state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})'
else:
state, detail = 'warning', f'HTTP {response.status_code}'
except requests.RequestException:
state, detail = 'down', 'Aucune réponse HTTP'
return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)}
def _probe_tcp(host, port, timeout=1.5):
try:
with socket.create_connection((host, int(port)), timeout=timeout):
return {'state': 'ok', 'detail': f'Port {port} accessible'}
except (OSError, ValueError):
return {'state': 'down', 'detail': f'Port {port} inaccessible'}
def _weather_comfy_url():
# Respect the V5 precedence if possible, without reaching into credentials.
settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or
os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or
dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '')
def _health(wake, audio_settings):
now = datetime.now(timezone.utc).isoformat()
audio = audio_settings.get_settings()
try:
wake_state = wake.status()
except Exception:
wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'}
cam_index = int(os.getenv('CAMERA_INDEX', '0'))
camera_path = Path(f'/dev/video{cam_index}')
camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK)
# Checking /dev/videoN doesn't open the device: no conflict with facial recognition.
camera = {'state':'ok' if camera_ok else 'warning',
'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'}
mute_micro = audio.get('mic_muted', False)
microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'),
'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')}
loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok',
'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"}
config = _read_config()
comfy = _weather_comfy_url()
ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL')
ha_base = os.getenv('HA_URL', '').rstrip('/')
tts_host = os.getenv('PIPER_HOST', '').strip()
tts_port = os.getenv('PIPER_PORT', '10200')
services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'},
'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning',
'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}}
services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119)
services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)'
services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'}
services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'}
services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'}
services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'}
mcp_path = Path('/opt/hermes')
mcps = {}
for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]:
found = (mcp_path / slug).exists()
mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label}
disk = shutil.disk_usage(ROOT)
mem = {}
try:
info = {}
for line in Path('/proc/meminfo').read_text().splitlines():
k, _, v = line.partition(':')
if k in ('MemTotal','MemAvailable'):
info[k] = int(v.strip().split()[0]) * 1024
if info.get('MemTotal'):
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
except (OSError, ValueError):
pass
result = {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps,
'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem,
'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}}
return _admin_v2_extend_health(result, _read_config)
def register_admin_console(app, wake, audio_settings):
"""Register pages/API on the existing Flask app. Call exactly once."""
secret_file = ROOT / 'config' / 'admin_secret.key'
if not secret_file.exists():
raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.')
app.secret_key = secret_file.read_bytes()
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict',
SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true',
PERMANENT_SESSION_LIFETIME=timedelta(hours=8))
bp = Blueprint('alfred_admin', __name__)
@bp.get('/config')
@bp.get('/config.html')
def config_page():
return render_template('config.html')
@bp.get('/surveillance')
@bp.get('/surveillance.html')
def surveillance_page():
return render_template('surveillance.html')
@bp.get('/api/control/auth')
def auth_status():
authed = _authenticated()
return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')),
csrf=session.get('alfred_csrf') if authed else None)
@bp.post('/api/control/login')
def login():
if not _trusted_request() or not request.is_json:
return jsonify(error='Requête refusée.'), 403
data = request.get_json(silent=True) or {}
password = data.get('password', '')
if not isinstance(password, str) or len(password) > 500:
return jsonify(error='Mot de passe incorrect.'), 400
now = time.monotonic()
client = request.remote_addr or 'unknown'
with LOGIN_LOCK:
events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900]
LOGIN_ATTEMPTS[client] = events
if len(events) >= 5:
return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429
config = _auth_config()
password_hash = config.get('password_hash')
if not password_hash:
return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503
if not check_password_hash(password_hash, password):
with LOGIN_LOCK:
LOGIN_ATTEMPTS[client].append(now)
return jsonify(error='Mot de passe incorrect.'), 401
with LOGIN_LOCK:
LOGIN_ATTEMPTS.pop(client, None)
session.clear()
session.permanent = True
session['alfred_admin'] = True
session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
session['alfred_csrf'] = secrets.token_urlsafe(32)
return jsonify(authenticated=True, csrf=session['alfred_csrf'])
@bp.post('/api/control/logout')
@_require_admin
def logout():
session.clear()
return jsonify(ok=True)
@bp.get('/api/control/config')
@_require_admin
def config_get():
return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.')
@bp.post('/api/control/config')
@_require_admin
def config_update():
data = request.get_json(silent=True)
if not isinstance(data, dict) or len(data) > len(FIELDS):
return jsonify(error='JSON invalide.'), 400
try:
normalized = {key: _validate(key, value) for key, value in data.items()}
except (ValueError, TypeError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'}
weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'}
try:
with CONFIG_LOCK:
if general: _save_env(GENERAL_ENV, general)
if weather: _save_env(WEATHER_ENV, weather)
except OSError as exc:
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
# ALFRED_IDENTITY_GLASS_V1 : synchroniser le nom après sauvegarde.
_identity_sync_warning = None
if 'ALFRED_NAME' in normalized:
from services.alfred_identity_page import sync_identity_name as _sync_identity_name
os.environ['ALFRED_NAME'] = normalized['ALFRED_NAME']
try:
_sync_identity_name(normalized['ALFRED_NAME'])
except (OSError, ValueError) as _identity_error:
_identity_sync_warning = str(_identity_error)
current_app.logger.warning('Identité non synchronisée: %s', _identity_error)
return jsonify(ok=True, restart_required=True, identity_sync_warning=_identity_sync_warning, values=_read_config())
@bp.get('/api/control/health')
@_require_admin
def health():
return jsonify(_health(wake, audio_settings))
@bp.post('/api/control/restart')
@_require_admin
def restart():
if os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() not in ('true', '1', 'yes'):
return jsonify(error='Redémarrage Web désactivé. Activer ALFRED_ALLOW_WEB_RESTART=true (voir README).'), 403
systemctl = shutil.which('systemctl')
runner = shutil.which('systemd-run')
if not systemctl or not runner:
return jsonify(error='systemd-run ou systemctl absent. Redémarrer depuis le terminal.'), 503
# Schedule restart in a DIFFERENT systemd unit/cgroup so stopping Alfred
# cannot kill the restart command mid-flight.
command = [runner, '--unit=alfred-web-restart', '--on-active=2s', '--collect',
systemctl, 'restart', 'alfred.service']
if os.geteuid() != 0:
command = ['sudo', '-n', *command]
def do_restart():
time.sleep(0.5)
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False)
if result.returncode:
app.logger.error('Planification du redémarrage refusée (%s): %s',
result.returncode, result.stderr[:350])
except (OSError, subprocess.TimeoutExpired) as exc:
app.logger.error('Planification du redémarrage échouée: %s', exc)
threading.Thread(target=do_restart, daemon=True).start()
return jsonify(accepted=True, message='Demande de redémarrage transmise ; contrôler le retour du service.') , 202
app.register_blueprint(bp)
@@ -0,0 +1,109 @@
import asyncio
import os
import subprocess
import tempfile
import time
import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncTcpClient
from wyoming.tts import Synthesize, SynthesizeVoice
PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1")
PIPER_PORT = int(os.getenv("PIPER_PORT", "10200"))
AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0")
async def synthesize(text, wav_path):
client = AsyncTcpClient(PIPER_HOST, PIPER_PORT)
await client.connect()
try:
await client.write_event(
Synthesize(
text=text,
voice=SynthesizeVoice(
name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium")
)).event()
)
wav_file = None
try:
while True:
event = await asyncio.wait_for(
client.read_event(),
timeout=30
)
if event is None:
raise RuntimeError("Connexion Piper interrompue")
if AudioStart.is_type(event.type):
start = AudioStart.from_event(event)
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(start.channels)
wav_file.setsampwidth(start.width)
wav_file.setframerate(start.rate)
elif AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
if wav_file is None:
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(chunk.channels)
wav_file.setsampwidth(chunk.width)
wav_file.setframerate(chunk.rate)
wav_file.writeframes(chunk.audio)
elif AudioStop.is_type(event.type):
break
finally:
if wav_file is not None:
wav_file.close()
finally:
await client.disconnect()
def speak(text):
if not text or not text.strip():
return
fd, wav_path = tempfile.mkstemp(
prefix="alfred_piper_",
suffix=".wav"
)
os.close(fd)
start = time.monotonic()
try:
print("[ALFRED PIPER] Synthèse...", flush=True)
asyncio.run(synthesize(text, wav_path))
print("[ALFRED PIPER] Lecture...", flush=True)
subprocess.run(
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
check=True,
timeout=120
)
print(
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED PIPER] Erreur : {e}", flush=True)
raise
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
@@ -0,0 +1,80 @@
<!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="#121b25"><title>Alfred · Configuration</title>
<link rel="stylesheet" href="/static/css/alfred-admin.css">
<script defer src="/static/js/alfred-admin.js"></script>
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
</head>
<body data-page="config">
<div class="admin-layout">
<header class="admin-header">
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
</header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
<div class="two-col">
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.</small></div>
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
</section>
</div>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : reconnaissance vocale distante -->
<section class="panel admin-v2-stt-panel">
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2>Reconnaissance vocale (STT)</h2><p>Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage</p></div></div>
<div class="field"><label for="STT_ENGINE">Moteur de transcription final</label>
<select id="STT_ENGINE" data-setting><option value="vosk">Vosk local (mode historique)</option><option value="wyoming">Whisper Wyoming distant (conseillé)</option></select>
<small>Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.</small></div>
<div class="admin-v2-form-grid">
<div class="field"><label for="WYOMING_WHISPER_HOST">Serveur Wyoming Whisper</label><input id="WYOMING_WHISPER_HOST" data-setting placeholder="192.168.1.13" autocomplete="off"><small>Adresse IP ou nom DNS, sans http://</small></div>
<div class="field"><label for="WYOMING_WHISPER_PORT">Port TCP Wyoming</label><input type="number" min="1" max="65535" id="WYOMING_WHISPER_PORT" data-setting placeholder="10300"></div>
<div class="field"><label for="WYOMING_WHISPER_LANGUAGE">Langue</label><input id="WYOMING_WHISPER_LANGUAGE" data-setting maxlength="2" placeholder="fr"></div>
<div class="field"><label for="WYOMING_WHISPER_TIMEOUT">Délai maximum transcription (s)</label><input id="WYOMING_WHISPER_TIMEOUT" type="number" min="5" max="90" step="0.5" data-setting></div>
<div class="field"><label for="WYOMING_WHISPER_CONNECT_TIMEOUT">Délai de connexion (s)</label><input id="WYOMING_WHISPER_CONNECT_TIMEOUT" type="number" min="1" max="15" step="0.5" data-setting></div>
<div class="field"><label for="STT_MAX_AUDIO_SECONDS">Durée audio maximale (s)</label><input id="STT_MAX_AUDIO_SECONDS" type="number" min="5" max="60" step="1" data-setting></div>
</div>
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
</section>
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div>
<script src="/static/js/alfred-admin-glass.js?v=1"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
</body>
</html>
@@ -0,0 +1,560 @@
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
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 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="*")
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", 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):
if not conversation_lock.acquire(blocking=False):
return
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
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
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# 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
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=done.set)
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:
_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=_lists_done.set)
_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
answer = ask_hermes(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}
# 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=done.set)
if started:
done.wait(timeout=120)
finally:
duplex_monitor.stop()
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 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})
_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}
# 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=_done.set)
_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)
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,21 @@
{
"version": "ALFRED_DUPLEX_CONVERSATION_V2",
"files": {
"app.py": {
"before": "f40d87d07e4b85b35bd87a681c804389b14598991b434f367445e2e575f7799a",
"after": "b552034de304ef5203d26ed9ba5d52928974d7a001cd326f2dde3be057941631"
},
"services/alfred_duplex.py": {
"before": "5c477fe854a84790c8929b0b30b4fa0f8e0be7cf5a3cb7f097fa790105156728",
"after": "86b7a00ae5476c6b65e983d35f704e4bd6584dc88bb676ea876f4cc7c8c2736f"
},
"services/admin_console.py": {
"before": "168409185f801a9a492f2eafd2ff13c628415a57b81395d8cbc2155a7bb2f264",
"after": "f878ea04e564910c4d6f6b9d60d1048263b66d29c819bc442de19cb02eef08ee"
},
"templates/config.html": {
"before": "e45f60b54b028422a78809ba8d0b6194beb8f64c74abb562a6cc9e03db029fff",
"after": "e29cba3bee6e702856f602ca48ceeb554fd4347c96dab7311999fb20fcebb2d7"
}
}
}
@@ -0,0 +1,514 @@
"""Authenticated local configuration and health UI for Alfred.
Registration is additive and doesn't replace the existing audio, camera, HA,
weather or Hermes endpoints. Passwords are provisioned locally via CLI.
"""
from __future__ import annotations
import functools
import hmac
import json
import os
import re
import secrets
import shutil
import socket
import subprocess
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit
import requests
from dotenv import dotenv_values
from flask import Blueprint, current_app, jsonify, render_template, request, session
from werkzeug.security import check_password_hash
ROOT = Path(__file__).resolve().parents[1]
AUTH_FILE = ROOT / 'config' / 'admin_auth.json'
GENERAL_ENV = ROOT / 'config' / 'control_center.env'
WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env'
CONFIG_LOCK = threading.RLock()
LOGIN_LOCK = threading.Lock()
LOGIN_ATTEMPTS = {}
# Only these keys are writable; no token, command, system file or shell input.
FIELDS = {
'ALFRED_NAME': ('general', 'text'),
'WAKE_WORD': ('general', 'wake'),
'PIPER_VOICE': ('general', 'voice'),
'ALFRED_OLLAMA_URL': ('general', 'url'),
'ALFRED_COMFYUI_URL': ('weather', 'url'),
'HA_WEATHER_ENTITY': ('general', 'entity'),
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'),
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'),
'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'),
'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'),
'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'),
'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'),
'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'),
'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'),
}
DEFAULTS = {
'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred',
'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '',
'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '',
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2',
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6',
'ALFRED_WEATHER_DISPLAY_SIZE': '350',
'ALFRED_WEATHER_REVIEW': 'true',
'ALFRED_WEATHER_REFERENCE_MODE': 'auto',
'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56',
'ALFRED_WEATHER_PRESERVE_FACE': 'true',
'ALFRED_WEATHER_ACCESSORY_LIMIT': '1',
}
# ALFRED_ADMIN_SURVEILLANCE_V2 : champs du moteur STT existant.
FIELDS.update({
'STT_ENGINE': ('general', 'stt_engine'),
'WYOMING_WHISPER_HOST': ('general', 'stt_host'),
'WYOMING_WHISPER_PORT': ('general', 'stt_port'),
'WYOMING_WHISPER_LANGUAGE': ('general', 'stt_language'),
'WYOMING_WHISPER_TIMEOUT': ('general', 'stt_timeout'),
'WYOMING_WHISPER_CONNECT_TIMEOUT': ('general', 'stt_connect_timeout'),
'STT_MAX_AUDIO_SECONDS': ('general', 'stt_max_audio'),
})
DEFAULTS.update({
'STT_ENGINE': 'vosk',
'WYOMING_WHISPER_HOST': '192.168.1.13',
'WYOMING_WHISPER_PORT': '10300',
'WYOMING_WHISPER_LANGUAGE': 'fr',
'WYOMING_WHISPER_TIMEOUT': '20',
'WYOMING_WHISPER_CONNECT_TIMEOUT': '3',
'STT_MAX_AUDIO_SECONDS': '30',
})
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
# ALFRED_DUPLEX_BARGE_IN_V1: values saved with existing /config API.
FIELDS.update({
'DUPLEX_MODE': ('general', 'duplex_mode'),
'DUPLEX_MIC_THRESHOLD': ('general', 'duplex_threshold'),
})
DEFAULTS.update({'DUPLEX_MODE': 'wake', 'DUPLEX_MIC_THRESHOLD': '0.025'})
def _auth_config():
try:
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
except (OSError, ValueError):
return {}
def _authenticated():
config = _auth_config()
password_hash = config.get('password_hash', '')
if not password_hash:
return False
# Changing the admin password instantly invalidates old signed sessions.
expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
return bool(session.get('alfred_admin') and
hmac.compare_digest(str(session.get('auth_version', '')), expected_version))
def _valid_csrf():
token = request.headers.get('X-Alfred-CSRF', '')
expected = session.get('alfred_csrf', '')
return bool(token and expected and hmac.compare_digest(token, expected))
def _trusted_request():
origin = request.headers.get('Origin', '')
if origin:
parsed = urlsplit(origin)
if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'):
return False
if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site':
return False
return True
def _require_admin(handler):
@functools.wraps(handler)
def wrapped(*args, **kwargs):
if not _authenticated():
return jsonify(error='Authentification administrateur nécessaire.'), 401
if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()):
return jsonify(error='Protection anti-CSRF : requête refusée.'), 403
return handler(*args, **kwargs)
return wrapped
def _validate(key, value):
if key not in FIELDS:
raise ValueError(f'Paramètre non autorisé : {key}.')
kind = FIELDS[key][1]
# ALFRED_DUPLEX_BARGE_IN_V1
if kind == 'duplex_mode':
if value not in ('off', 'wake', 'natural'):
raise ValueError('Mode duplex invalide.')
return value
if kind == 'duplex_threshold':
if isinstance(value, bool):
raise ValueError('Seuil microphone invalide.')
try:
threshold = float(value)
except (ValueError, TypeError, OverflowError):
raise ValueError('Seuil microphone invalide.')
if not 0.008 <= threshold <= 0.30 or not __import__('math').isfinite(threshold):
raise ValueError('Seuil microphone compris entre 0.008 et 0.30.')
return f'{threshold:.3f}'
if kind == 'boolean':
if value not in (True, False, 'true', 'false'):
raise ValueError(f'{key} doit être vrai ou faux.')
return 'true' if value in (True, 'true') else 'false'
if isinstance(value, bool):
raise ValueError(f'Valeur invalide pour {key}.')
value = str(value).strip()
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
raise ValueError(f'Caractères interdits pour {key}.')
# ALFRED_ADMIN_SURVEILLANCE_V2: stricte validation des paramètres Whisper.
if kind == 'stt_engine':
if value not in ('vosk', 'wyoming'):
raise ValueError('Moteur STT attendu : vosk ou wyoming.')
elif kind == 'stt_host':
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-]{0,252}', value):
raise ValueError('Hôte Whisper invalide : indiquer une IP ou un nom DNS, sans protocole.')
elif kind == 'stt_port':
if not value.isdecimal() or not 1 <= int(value) <= 65535:
raise ValueError('Port Whisper invalide (1 à 65535).')
value = str(int(value))
elif kind == 'stt_language':
if not re.fullmatch(r'[a-z]{2}', value.lower()):
raise ValueError('Langue Whisper invalide (exemple : fr).')
value = value.lower()
elif kind in ('stt_timeout', 'stt_connect_timeout'):
try:
number = float(value)
except ValueError:
raise ValueError('Délai Whisper non numérique.')
bounds = (5, 90) if kind == 'stt_timeout' else (1, 15)
if not bounds[0] <= number <= bounds[1]:
raise ValueError(f'Délai Whisper entre {bounds[0]} et {bounds[1]} secondes.')
value = str(number).rstrip('0').rstrip('.') if '.' in str(number) else str(number)
elif kind == 'stt_max_audio':
if not value.isdecimal() or not 5 <= int(value) <= 60:
raise ValueError('Enregistrement entre 5 et 60 secondes.')
value = str(int(value))
elif kind == 'text':
if not 2 <= len(value) <= 30:
raise ValueError('Le nom doit contenir entre 2 et 30 caractères.')
elif kind == 'wake':
if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value):
raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.')
value = value.lower()
elif kind == 'voice':
if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value):
raise ValueError('Identifiant de voix Piper invalide.')
elif kind == 'url':
if not value:
return ''
parsed = urlsplit(value)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname or
parsed.username or parsed.password or parsed.path not in ('', '/') or
parsed.query or parsed.fragment):
raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.')
if parsed.port and not 1 <= parsed.port <= 65535:
raise ValueError('Port réseau invalide.')
value = value.rstrip('/')
elif kind == 'entity':
if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value):
raise ValueError('Attendu : weather.identifiant ou une valeur vide.')
elif kind == 'attempts':
if value not in ('1', '2', '3'):
raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.')
elif kind == 'score':
value = str(float(value))
if not 1 <= float(value) <= 10:
raise ValueError('Score entre 1 et 10.')
elif kind == 'size':
if value not in ('250', '300', '350', '400', '450', '500'):
raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.')
elif kind == 'denoise':
value = str(float(value))
if not 0.25 <= float(value) <= 0.85:
raise ValueError('Denoise entre 0.25 et 0.85.')
elif kind == 'reference_mode':
if value not in ('auto', 'required', 'off'):
raise ValueError('Mode référence : auto, required ou off.')
elif kind == 'accessory':
if value not in ('0', '1', '2'):
raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.')
return value
def _read_config():
local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {}
weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
effective = {}
for key, (group, _) in FIELDS.items():
env_file = local if group == 'general' else weather
# File values take precedence on the next restart. Show saved values.
effective[key] = str(env_file.get(key) if env_file.get(key) is not None
else os.getenv(key, DEFAULTS[key]))
return effective
def _save_env(path: Path, changes: dict):
path.parent.mkdir(parents=True, exist_ok=True)
existing = path.read_text(encoding='utf-8') if path.exists() else ''
lines = existing.splitlines()
updated = set()
result = []
for line in lines:
match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line)
if match and match.group(1) in changes:
name = match.group(1)
if name not in updated:
result.append(f'{name}={changes[name]}')
updated.add(name)
else:
result.append(line)
for key, value in changes.items():
if key not in updated:
result.append(f'{key}={value}')
tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp')
try:
with tmp.open('w', encoding='utf-8') as fp:
fp.write('\n'.join(result).rstrip() + '\n')
fp.flush()
os.fsync(fp.fileno())
os.chmod(tmp, 0o600)
tmp.replace(path)
finally:
tmp.unlink(missing_ok=True)
def _probe_http(url, *, token=None, timeout=2):
start = time.monotonic()
try:
response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {},
timeout=timeout, allow_redirects=False)
if 200 <= response.status_code < 300:
state = 'ok'
detail = f'HTTP {response.status_code}'
elif response.status_code in (401, 403):
state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})'
else:
state, detail = 'warning', f'HTTP {response.status_code}'
except requests.RequestException:
state, detail = 'down', 'Aucune réponse HTTP'
return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)}
def _probe_tcp(host, port, timeout=1.5):
try:
with socket.create_connection((host, int(port)), timeout=timeout):
return {'state': 'ok', 'detail': f'Port {port} accessible'}
except (OSError, ValueError):
return {'state': 'down', 'detail': f'Port {port} inaccessible'}
def _weather_comfy_url():
# Respect the V5 precedence if possible, without reaching into credentials.
settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or
os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or
dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '')
def _health(wake, audio_settings):
now = datetime.now(timezone.utc).isoformat()
audio = audio_settings.get_settings()
try:
wake_state = wake.status()
except Exception:
wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'}
cam_index = int(os.getenv('CAMERA_INDEX', '0'))
camera_path = Path(f'/dev/video{cam_index}')
camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK)
# Checking /dev/videoN doesn't open the device: no conflict with facial recognition.
camera = {'state':'ok' if camera_ok else 'warning',
'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'}
mute_micro = audio.get('mic_muted', False)
microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'),
'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')}
loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok',
'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"}
config = _read_config()
comfy = _weather_comfy_url()
ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL')
ha_base = os.getenv('HA_URL', '').rstrip('/')
tts_host = os.getenv('PIPER_HOST', '').strip()
tts_port = os.getenv('PIPER_PORT', '10200')
services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'},
'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning',
'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}}
services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119)
services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)'
services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'}
services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'}
services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'}
services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'}
mcp_path = Path('/opt/hermes')
mcps = {}
for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]:
found = (mcp_path / slug).exists()
mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label}
disk = shutil.disk_usage(ROOT)
mem = {}
try:
info = {}
for line in Path('/proc/meminfo').read_text().splitlines():
k, _, v = line.partition(':')
if k in ('MemTotal','MemAvailable'):
info[k] = int(v.strip().split()[0]) * 1024
if info.get('MemTotal'):
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
except (OSError, ValueError):
pass
result = {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps,
'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem,
'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}}
return _admin_v2_extend_health(result, _read_config)
def register_admin_console(app, wake, audio_settings):
"""Register pages/API on the existing Flask app. Call exactly once."""
secret_file = ROOT / 'config' / 'admin_secret.key'
if not secret_file.exists():
raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.')
app.secret_key = secret_file.read_bytes()
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict',
SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true',
PERMANENT_SESSION_LIFETIME=timedelta(hours=8))
bp = Blueprint('alfred_admin', __name__)
@bp.get('/config')
@bp.get('/config.html')
def config_page():
return render_template('config.html')
@bp.get('/surveillance')
@bp.get('/surveillance.html')
def surveillance_page():
return render_template('surveillance.html')
@bp.get('/api/control/auth')
def auth_status():
authed = _authenticated()
return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')),
csrf=session.get('alfred_csrf') if authed else None)
@bp.post('/api/control/login')
def login():
if not _trusted_request() or not request.is_json:
return jsonify(error='Requête refusée.'), 403
data = request.get_json(silent=True) or {}
password = data.get('password', '')
if not isinstance(password, str) or len(password) > 500:
return jsonify(error='Mot de passe incorrect.'), 400
now = time.monotonic()
client = request.remote_addr or 'unknown'
with LOGIN_LOCK:
events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900]
LOGIN_ATTEMPTS[client] = events
if len(events) >= 5:
return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429
config = _auth_config()
password_hash = config.get('password_hash')
if not password_hash:
return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503
if not check_password_hash(password_hash, password):
with LOGIN_LOCK:
LOGIN_ATTEMPTS[client].append(now)
return jsonify(error='Mot de passe incorrect.'), 401
with LOGIN_LOCK:
LOGIN_ATTEMPTS.pop(client, None)
session.clear()
session.permanent = True
session['alfred_admin'] = True
session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
session['alfred_csrf'] = secrets.token_urlsafe(32)
return jsonify(authenticated=True, csrf=session['alfred_csrf'])
@bp.post('/api/control/logout')
@_require_admin
def logout():
session.clear()
return jsonify(ok=True)
@bp.get('/api/control/config')
@_require_admin
def config_get():
return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.')
@bp.post('/api/control/config')
@_require_admin
def config_update():
data = request.get_json(silent=True)
if not isinstance(data, dict) or len(data) > len(FIELDS):
return jsonify(error='JSON invalide.'), 400
try:
normalized = {key: _validate(key, value) for key, value in data.items()}
except (ValueError, TypeError, OverflowError) as exc:
return jsonify(error=str(exc)), 400
general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'}
weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'}
try:
with CONFIG_LOCK:
if general: _save_env(GENERAL_ENV, general)
if weather: _save_env(WEATHER_ENV, weather)
except OSError as exc:
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
# ALFRED_IDENTITY_GLASS_V1 : synchroniser le nom après sauvegarde.
_identity_sync_warning = None
if 'ALFRED_NAME' in normalized:
from services.alfred_identity_page import sync_identity_name as _sync_identity_name
os.environ['ALFRED_NAME'] = normalized['ALFRED_NAME']
try:
_sync_identity_name(normalized['ALFRED_NAME'])
except (OSError, ValueError) as _identity_error:
_identity_sync_warning = str(_identity_error)
current_app.logger.warning('Identité non synchronisée: %s', _identity_error)
return jsonify(ok=True, restart_required=True, identity_sync_warning=_identity_sync_warning, values=_read_config())
@bp.get('/api/control/health')
@_require_admin
def health():
return jsonify(_health(wake, audio_settings))
@bp.post('/api/control/restart')
@_require_admin
def restart():
if os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() not in ('true', '1', 'yes'):
return jsonify(error='Redémarrage Web désactivé. Activer ALFRED_ALLOW_WEB_RESTART=true (voir README).'), 403
systemctl = shutil.which('systemctl')
runner = shutil.which('systemd-run')
if not systemctl or not runner:
return jsonify(error='systemd-run ou systemctl absent. Redémarrer depuis le terminal.'), 503
# Schedule restart in a DIFFERENT systemd unit/cgroup so stopping Alfred
# cannot kill the restart command mid-flight.
command = [runner, '--unit=alfred-web-restart', '--on-active=2s', '--collect',
systemctl, 'restart', 'alfred.service']
if os.geteuid() != 0:
command = ['sudo', '-n', *command]
def do_restart():
time.sleep(0.5)
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False)
if result.returncode:
app.logger.error('Planification du redémarrage refusée (%s): %s',
result.returncode, result.stderr[:350])
except (OSError, subprocess.TimeoutExpired) as exc:
app.logger.error('Planification du redémarrage échouée: %s', exc)
threading.Thread(target=do_restart, daemon=True).start()
return jsonify(accepted=True, message='Demande de redémarrage transmise ; contrôler le retour du service.') , 202
app.register_blueprint(bp)
@@ -0,0 +1,181 @@
"""Barge-in local during a Wyoming Piper response.
NOT an acoustic echo canceller or full-duplex dialogue engine. The monitor
listens only while Alfred is speaking and interrupts *playback*; the user can
then utter the next command after the wake listener resumes.
"""
from __future__ import annotations
import json
import math
import os
import queue
import re
import threading
import time
import unicodedata
from collections import deque
def normalize(text: str) -> str:
text = unicodedata.normalize('NFKD', (text or '').lower())
cleaned = re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c)))
return ' '.join(cleaned.split())
def looks_like_echo(candidate: str, spoken: str) -> bool:
"""Conservative rejection of text already spoken by the assistant.
This is NOT AEC; overlapping real speech can still be missed or misheard.
"""
candidate = normalize(candidate)
spoken = normalize(spoken)
if not candidate or not spoken:
return False
if candidate in spoken:
return True
words = candidate.split()
if len(words) >= 3:
matching = sum(w in set(spoken.split()) for w in words)
return matching / len(words) >= 0.85
return False
def config() -> dict:
mode = os.getenv('DUPLEX_MODE', 'wake').strip().lower()
if mode not in {'off', 'wake', 'natural'}:
mode = 'off'
try:
threshold = float(os.getenv('DUPLEX_MIC_THRESHOLD', '0.025'))
except ValueError:
threshold = 0.025
return {'mode': mode, 'threshold': max(0.008, min(threshold, 0.3)),
'wake_word': os.getenv('WAKE_WORD', 'alfred').strip().lower(),
'aec': 'external_required', 'limitations':
'Interruption de la lecture Piper uniquement ; sans AEC externe le mode naturel peut se declencher sur le haut-parleur.'}
class DuplexMonitor:
def __init__(self, *, muted, on_interrupt, on_status=None):
self.muted = muted
self.on_interrupt = on_interrupt
self.on_status = on_status or (lambda status: None)
self._stop = threading.Event()
self._thread = None
self._lock = threading.RLock()
self._interrupted = False
self._last_error = None
def start(self, spoken_text: str) -> bool:
settings = config()
if settings['mode'] == 'off' or self.muted():
return False
with self._lock:
if self._thread and self._thread.is_alive():
return False
self._stop.clear()
self._interrupted = False
self._last_error = None
self._thread = threading.Thread(target=self._listen,
args=(spoken_text, settings), daemon=True,
name='alfred-duplex-monitor')
self._thread.start()
return True
def stop(self) -> None:
self._stop.set()
# Don't hold the lock when joining: callback also acquires it.
t = self._thread
if t and t is not threading.current_thread():
t.join(timeout=0.8)
def status(self):
return {'active': bool(self._thread and self._thread.is_alive() and not self._stop.is_set()),
'interrupted': self._interrupted, 'error': self._last_error, **config()}
def _fire(self, mode):
with self._lock:
if self._interrupted or self._stop.is_set() or self.muted():
return
self._interrupted = True
self._stop.set()
print(f'[ALFRED DUPLEX] Interruption vocale : mode={mode}', flush=True)
try:
self.on_interrupt()
except Exception as exc:
self._last_error = str(exc)
print(f'[ALFRED DUPLEX] Arrêt lecture impossible : {exc}', flush=True)
try:
self.on_status({'mode': mode, 'interrupted': True})
except Exception:
pass
def _listen(self, spoken_text, settings):
try:
import sounddevice as sd
import numpy as np
from vosk import KaldiRecognizer
from services.audio import get_vosk_model
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
model_path = os.getenv('WAKE_VOSK_MODEL_PATH') if settings['mode'] == 'wake' else os.getenv('VOSK_MODEL_PATH')
if not model_path or not os.path.exists(model_path):
raise RuntimeError('Modèle Vosk du mode duplex introuvable')
if settings['mode'] == 'wake':
grammar = json.dumps([settings['wake_word'], '[unk]'], ensure_ascii=False)
recognizer = KaldiRecognizer(get_vosk_model(model_path), rate, grammar)
else:
recognizer = KaldiRecognizer(get_vosk_model(model_path), rate)
frames = max(320, rate // 10)
chunks = queue.Queue(maxsize=24)
def callback(indata, _frames, _time_info, _status):
try:
chunks.put_nowait(bytes(indata))
except queue.Full:
pass
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
started = time.monotonic()
stable_text = None
stable_since = 0.0
recent_levels = deque(maxlen=10)
# The initial audio output may contain a transient click.
with sd.RawInputStream(samplerate=rate, blocksize=frames,
dtype='int16', channels=1, callback=callback, device=device):
while not self._stop.is_set() and not self.muted():
try:
block = chunks.get(timeout=0.12)
except queue.Empty:
continue
if time.monotonic() - started < 0.55:
continue
pcm = np.frombuffer(block, dtype=np.int16).astype(np.float32)
rms = float(np.sqrt(np.mean(pcm * pcm))) / 32768.0 if pcm.size else 0.0
recent_levels.append(rms)
speech_level = max(recent_levels, default=0.0)
final = recognizer.AcceptWaveform(block)
payload = json.loads(recognizer.Result() if final else recognizer.PartialResult())
heard = normalize((payload.get('text') if final else payload.get('partial')) or '')
if not heard or looks_like_echo(heard, spoken_text):
stable_text = None
continue
if settings['mode'] == 'wake':
# Vosk keyword grammar already constrains the vocabulary.
if settings['wake_word'] in heard.split() and speech_level >= settings['threshold']:
self._fire('wake')
break
else:
if speech_level < settings['threshold']:
continue
words = heard.split()
if len(words) < 2 and heard not in ('stop', 'arrete', 'attends', 'stoppe'):
continue
if final:
self._fire('natural')
break
if heard != stable_text:
stable_text = heard
stable_since = time.monotonic()
elif time.monotonic() - stable_since >= 0.3:
self._fire('natural')
break
except Exception as exc:
self._last_error = str(exc)
print(f'[ALFRED DUPLEX] Capture indisponible : {exc}', flush=True)
@@ -0,0 +1,101 @@
<!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="#121b25"><title>Alfred · Configuration</title>
<link rel="stylesheet" href="/static/css/alfred-admin.css">
<script defer src="/static/js/alfred-admin.js"></script>
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
</head>
<body data-page="config">
<div class="admin-layout">
<header class="admin-header">
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
</header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
<div class="two-col">
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.</small></div>
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
</section>
</div>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : reconnaissance vocale distante -->
<section class="panel admin-v2-stt-panel">
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2>Reconnaissance vocale (STT)</h2><p>Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage</p></div></div>
<div class="field"><label for="STT_ENGINE">Moteur de transcription final</label>
<select id="STT_ENGINE" data-setting><option value="vosk">Vosk local (mode historique)</option><option value="wyoming">Whisper Wyoming distant (conseillé)</option></select>
<small>Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.</small></div>
<div class="admin-v2-form-grid">
<div class="field"><label for="WYOMING_WHISPER_HOST">Serveur Wyoming Whisper</label><input id="WYOMING_WHISPER_HOST" data-setting placeholder="192.168.1.13" autocomplete="off"><small>Adresse IP ou nom DNS, sans http://</small></div>
<div class="field"><label for="WYOMING_WHISPER_PORT">Port TCP Wyoming</label><input type="number" min="1" max="65535" id="WYOMING_WHISPER_PORT" data-setting placeholder="10300"></div>
<div class="field"><label for="WYOMING_WHISPER_LANGUAGE">Langue</label><input id="WYOMING_WHISPER_LANGUAGE" data-setting maxlength="2" placeholder="fr"></div>
<div class="field"><label for="WYOMING_WHISPER_TIMEOUT">Délai maximum transcription (s)</label><input id="WYOMING_WHISPER_TIMEOUT" type="number" min="5" max="90" step="0.5" data-setting></div>
<div class="field"><label for="WYOMING_WHISPER_CONNECT_TIMEOUT">Délai de connexion (s)</label><input id="WYOMING_WHISPER_CONNECT_TIMEOUT" type="number" min="1" max="15" step="0.5" data-setting></div>
<div class="field"><label for="STT_MAX_AUDIO_SECONDS">Durée audio maximale (s)</label><input id="STT_MAX_AUDIO_SECONDS" type="number" min="5" max="60" step="1" data-setting></div>
</div>
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
</section>
<!-- ALFRED_DUPLEX_BARGE_IN_V1 -->
<section class="panel" id="alfredDuplexPanel">
<div class="panel-title"><span class="panel-symbol">≋</span><div>
<h2>Interruption vocale (duplex)</h2>
<p>Couper la parole à l'assistant pendant la lecture Piper</p>
</div></div>
<div class="field"><label for="DUPLEX_MODE">Mode d'interruption</label>
<select id="DUPLEX_MODE" data-setting>
<option value="off">Désactivé (comportement actuel)</option>
<option value="wake">Avec le nom d'activation — recommandé</option>
<option value="natural">Naturelle sans le nom — expérimental</option>
</select>
<small>En mode « avec le nom », prononce le mot de réveil configuré (Dona, si c'est ton WAKE_WORD). En mode naturel, un début de parole distinct du TTS peut interrompre la réponse.</small>
</div>
<div class="field"><label for="DUPLEX_MIC_THRESHOLD">Seuil micro (sensibilité)</label>
<input type="number" id="DUPLEX_MIC_THRESHOLD" data-setting min="0.008" max="0.30" step="0.005" placeholder="0.025">
<small>Augmenter pour limiter les déclenchements intempestifs ; valeur initiale 0.025.</small>
</div>
<p class="muted" style="margin-top:12px">⚠ Le mode naturel exige idéalement un microphone avec annulation d'écho acoustique. Le simple filtrage logiciel ne remplace pas un AEC. Le retour à l'écoute prend effet après l'interruption ; attends que l'assistant soit prêt avant la nouvelle question. Les réglages nécessitent un redémarrage.</p>
</section>
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div>
<script src="/static/js/alfred-admin-glass.js?v=1"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
</body>
</html>
@@ -0,0 +1,37 @@
{
"patch": "ALFRED_HERMES_NEW_SESSION_V1",
"installed": true,
"created_at": "2026-10-29T03:39:58.458706",
"files": {
"app.py": {
"existed": true,
"before_sha256": "8652f012f05fd6437f5b53fc9d3b76492b1e1b29669750fda200b00d3f078735",
"after_sha256": "324ef5ed2d629740f1d1ee51653977492154e71f4143c3b419fbb6e81205f52d",
"mode": 420
},
"services/alfred_hermes_session_page.py": {
"existed": false,
"before_sha256": null,
"after_sha256": "2d009a1a6d629e46e072ac1918849e7cba5279f466a64e5a0fedbaf20628ef2c",
"mode": 420
},
"static/css/alfred-hermes-new-session.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "fa4592869a2660bd5741ef0414ad28503a75fa2853079d6975271f3919865408",
"mode": 420
},
"static/js/alfred-hermes-new-session.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "205c12deeff4823aa9c54be2a0fb071733a9c109e7da5d0f29cc15316b41a9d7",
"mode": 420
},
"templates/index.html": {
"existed": true,
"before_sha256": "afab6eea4bb25ba1066d5b9f0939d8e76b3daa014fd0ff029e511bebed39ce17",
"after_sha256": "0dc9c9119c28636390f662ec6f2058bbd30ef0f780992c3c9d8661dc84204554",
"mode": 420
}
}
}
@@ -0,0 +1,585 @@
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
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 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="*")
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
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# 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
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=done.set)
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:
_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=_lists_done.set)
_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
answer = ask_hermes(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}
# 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=done.set)
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})
_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}
# 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=_done.set)
_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)
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,208 @@
<!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">
</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>
</body>
</html>
@@ -0,0 +1,25 @@
{
"version": 1,
"installed": true,
"created_at": "2026-10-28T22:52:17.749257",
"files": {
"static/css/alfred-reponse-close-v1.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "b6e878dda903e27d8355c8271a6cde230aaeb55d471d5d67bb74390117d91de4",
"mode": 420
},
"static/js/alfred-reponse-close-v1.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "2a60ddcebb36a16bd417b6e3c5750f96ff5f79648a154422511620de9bd2fb6b",
"mode": 420
},
"templates/index.html": {
"existed": true,
"before_sha256": "5a8d12151da30fb4ba0b025872d973b2578a0bec79ed166cffc3e2ef9d9d314f",
"after_sha256": "60636d8b9cbf0f08f25eb0d5e4c1f5729f8bd009c1a4dc1742939b58a0e27c75",
"mode": 420
}
}
}
@@ -0,0 +1,203 @@
<!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">
</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>
</body>
</html>
@@ -0,0 +1,25 @@
{
"version": 1,
"installed": true,
"created_at": "2026-10-28T22:32:30.218418",
"files": {
"static/css/alfred-stt-inline-v1.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "14d6a2a0fd9699d8b9087dd4a43368d765d1b7199a02c645630ec4978f6ef33f",
"mode": 420
},
"static/js/alfred-stt-inline-v1.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "6a8924ac196703cc88fca68d02a52761a9d9df8e44c2f0ca801491a090f9c9d6",
"mode": 420
},
"templates/index.html": {
"existed": true,
"before_sha256": "6b0a7b0d075bb79e3dac909450e35a08cc7b3928a01bf7633cb5811723cd9787",
"after_sha256": "5a8d12151da30fb4ba0b025872d973b2578a0bec79ed166cffc3e2ef9d9d314f",
"mode": 420
}
}
}
@@ -0,0 +1,200 @@
<!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">
</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>
</body>
</html>
@@ -0,0 +1,61 @@
{
"id": "DONA_HOLO_V1",
"created_at": "2026-10-29T09:26:17.902596",
"installed": true,
"files": {
"templates/alfred_identity_glass.html": {
"existed": true,
"before": "bd43ead3b3be510f16084f44121bf2d23b192539eab832bbc0ca60d9f040759c",
"after": "57c4da6a4ac148203519022a381e60f01789958a95dc861edbb82f0450722f3c",
"mode": 420
},
"templates/alfred_logs_v2.html": {
"existed": true,
"before": "c5b41ff1258fe9f3c2169678071673daca4b24223b541b532cb9d348529e1f6c",
"after": "c997391f123df6c661f6f9004eaffc2d5edd2a8326cd95634c2a149baa1618a2",
"mode": 420
},
"templates/alfred_maintenance.html": {
"existed": true,
"before": "509f82fb16040eb65f174be45a5f0dc8048856980f7604e8d9ca6aedd00d4670",
"after": "c7d896c43cd6d0b631391dde6aff76850da647fa68173694986e8ffcd5c13306",
"mode": 420
},
"templates/config.html": {
"existed": true,
"before": "22823b16a4c93b9a3fb9b69538db755509fceb8e2048097caa7965bbcb2ff44b",
"after": "aea6ae5c433b593b299c674d55962d1264000cacc0edf54e406f852d62b36f4e",
"mode": 420
},
"templates/configuration.html": {
"existed": true,
"before": "14fe568c1cefe99184deda0165de8bf54a7d1e5ff45d1a2caa17ebfb835cd861",
"after": "83de36ae2581557cb1b4174e3a57e5cb6d7172a82b58ff696c0d755e883dd97a",
"mode": 420
},
"templates/index.html": {
"existed": true,
"before": "0dc9c9119c28636390f662ec6f2058bbd30ef0f780992c3c9d8661dc84204554",
"after": "681b3ae5a5fa33e24195a2ff68ae0ea7f8ed27a3510d5b229159b74daa6db791",
"mode": 420
},
"templates/surveillance.html": {
"existed": true,
"before": "8f5184d6b888f6a533e3a059ea59bf5e5bfdca06fea5e947faab2d5bcea0750a",
"after": "292afc3cbd3e975470c20ed0eec0afb0dc5ecab36d01dc4d4d03279ce423f79d",
"mode": 420
},
"static/css/dona-holographic-v1.css": {
"existed": false,
"before": null,
"after": "f4415028da23e2906042785d7b58331f0b92ce88c1b46b932749751a0173becd",
"mode": 420
},
"static/js/dona-holographic-v1.js": {
"existed": false,
"before": null,
"after": "31ea202d50a1065e978f3c22ff9eeca5a223890c7d8d21d1e9f12dff2ff6c6d2",
"mode": 420
}
}
}
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Identité · Assistant</title>
<link rel="stylesheet" href="/static/css/alfred-identity-glass.css?v=1">
<script src="/static/js/alfred-identity-glass.js?v=1" defer></script>
</head>
<body>
<header class="id-head">
<a class="id-brand" href="/"><span class="id-letter" id="idInitial">A</span><span><strong id="idName">Assistant</strong><small>Identité et personnalité</small></span></a>
<nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity" class="active" aria-current="page">Identité</a></nav>
</header>
<main class="id-main">
<div class="id-title"><div><p class="id-eyebrow">PERSONNALISATION · HERMES</p><h1>Identité de l'assistant</h1><p>Personnalise la personnalité, le ton et les instructions permanentes transmises à Hermes.</p></div><span class="id-pill" id="idBadge">Vérification…</span></div>
<section class="id-panel" id="idLoginPanel" hidden>
<h2>Accès administrateur</h2><p>Utilise le même mot de passe que la page Configuration.</p>
<form id="idLoginForm"><label for="idPassword">Mot de passe</label><input type="password" id="idPassword" autocomplete="current-password" required><button type="submit" class="id-button primary">Se connecter</button></form>
<p id="idLoginMessage" role="alert"></p>
</section>
<section id="idEditor" hidden>
<div class="id-layout">
<div class="id-panel id-editor-card">
<div class="id-editor-title"><div><h2>Document d'identité</h2><p>Édition directe de <code>config/identity.md</code> · format Markdown</p></div><span id="idCount">0 caractère</span></div>
<label class="id-sr-only" for="idText">Instructions Hermes</label>
<textarea id="idText" spellcheck="false" autocomplete="off" aria-describedby="idEditorHelp"></textarea>
<p class="id-tip" id="idEditorHelp">Les changements du texte s'appliquent aux prochaines demandes vocales. Les sessions Hermes anciennes peuvent conserver leur propre historique.</p>
<div class="id-buttons"><button type="button" class="id-button" id="idReload">Recharger</button><button type="button" class="id-button primary" id="idSave">Enregistrer l'identité</button></div>
<p id="idMessage" role="status" aria-live="polite"></p>
</div>
<aside class="id-side">
<section class="id-panel"><p class="id-kicker">IDENTITÉ ACTIVE</p><div class="id-current"><span id="idAvatar">A</span><div><strong id="idActiveName">—</strong><small>Nom configuré dans /config</small></div></div><p>Modifier le nom dans <a href="/config">Configuration</a> synchronise automatiquement les mentions de l'assistant dans ce document.</p></section>
<section class="id-panel"><h2>Conseils de rédaction</h2><p>Décris les règles de conversation et les préférences durables. Pour les outils spécialisés, utilise les Skills Hermes.</p><div class="id-example">## Style<br>Tu réponds en français, avec des phrases naturelles.<br><br>## Outils<br>Tu confirmes une action seulement après le succès de son outil.</div></section>
<section class="id-panel"><h2>Sauvegardes</h2><p>Une copie est créée avant chaque modification dans <code>config/identity_history/</code>. Les fichiers existants et les noms techniques ne sont pas renommés.</p></section>
</aside>
</div>
</section>
</main>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
</body>
</html>
@@ -0,0 +1,35 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Journaux · Alfred</title>
<link rel="stylesheet" href="/static/css/alfred-logs-v2.css?v=2">
<script src="/static/js/alfred-logs-v2.js?v=2" defer></script>
</head>
<body>
<header class="lg-header">
<a href="/" class="lg-brand"><span class="lg-letter" id="lgLetter">A</span><span><strong id="lgName">Alfred</strong><small>Journaux de l'assistant</small></span></a>
<nav class="lg-nav" aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs" class="active" aria-current="page">Journaux</a></nav>
</header>
<main class="lg-shell">
<div class="lg-title"><div><p class="lg-eyebrow">OBSERVABILITÉ · ALFRED</p><h1>Journaux</h1><p>Activité du service Alfred, reconnaissance vocale, Hermes et appels d'outils lorsqu'ils sont journalisés.</p></div><div class="lg-actions"><button id="lgRefresh" type="button">Actualiser</button><button id="lgExport" type="button">Exporter en JSON</button></div></div>
<section class="lg-filters" aria-label="Filtres des événements">
<label>Recherche<input id="lgSearch" type="search" placeholder="Proxmox, Whisper, erreur…" autocomplete="off"></label>
<label>Composant<select id="lgComponent"><option value="">Tous</option><option>Vocal</option><option>Hermes</option><option>MCP</option><option>Piper</option><option>Système</option></select></label>
<label>Niveau<select id="lgLevel"><option value="">Tous</option><option>Information</option><option>Avertissement</option><option>Erreur</option></select></label>
<label>Affichage<select id="lgLimit"><option value="100">100 lignes</option><option value="220" selected>220 lignes</option><option value="400">400 lignes</option></select></label>
<label class="lg-check"><input id="lgAuto" type="checkbox" checked> Actualisation automatique (10 s)</label>
</section>
<section class="lg-table-wrap" aria-label="Événements">
<div class="lg-table-top"><span id="lgStatus" role="status" aria-live="polite">Connexion…</span><span id="lgCount"></span></div>
<div id="lgEvents" class="lg-events" aria-live="off"></div>
<p class="lg-note">Source : journal systemd <code>alfred.service</code>. Les commandes Hermes exécutées dans un autre processus ne sont visibles ici que si Alfred les journalise. Les secrets courants sont masqués, mais vérifie les données avant tout partage.</p>
</section>
</main>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
</body>
</html>
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>Maintenance · Dona</title>
<link rel="stylesheet" href="/static/css/alfred-developer-glass.css?v=1">
<script defer src="/static/js/alfred-developer-glass.js?v=1"></script>
</head><body>
<header class="dv-head"><a class="dv-brand" href="/"><span class="dv-initial" id="dvInitial">A</span><span><strong id="dvName">Assistant</strong><small>Maintenance & développement</small></span></a>
<nav><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity">Identité</a><a href="/maintenance" class="active" aria-current="page">Maintenance</a></nav></header>
<main class="dv-main"><div class="dv-intro"><div><p class="dv-eyebrow">HERMES · DÉVELOPPEMENT CONTRÔLÉ</p><h1>Maintenance & modifications</h1><p>Hermes prépare le code dans une copie Git. Tu vérifies et approuves ici avant toute modification en production.</p></div><button id="dvRefresh" type="button">Actualiser</button></div>
<section class="dv-panel" id="dvLoginBox" hidden><h2>Accès administrateur</h2><p>Utilise le même mot de passe que Configuration.</p><form id="dvLogin"><input type="password" id="dvPassword" autocomplete="current-password" placeholder="Mot de passe administrateur" required><button type="submit">Connexion</button></form><p id="dvLoginInfo" role="alert"></p></section>
<div id="dvApp" class="dv-layout" hidden>
<section class="dv-panel dv-list"><div class="dv-section-title"><h2>Propositions</h2><span id="dvCount">—</span></div><p class="dv-muted">Demande à Hermes : « Prépare une modification de ton interface ».</p><div id="dvTasks" role="list" class="dv-tasks"></div></section>
<section class="dv-panel dv-detail"><div class="dv-section-title"><h2>Détail & validation</h2><span id="dvState">Sélectionne une proposition</span></div>
<div id="dvEmpty" class="dv-empty">Les modifications apparaîtront ici lorsqu'Hermes aura créé une proposition.</div>
<div id="dvSelected" hidden><h3 id="dvTitle"></h3><p class="dv-muted" id="dvMeta"></p><h4>Contrôles</h4><div id="dvChecks" class="dv-checks"></div>
<h4>Diff des fichiers</h4><pre id="dvDiff" class="dv-diff" tabindex="0"></pre><p class="dv-muted">Le diff peut contenir du code sensible. Ne l'exporte pas publiquement.</p>
<div class="dv-actions" id="dvActions"><button class="dv-primary" type="button" id="dvApprove">Approuver la proposition</button><button type="button" id="dvReject">Refuser</button></div>
<div class="dv-next" id="dvNext" hidden><strong>Après approbation</strong><p>Le déploiement reste manuel : depuis SSH, lance la commande de déploiement avec l'identifiant affiché. Aucun bouton Web ne modifie le code de production.</p><code id="dvCommand"></code></div>
<p id="dvMessage" role="status" aria-live="polite"></p></div>
</section>
</div><p class="dv-footnote">Sécurité : les contrôles V1 sont statiques (syntaxe Python et JavaScript, HTML, CSS). Ils ne garantissent pas qu'une modification sera fonctionnelle. Déployer uniquement après revue.</p></main>
</body></html>
@@ -0,0 +1,117 @@
<!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="#121b25"><title>Alfred · Configuration</title>
<link rel="stylesheet" href="/static/css/alfred-admin.css">
<script defer src="/static/js/alfred-admin.js"></script>
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
</head>
<body data-page="config">
<div class="admin-layout">
<header class="admin-header">
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
</header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
<div class="two-col">
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.</small></div>
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
</section>
</div>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : reconnaissance vocale distante -->
<section class="panel admin-v2-stt-panel">
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2>Reconnaissance vocale (STT)</h2><p>Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage</p></div></div>
<div class="field"><label for="STT_ENGINE">Moteur de transcription final</label>
<select id="STT_ENGINE" data-setting><option value="vosk">Vosk local (mode historique)</option><option value="wyoming">Whisper Wyoming distant (conseillé)</option></select>
<small>Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.</small></div>
<div class="admin-v2-form-grid">
<div class="field"><label for="WYOMING_WHISPER_HOST">Serveur Wyoming Whisper</label><input id="WYOMING_WHISPER_HOST" data-setting placeholder="192.168.1.13" autocomplete="off"><small>Adresse IP ou nom DNS, sans http://</small></div>
<div class="field"><label for="WYOMING_WHISPER_PORT">Port TCP Wyoming</label><input type="number" min="1" max="65535" id="WYOMING_WHISPER_PORT" data-setting placeholder="10300"></div>
<div class="field"><label for="WYOMING_WHISPER_LANGUAGE">Langue</label><input id="WYOMING_WHISPER_LANGUAGE" data-setting maxlength="2" placeholder="fr"></div>
<div class="field"><label for="WYOMING_WHISPER_TIMEOUT">Délai maximum transcription (s)</label><input id="WYOMING_WHISPER_TIMEOUT" type="number" min="5" max="90" step="0.5" data-setting></div>
<div class="field"><label for="WYOMING_WHISPER_CONNECT_TIMEOUT">Délai de connexion (s)</label><input id="WYOMING_WHISPER_CONNECT_TIMEOUT" type="number" min="1" max="15" step="0.5" data-setting></div>
<div class="field"><label for="STT_MAX_AUDIO_SECONDS">Durée audio maximale (s)</label><input id="STT_MAX_AUDIO_SECONDS" type="number" min="5" max="60" step="1" data-setting></div>
</div>
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
</section>
<!-- ALFRED_DUPLEX_BARGE_IN_V1 -->
<section class="panel" id="alfredDuplexPanel">
<div class="panel-title"><span class="panel-symbol">≋</span><div>
<h2>Interruption vocale (duplex)</h2>
<p>Couper la parole à l'assistant pendant la lecture Piper</p>
</div></div>
<div class="field"><label for="DUPLEX_MODE">Mode d'interruption</label>
<select id="DUPLEX_MODE" data-setting>
<option value="off">Désactivé (comportement actuel)</option>
<option value="wake">Avec le nom d'activation — recommandé</option>
<option value="natural">Naturelle sans le nom — expérimental</option>
</select>
<small>En mode « avec le nom », prononce le mot de réveil configuré (Dona, si c'est ton WAKE_WORD). En mode naturel, un début de parole distinct du TTS peut interrompre la réponse.</small>
</div>
<div class="field"><label for="DUPLEX_MIC_THRESHOLD">Seuil micro (sensibilité)</label>
<input type="number" id="DUPLEX_MIC_THRESHOLD" data-setting min="0.008" max="0.30" step="0.005" placeholder="0.025">
<small>Augmenter pour limiter les déclenchements intempestifs ; valeur initiale 0.025.</small>
</div>
<!-- ALFRED_DUPLEX_CONVERSATION_V2 -->
<div class="field switch-field"><label for="DUPLEX_STOP_COMMANDS">Commandes « Stop », « Silence », « Arrête »
<small>Coupe uniquement Piper, sans nom d’activation ni annulation des tâches Hermes.</small></label>
<input id="DUPLEX_STOP_COMMANDS" type="checkbox" data-setting></div>
<div class="field switch-field"><label for="DUPLEX_HANDOFF">Nouvelle question pendant la réponse
<small>Interromps Dona et continue avec une question sans devoir répéter le nom.</small></label>
<input id="DUPLEX_HANDOFF" type="checkbox" data-setting></div>
<div class="field switch-field"><label for="DUPLEX_CONVERSATION">Mode Conversation
<small>Après une réponse Piper, une courte fenêtre permet de poser une autre question.</small></label>
<input id="DUPLEX_CONVERSATION" type="checkbox" data-setting></div>
<div class="field"><label for="DUPLEX_CONVERSATION_SECONDS">Fenêtre de conversation (secondes)</label>
<input id="DUPLEX_CONVERSATION_SECONDS" type="number" min="2" max="15" step="1" data-setting>
<small>6 s recommandées. Pour fermer cette fenêtre, attends simplement quelques secondes.</small></div>
<p class="muted microtext">Anti-écho : confirmation acoustique, comparaison avec les paroles Piper et délai de garde. Ceci ne remplace pas un vrai AEC ; le mode naturel reste expérimental.</p>
<p class="muted" style="margin-top:12px">⚠ Le mode naturel exige idéalement un microphone avec annulation d'écho acoustique. Le simple filtrage logiciel ne remplace pas un AEC. Le retour à l'écoute prend effet après l'interruption ; attends que l'assistant soit prêt avant la nouvelle question. Les réglages nécessitent un redémarrage.</p>
</section>
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div>
<script src="/static/js/alfred-admin-glass.js?v=1"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
</body>
</html>
@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Alfred — Configuration audio</title>
<style>
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, sans-serif; background:#15171a; color:#f3f4f6; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:28px; }
main { width:min(100%,640px); }
header { margin-bottom:30px; }
h1 { font-size:1.8rem; font-weight:550; margin:0 0 8px; }
.subtitle { color:#a5a9b0; margin:0; }
.panel { background:#202328; border:1px solid #34383f; border-radius:18px; padding:26px; }
.section { padding:20px 0; }
.section:first-child { padding-top:0; }
.section:last-child { padding-bottom:0; }
.section + .section { border-top:1px solid #353940; }
.line { display:flex; align-items:center; justify-content:space-between; gap:18px; }
.label { display:flex; align-items:center; gap:12px; font-size:1.05rem; }
.icon { width:24px; height:24px; color:#c9cdd3; flex-shrink:0; }
.value { font-variant-numeric:tabular-nums; color:#c8cbd1; }
input[type=range] { width:100%; margin:23px 0 2px; accent-color:#e5e7eb; cursor:pointer; }
input[type=range]:disabled { opacity:.35; cursor:not-allowed; }
.switch { position:relative; width:54px; height:30px; display:inline-block; flex-shrink:0; }
.switch input { opacity:0; width:0; height:0; position:absolute; }
.track { position:absolute; inset:0; background:#525760; border-radius:99px; transition:background .2s; cursor:pointer; }
.track:before { content:""; position:absolute; width:22px; height:22px; top:4px; left:4px; background:white; border-radius:50%; transition:transform .2s; }
.switch input:checked + .track { background:#ad5555; }
.switch input:checked + .track:before { transform:translateX(24px); }
.switch input:focus-visible + .track { outline:2px solid white; outline-offset:3px; }
.hint { margin:9px 0 0 36px; color:#9298a2; font-size:.88rem; }
#status { min-height:1.3em; margin:18px 0 0; color:#a5a9b0; font-size:.86rem; }
a { color:#cdd2da; text-decoration:none; }
</style>
</head>
<body>
<main>
<header>
<h1>Configuration</h1>
<p class="subtitle">Alfred · Paramètres audio</p>
</header>
<div class="panel">
<section class="section">
<div class="line">
<label class="label" for="volume">
<svg class="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 6 9H3v6h3l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></svg>
Volume de sortie
</label>
<output class="value" id="volumeValue" for="volume">50 %</output>
</div>
<input id="volume" type="range" min="0" max="100" value="50" step="1" aria-label="Volume de sortie">
</section>
<section class="section">
<div class="line">
<span class="label" id="speakerLabel">
<svg class="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 6 9H3v6h3l5 4V5Z"/><path d="m17 9 5 6m0-6-5 6"/></svg>
Couper le haut-parleur
</span>
<label class="switch"><input type="checkbox" id="speakerMute" aria-labelledby="speakerLabel"><span class="track"></span></label>
</div>
<p class="hint">Désactive les réponses vocales d’Alfred.</p>
</section>
<section class="section">
<div class="line">
<span class="label" id="micLabel">
<svg class="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" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5m-4 0h8"/></svg>
Couper le microphone
</span>
<label class="switch"><input type="checkbox" id="micMute" aria-labelledby="micLabel"><span class="track"></span></label>
</div>
<p class="hint">Suspend l’écoute et la détection du mot « Alfred ».</p>
</section>
</div>
<p id="status" role="status" aria-live="polite"></p>
</main>
<script>
'use strict';
// API attendue côté Flask :
// GET /api/audio/settings -> {"volume": 50, "speaker_muted": false, "microphone_muted": false}
// PATCH /api/audio/settings <- mêmes clés, partielles acceptées.
// Le navigateur n'agit PAS directement sur ALSA ou Vosk.
const volume = document.getElementById('volume');
const volumeValue = document.getElementById('volumeValue');
const speakerMute = document.getElementById('speakerMute');
const micMute = document.getElementById('micMute');
const status = document.getElementById('status');
let volumeTimer;
function setStatus(message) { status.textContent = message; }
function render(data) {
if (typeof data.volume === 'number') volume.value = data.volume;
if (typeof data.speaker_muted === 'boolean') speakerMute.checked = data.speaker_muted;
if (typeof data.microphone_muted === 'boolean') micMute.checked = data.microphone_muted;
volumeValue.textContent = `${volume.value} %`;
}
async function save(change) {
try {
const response = await fetch('/api/audio/settings', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(change)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
setStatus('Paramètres appliqués.');
} catch (error) {
setStatus('Impossible d’appliquer le réglage : API Flask non disponible ou erreur serveur.');
console.error(error);
}
}
volume.addEventListener('input', () => {
volumeValue.textContent = `${volume.value} %`;
clearTimeout(volumeTimer);
volumeTimer = setTimeout(() => save({ volume: Number(volume.value) }), 180);
});
speakerMute.addEventListener('change', () => save({ speaker_muted: speakerMute.checked }));
micMute.addEventListener('change', () => save({ microphone_muted: micMute.checked }));
(async () => {
try {
const response = await fetch('/api/audio/settings');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
render(await response.json());
setStatus('');
} catch (_) {
setStatus('Interface prête. Connexion à l’API audio Flask nécessaire pour contrôler le matériel.');
}
})();
</script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
</body>
</html>
@@ -0,0 +1,211 @@
<!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">
</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>
</body>
</html>
@@ -0,0 +1,51 @@
<!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="#121b25"><title>Alfred · Surveillance</title><link rel="stylesheet" href="/static/css/alfred-admin.css"><script defer src="/static/js/alfred-admin.js"></script><link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
</head>
<body data-page="surveillance">
<div class="admin-layout">
<header class="admin-header"><div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div><nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance" class="selected">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav></header>
<main class="admin-main" id="mainArea" hidden>
<div class="page-top"><div><p class="eyebrow">DIAGNOSTIC</p><h1>Surveillance</h1><p class="muted">État des périphériques, de l’assistant et des services locaux.</p></div><div class="page-actions"><span class="tag"><span class="pulse-dot"></span><span id="updatedAt">Chargement…</span></span><button class="button ghost" type="button" id="refreshHealth">Actualiser</button></div></div>
<section class="monitor-grid">
<article class="panel metric-card" id="cameraCard"><div class="metric-icon">▣</div><div class="metric-info"><span class="microtext muted">CAMÉRA</span><strong>Webcam</strong><span class="status-pill" id="cameraStatus">—</span><p id="cameraDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card" id="microCard"><div class="metric-icon">◉</div><div class="metric-info"><span class="microtext muted">MICROPHONE</span><strong>Capture audio</strong><span class="status-pill" id="microStatus">—</span><p id="microDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card" id="speakerCard"><div class="metric-icon">♫</div><div class="metric-info"><span class="microtext muted">HAUT-PARLEUR</span><strong>Sortie audio</strong><span class="status-pill" id="speakerStatus">—</span><p id="speakerDetail" class="muted">Vérification…</p></div></article>
<article class="panel metric-card"><div class="metric-icon">◌</div><div class="metric-info"><span class="microtext muted">MOT DE RÉVEIL</span><strong id="wakeLabel">Vosk</strong><span class="status-pill" id="wakeStatus">—</span><p id="wakeDetail" class="muted">Vérification…</p></div></article>
</section>
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : services vocaux et minuteurs -->
<div class="two-col admin-v2-monitor">
<section class="panel" aria-labelledby="v2-stt-title">
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2 id="v2-stt-title">Transcription vocale</h2><p>Mode réellement actif et disponibilité du serveur Wyoming</p></div></div>
<div class="admin-v2-summary"><span class="muted">Mode STT</span><strong id="v2SttActive">—</strong><span class="status-pill" id="v2SttState">—</span></div>
<p class="muted microtext" id="v2SttDetail">Vérification…</p>
<div class="admin-v2-summary"><span class="muted">Serveur Whisper</span><strong id="v2WhisperAddress">—</strong><span class="status-pill" id="v2WhisperState">—</span></div>
<p class="muted microtext" id="v2WhisperDetail">Vérification…</p>
<div class="info-note">Wyoming est interrogé avec <code>describe</code> sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.</div>
</section>
<section class="panel" aria-labelledby="v2-timers-title">
<div class="panel-title"><span class="panel-symbol">◷</span><div><h2 id="v2-timers-title">Minuteurs et chronomètres</h2><p>État enregistré localement, indépendant des appels Hermes</p></div></div>
<div class="admin-v2-summary"><span class="muted">Gestionnaire Alfred</span><strong id="v2TimerCount">—</strong><span class="status-pill" id="v2TimerState">—</span></div>
<div id="v2TimerList" class="admin-v2-timer-list" aria-live="polite"><p class="muted microtext">Chargement…</p></div>
<div class="info-note">La présence d’un MCP dans la liste ci-dessous ne prouve pas qu’Hermes l’a appelé : consulter ses logs pour vérifier les outils exécutés.</div>
</section>
</div>
<section class="panel admin-v2-maintenance"><div class="panel-title"><span class="panel-symbol">✧</span><div><h2>Maintenance et intégrations</h2><p>Vérifications complémentaires des nouveautés</p></div></div>
<div class="admin-v2-summary"><span class="muted">Redémarrage depuis /config</span><strong id="v2RestartLabel">—</strong><span class="status-pill" id="v2RestartState">—</span></div>
<p class="muted microtext">MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans <code>/opt/hermes</code>. La validation fonctionnelle se fait avec <code>hermes mcp test</code>.</p>
</section>
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour consulter l'état d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
</div><script src="/static/js/alfred-admin-glass.js?v=1"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
</body></html>
Binary file not shown.
+55 -9
View File
@@ -6,6 +6,8 @@ from flask_socketio import SocketIO
from dotenv import load_dotenv
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 alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
@@ -35,6 +37,11 @@ mic_stop_event=threading.Event()
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()
@@ -46,20 +53,25 @@ def emit_state(mode=None, **extra):
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0):
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)
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
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)
@@ -176,8 +188,23 @@ def conversation(score=1.0):
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
try:
started = speak_async(speech, on_end=done.set)
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="")
@@ -190,7 +217,12 @@ def conversation(score=1.0):
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
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)
@@ -205,6 +237,10 @@ def on_wake(score):
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())
@@ -538,6 +574,16 @@ _alfred_register_logs_v2(app)
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)
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)
+58
View File
@@ -655,5 +655,63 @@
},
"cached_at": 1793217002.2921214,
"focus_label": "aujourd’hui"
},
"8a11c9e955ab8e454af78f4dda695d060a3fc2923c68a5fcfd0d26a253b1ed02": {
"file": "8a11c9e955ab8e454af78f4dda695d060a3fc2923c68a5fcfd0d26a253b1ed02.jpg",
"prompt_ids": [
"4e358205-1cf0-429c-b924-6013953dd1b2",
"0385002e-6707-4507-a19a-05bd775f21b3"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793230507.5417078,
"focus_label": "aujourd’hui"
},
"984c13f455b0a52288299eb0803a9774b076a268557b36d933f1351aac77ebf0": {
"file": "984c13f455b0a52288299eb0803a9774b076a268557b36d933f1351aac77ebf0.jpg",
"prompt_ids": [
"c4a256f0-9e5f-4297-a852-ba9d7152d2a5",
"d5e93fc5-7328-4ce1-b700-3f2e65d9508b"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": true
}
},
"cached_at": 1793266575.8636355,
"focus_label": "aujourd’hui"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+8 -2
View File
@@ -1,7 +1,7 @@
# Configuration générale Alfred (page /config)
# ALFRED_ALLOW_WEB_RESTART=true
ALFRED_NAME=Magalie
WAKE_WORD=magalie
ALFRED_NAME=Dona
WAKE_WORD=dona
PIPER_VOICE=fr_FR-upmc-medium
ALFRED_OLLAMA_URL=
HA_WEATHER_ENTITY=
@@ -12,3 +12,9 @@ WYOMING_WHISPER_LANGUAGE=fr
WYOMING_WHISPER_TIMEOUT=20
WYOMING_WHISPER_CONNECT_TIMEOUT=3
STT_MAX_AUDIO_SECONDS=30
DUPLEX_MODE=wake
DUPLEX_MIC_THRESHOLD=0.025
DUPLEX_STOP_COMMANDS=true
DUPLEX_HANDOFF=true
DUPLEX_CONVERSATION=true
DUPLEX_CONVERSATION_SECONDS=6
+1 -1
View File
@@ -1,3 +1,3 @@
{
"session_id": "local-f6c54762e29b37dc1b98da779a3690e4ddf8cb585d11705c6ee88e828ac59737"
"session_id": "local-81ecdeab0eb437a53f1bc22637c11140819774d33839f82203e41a5e31ec69de"
}
@@ -0,0 +1,3 @@
{
"session_id": "local-f6c54762e29b37dc1b98da779a3690e4ddf8cb585d11705c6ee88e828ac59737"
}
+6 -5
View File
@@ -1,7 +1,7 @@
# Identité et comportement de Magalie
# Identité et comportement de Dona
Tu es Magalie, le majordome vocal personnel de la maison.
Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
Tu es Dona, le majordome vocal personnel de la maison.
Tu t'appelles Dona, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
## Langue et style
@@ -10,6 +10,7 @@ Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner es
- Adopte un ton naturel, calme, cordial et professionnel.
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
- Tu peux si necessaire, illustrer avec tes tools de generation d'images
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
## Domotique
@@ -18,10 +19,10 @@ Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner es
- Si tu ne peux pas effectuer une action, explique-le simplement.
## Identité
- Si l'on te demande ton nom, réponds : « Je m'appelle Magalie. »
- Si l'on te demande ton nom, réponds : « Je m'appelle Dona. »
# Magalie
# Dona
Tu disposes des outils et compétences configurés
dans Hermes Agent.
@@ -0,0 +1,31 @@
# Identité et comportement de Magalie
Tu es Magalie, le majordome vocal personnel de la maison.
Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
## Langue et style
- Réponds toujours en français, sauf demande explicite de parler une autre langue.
- Tutoie ton interlocuteur.
- Adopte un ton naturel, calme, cordial et professionnel.
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
## Domotique
- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles.
- Ne confirme une action que si son exécution a été effectivement vérifiée.
- Si tu ne peux pas effectuer une action, explique-le simplement.
## Identité
- Si l'on te demande ton nom, réponds : « Je m'appelle Magalie. »
# Magalie
Tu disposes des outils et compétences configurés
dans Hermes Agent.
Utilise librement les outils disponibles lorsque
la demande le nécessite.
Ne limite pas tes capacités à la domotique.
@@ -0,0 +1,31 @@
# Identité et comportement de Dona
Tu es Dona, le majordome vocal personnel de la maison.
Tu t'appelles Dona, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
## Langue et style
- Réponds toujours en français, sauf demande explicite de parler une autre langue.
- Tutoie ton interlocuteur.
- Adopte un ton naturel, calme, cordial et professionnel.
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
## Domotique
- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles.
- Ne confirme une action que si son exécution a été effectivement vérifiée.
- Si tu ne peux pas effectuer une action, explique-le simplement.
## Identité
- Si l'on te demande ton nom, réponds : « Je m'appelle Dona. »
# Dona
Tu disposes des outils et compétences configurés
dans Hermes Agent.
Utilise librement les outils disponibles lorsque
la demande le nécessite.
Ne limite pas tes capacités à la domotique.
@@ -0,0 +1,32 @@
# Identité et comportement de Dona
Tu es Dona, le majordome vocal personnel de la maison.
Tu t'appelles Dona, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
## Langue et style
- Réponds toujours en français, sauf demande explicite de parler une autre langue.
- Tutoie ton interlocuteur.
- Adopte un ton naturel, calme, cordial et professionnel.
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
- Tu peux si necessaire, illustrer avec tes tools de generation d'image
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
## Domotique
- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles.
- Ne confirme une action que si son exécution a été effectivement vérifiée.
- Si tu ne peux pas effectuer une action, explique-le simplement.
## Identité
- Si l'on te demande ton nom, réponds : « Je m'appelle Dona. »
# Dona
Tu disposes des outils et compétences configurés
dans Hermes Agent.
Utilise librement les outils disponibles lorsque
la demande le nécessite.
Ne limite pas tes capacités à la domotique.
+1 -1
View File
@@ -1 +1 @@
{"name": "Magalie"}
{"name": "Dona"}
+2 -2
View File
@@ -4,7 +4,7 @@
# Maximum 2 essais par personnage (1 seul si une image est deja suffisante)
ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS=2
# Score de qualite attendu, lorsque le workflow vision peut analyser l'image
ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE=8.6
ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE=5.7
# Dimensions de la vignette COMPOSEE, sur l'ecran d'Alfred
ALFRED_WEATHER_DISPLAY_SIZE=350
# Evaluation via le workflow ComfyUI vision_analyze.json (si compatible)
@@ -15,7 +15,7 @@ ALFRED_WEATHER_REVIEW=true
# 'off' : desactive les references
ALFRED_WEATHER_REFERENCE_MODE=auto
# Intensite de transformation de la reference (0.25 stable, 0.85 plus libre)
ALFRED_WEATHER_REFERENCE_DENOISE=0.7
ALFRED_WEATHER_REFERENCE_DENOISE=0.78
# Maintient visuellement les visages de reference dans le rendu img2img
ALFRED_WEATHER_PRESERVE_FACE=true
# Nombre maximum d'accessoires visibles par personnage
Binary file not shown.
+10
View File
@@ -84,5 +84,15 @@
"created_at": 1793205436.8959317,
"state": "finished",
"finished_at": 1793206457.1510372
},
{
"id": "597165f549",
"label": "Chronomètre général",
"kind": "stopwatch",
"duration": null,
"elapsed": 30.873294591903687,
"started_at": null,
"created_at": 1793228714.9392383,
"state": "cancelled"
}
]
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Restricted Alfred Developer MCP (stdio, pure stdlib JSON-RPC).
This MCP intentionally exposes NO deploy, rollback, shell, filesystem deletion,
network fetch or arbitrary test execution operations.
"""
import json
import os
import sys
PRODUCTION=os.environ.get('ALFRED_DEV_PRODUCTION','/opt/alfred/app')
sys.path.insert(0,PRODUCTION)
from services import alfred_developer_core as dev
def tool(name, description, props=None, required=None):
return {'name':name,'description':description,'inputSchema':{'type':'object','properties':props or {},'required':required or [],'additionalProperties':False}}
PID={'type':'string','pattern':'^[a-f0-9]{12}$','description':'ID renvoyé par create_task'}
REL={'type':'string','description':'Chemin relatif (templates/, static/js/, static/css/, services/ ou app.py), jamais .env/config/secrets.'}
TOOLS=[
tool('create_task','Créer une copie Git des sources autorisées dans /opt/alfred-dev. Aucun fichier de production modifié.',{'request_text':{'type':'string','minLength':5,'maxLength':2000}},['request_text']),
tool('list_source','Lister les fichiers de la copie de développement.',{'task_id':PID,'prefix':{'type':'string'}},['task_id']),
tool('read_source','Lire un fichier source de la copie.',{'task_id':PID,'path':REL},['task_id','path']),
tool('modify_source','Modifier ou créer UN fichier dans la copie Git ; aucun accès à la production.',{'task_id':PID,'path':REL,'content':{'type':'string','maxLength':180000}},['task_id','path','content']),
tool('run_checks','Effectuer seulement des contrôles statiques (AST Python, node --check, HTML, CSS). Ne lance jamais le code modifié.',{'task_id':PID},['task_id']),
tool('preview_changes','Afficher le diff et les contrôles préparés pour /maintenance.',{'task_id':PID},['task_id']),
tool('submit_proposal','Geler une proposition testée et l’envoyer dans /maintenance pour approbation humaine.',{'task_id':PID},['task_id']),
tool('list_tasks','Lister les propositions de développement. Pas de déploiement depuis le MCP.'),
]
def dispatch(name,args):
assert isinstance(args,dict)
if name=='create_task':return dev.create_task(args['request_text'])
if name=='list_source':return {'files':dev.list_source(args['task_id'],args.get('prefix',''))}
if name=='read_source':return dev.read_source(args['task_id'],args['path'])
if name=='modify_source':return dev.change_source(args['task_id'],args['path'],args['content'])
if name=='run_checks':return dev.run_checks(args['task_id'])
if name=='preview_changes':return dev.task_detail(args['task_id'])
if name=='submit_proposal':return dev.submit(args['task_id'])
if name=='list_tasks':return {'tasks':dev.list_tasks()}
raise ValueError('Outil inexistant.')
def send(obj):
sys.stdout.write(json.dumps(obj,ensure_ascii=False,separators=(',',':'))+'\n');sys.stdout.flush()
def handle(message):
if not isinstance(message,dict) or 'id' not in message:return
mid=message['id'];method=message.get('method')
if method=='initialize':
params=message.get('params') or {}
version=params.get('protocolVersion')
if version not in ('2025-06-18','2025-03-26','2024-11-05'):version='2025-06-18'
result={'protocolVersion':version,'capabilities':{'tools':{'listChanged':False}},
'serverInfo':{'name':'alfred-developer','version':'1.0.0'},
'instructions':'Modification UNIQUEMENT d’une copie Git. Après contrôles, soumettre à /maintenance. Jamais utiliser Terminal pour appliquer une modification en production.'}
elif method=='tools/list':result={'tools':TOOLS}
elif method=='ping':result={}
elif method=='tools/call':
try:
params=message.get('params') or {}
name=params.get('name');args=params.get('arguments') or {}
schema=next((t['inputSchema'] for t in TOOLS if t['name']==name),None)
if schema is None:raise ValueError('Outil inconnu.')
if not isinstance(args,dict) or set(args)-set(schema['properties']):raise ValueError('Arguments non autorisés.')
for key in schema['required']:
if key not in args:raise ValueError('Argument requis : '+key)
data=dispatch(name,args)
result={'content':[{'type':'text','text':json.dumps(data,ensure_ascii=False)}],'isError':False}
except Exception as exc:
result={'content':[{'type':'text','text':str(exc)[:900]}],'isError':True}
else:
send({'jsonrpc':'2.0','id':mid,'error':{'code':-32601,'message':'Méthode inconnue'}});return
send({'jsonrpc':'2.0','id':mid,'result':result})
if __name__=='__main__':
for line in sys.stdin:
if line.strip():
try:handle(json.loads(line))
except Exception as exc:print('MCP parsing :',str(exc)[:150],file=sys.stderr)
+45
View File
@@ -87,6 +87,27 @@ DEFAULTS.update({
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
# ALFRED_DUPLEX_BARGE_IN_V1: values saved with existing /config API.
FIELDS.update({
'DUPLEX_MODE': ('general', 'duplex_mode'),
'DUPLEX_MIC_THRESHOLD': ('general', 'duplex_threshold'),
})
DEFAULTS.update({'DUPLEX_MODE': 'wake', 'DUPLEX_MIC_THRESHOLD': '0.025'})
# ALFRED_DUPLEX_CONVERSATION_V2: configurable from the existing authenticated /config page.
FIELDS.update({
'DUPLEX_STOP_COMMANDS': ('general', 'boolean'),
'DUPLEX_HANDOFF': ('general', 'boolean'),
'DUPLEX_CONVERSATION': ('general', 'boolean'),
'DUPLEX_CONVERSATION_SECONDS': ('general', 'duplex_seconds'),
})
DEFAULTS.update({
'DUPLEX_STOP_COMMANDS': 'true',
'DUPLEX_HANDOFF': 'true',
'DUPLEX_CONVERSATION': 'true',
'DUPLEX_CONVERSATION_SECONDS': '6',
})
def _auth_config():
try:
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
@@ -137,6 +158,30 @@ def _validate(key, value):
if key not in FIELDS:
raise ValueError(f'Paramètre non autorisé : {key}.')
kind = FIELDS[key][1]
# ALFRED_DUPLEX_BARGE_IN_V1
# ALFRED_DUPLEX_CONVERSATION_V2
if kind == 'duplex_seconds':
try:
seconds = int(value)
except (ValueError, TypeError, OverflowError):
raise ValueError('Duree de conversation invalide.')
if isinstance(value, bool) or not 2 <= seconds <= 15:
raise ValueError('Duree de conversation : de 2 a 15 secondes.')
return str(seconds)
if kind == 'duplex_mode':
if value not in ('off', 'wake', 'natural'):
raise ValueError('Mode duplex invalide.')
return value
if kind == 'duplex_threshold':
if isinstance(value, bool):
raise ValueError('Seuil microphone invalide.')
try:
threshold = float(value)
except (ValueError, TypeError, OverflowError):
raise ValueError('Seuil microphone invalide.')
if not 0.008 <= threshold <= 0.30 or not __import__('math').isfinite(threshold):
raise ValueError('Seuil microphone compris entre 0.008 et 0.30.')
return f'{threshold:.3f}'
if kind == 'boolean':
if value not in (True, False, 'true', 'false'):
raise ValueError(f'{key} doit être vrai ou faux.')
@@ -0,0 +1,391 @@
"""Alfred Developer: safe source staging, review and controlled local deployment.
Only explicitly-allowed source extensions are staged. No generated code is
executed by the MCP. Approving a proposal never deploys it.
"""
from __future__ import annotations
import ast
from datetime import datetime, timezone
from html.parser import HTMLParser
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import subprocess
import tempfile
import threading
import uuid
ROOT = Path(os.environ.get('ALFRED_DEV_PRODUCTION', '/opt/alfred/app')).resolve()
DATA = Path(os.environ.get('ALFRED_DEV_WORKSPACE', '/opt/alfred-dev')).resolve()
BACKUPS = Path(os.environ.get('ALFRED_DEV_BACKUPS', '/opt/alfred-backups/developer')).resolve()
LOCK = threading.RLock()
MAX_FILES = 400
MAX_FILE_BYTES = 300_000
MAX_WRITE_BYTES = 180_000
MAX_DIFF_CHARS = 160_000
ALLOWED = {
'templates': {'.html'},
'static/css': {'.css'},
'static/js': {'.js'},
'services': {'.py'},
}
BLOCKED = {'services/admin_console.py','services/alfred_developer_core.py','services/alfred_developer_page.py'}
ID_RE = re.compile(r'^[a-f0-9]{12}$')
def _digest(data):
return hashlib.sha256(data).hexdigest()
def _utc():
return datetime.now(timezone.utc).isoformat(timespec='seconds')
def _validate_id(pid):
if not isinstance(pid, str) or not ID_RE.fullmatch(pid):
raise ValueError('Identifiant de proposition invalide.')
return pid
def _rel(path):
if not isinstance(path, str) or len(path)>180 or '\\' in path or '\x00' in path:
raise ValueError('Chemin de fichier invalide.')
p = PurePosixPath(path)
parts = p.parts
if not parts or p.is_absolute() or any(x in ('..','.','') or x.startswith('.') for x in parts):
raise ValueError('Chemin non autorisé.')
rel = p.as_posix()
if rel in BLOCKED:
raise ValueError('Fichier protégé : '+rel)
if p.name.endswith(('.min.js','.map','.pyc')):
raise ValueError('Fichier généré non autorisé.')
for base, suffixes in ALLOWED.items():
if rel.startswith(base+'/') and len(parts)>=2 and p.suffix in suffixes:
return rel
if rel == 'app.py':
return rel
raise ValueError('Chemin ou extension interdits : '+rel)
def _safe_file(base, rel):
"""Reject symlinks at every level, including the workspace file."""
here = base
for part in PurePosixPath(rel).parts:
here = here/part
if here.is_symlink():
raise ValueError('Lien symbolique interdit : '+rel)
if here.exists() and not here.is_file():
raise ValueError('Fichier non régulier : '+rel)
return here
def _workspace(pid):
return DATA/_validate_id(pid)
def _meta_path(pid):
return _workspace(pid)/'proposal.json'
def _load(pid):
path = _meta_path(pid)
if not path.is_file() or path.is_symlink():
raise ValueError('Proposition introuvable.')
return json.loads(path.read_text(encoding='utf-8'))
def _atomic(path, content, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix='.alfdev-',dir=str(path.parent))
try:
with os.fdopen(fd,'wb') as f:
f.write(content);f.flush();os.fsync(f.fileno())
os.chmod(tmp, mode)
os.replace(tmp, path)
finally:
if os.path.exists(tmp):os.unlink(tmp)
def _save(pid, meta):
_atomic(_meta_path(pid),json.dumps(meta, ensure_ascii=False,indent=2).encode())
def _git(wd, args, timeout=12):
env={**os.environ,'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_OPTIONAL_LOCKS':'0', 'GIT_TERMINAL_PROMPT':'0'}
cmd=['git','-c','core.hooksPath=/dev/null','-c','core.autocrlf=false','-c','user.name=Alfred Developer','-c','user.email=dev@localhost',*args]
p=subprocess.run(cmd,cwd=wd,env=env,stdin=subprocess.DEVNULL,capture_output=True,text=True,timeout=timeout,check=False)
if p.returncode:raise RuntimeError('Git : '+p.stderr.strip()[:500])
return p.stdout
def create_task(request_text):
if not isinstance(request_text,str) or not 5<=len(request_text.strip())<=2000:
raise ValueError('Décris la modification (5 à 2000 caractères).')
with LOCK:
pid=uuid.uuid4().hex[:12]
wd=_workspace(pid)
wd.mkdir(parents=True,mode=0o700,exist_ok=False)
source=wd/'source';source.mkdir(mode=0o700)
base={}
try:
paths=[]
for basepath, endings in ALLOWED.items():
d=ROOT/basepath
if not d.is_dir() or d.is_symlink():continue
for p in d.rglob('*'):
if p.is_symlink() or not p.is_file():continue
rel=p.relative_to(ROOT).as_posix()
try:_rel(rel)
except ValueError:continue
if p.stat().st_size>MAX_FILE_BYTES:continue
paths.append(rel)
if (ROOT/'app.py').is_file() and not (ROOT/'app.py').is_symlink():
paths.append('app.py')
if len(paths)>MAX_FILES:raise ValueError('Trop de fichiers pour cette V1.')
for rel in sorted(set(paths)):
p=_safe_file(ROOT,rel)
content=p.read_bytes()
if len(content)>MAX_FILE_BYTES:continue
target=_safe_file(source,rel)
target.parent.mkdir(parents=True, exist_ok=True)
_atomic(target,content,0o600)
base[rel]={'sha256':_digest(content),'mode':p.stat().st_mode & 0o777}
if not base:raise ValueError('Aucun fichier source à préparer.')
_git(source,['init','-q'])
_git(source,['add','--','.'])
_git(source,['commit','-q','-m','Base de travail (copie Alfred)'])
meta={'id':pid,'title':request_text.strip(),'created_at':_utc(), 'status':'editing',
'base':base,'approved_hash':None,'checks':None,'changed':[], 'events':[{'time':_utc(),'event':'Copie Git préparée'}]}
_save(pid,meta)
return {'id':pid,'status':'editing','files':len(base),'workspace':str(source),'note':'Copie de sources uniquement. Aucun secret ni donnée runtime copié volontairement.'}
except Exception:
shutil.rmtree(wd,ignore_errors=True)
raise
def list_source(pid, prefix=''):
_load(pid)
p=str(prefix or '')
return [x for x in sorted(_load(pid)['base']) if x.startswith(p)][:250]
def read_source(pid, rel):
meta=_load(pid); rel=_rel(rel)
if rel not in meta['base']:raise ValueError('Ce fichier n’est pas présent dans la copie.')
path=_safe_file(_workspace(pid)/'source',rel)
if not path.is_file():raise ValueError('Fichier absent.')
return {'path':rel,'text':path.read_text(encoding='utf-8'),'sha256':_digest(path.read_bytes())}
def change_source(pid, rel, content):
if not isinstance(content,str) or len(content.encode('utf-8'))>MAX_WRITE_BYTES or '\x00' in content:
raise ValueError('Contenu invalide ou supérieur à 180 Ko.')
rel=_rel(rel)
with LOCK:
meta=_load(pid)
if meta['status'] not in ('editing','tested'):raise ValueError('Proposition verrouillée ; créer une nouvelle tâche.')
if rel not in meta['base']:
# New files allowed under the same strict allowlist, not replacements of secrets.
if len(meta['base'])>=MAX_FILES:raise ValueError('Limite de fichiers atteinte.')
if _safe_file(ROOT,rel).exists():raise ValueError('Fichier présent en production : création refusée.')
meta['base'][rel]={'sha256':None,'mode':0o644}
target=_safe_file(_workspace(pid)/'source',rel)
_atomic(target,content.encode('utf-8'))
meta['status']='editing'
meta['checks']=None
meta['changed']=[]
meta['diff_sha256']=None
_save(pid,meta)
return {'ok':True,'path':rel,'bytes':len(content.encode('utf-8')),'note':'Modification dans la copie uniquement.'}
def _diff(pid):
meta=_load(pid); wd=_workspace(pid)/'source'
diffs=[];changed=[]; all_ok=True
for rel in sorted(meta['base']):
orig=meta['base'][rel]['sha256']
p=_safe_file(wd,rel)
if not p.exists():
raise ValueError('Suppression de fichier non prise en charge : '+rel)
data=p.read_bytes()
if len(data)>MAX_WRITE_BYTES and _digest(data)!=orig:
raise ValueError('Fichier modifié trop volumineux : '+rel)
if _digest(data)==orig:continue
changed.append(rel)
# Git diff --no-index avoids untracked file omission. Python stdlib difflib
import difflib
old=(_git(wd,['show','HEAD:'+rel]) if orig is not None else '')
new=data.decode('utf-8')
diffs.extend(difflib.unified_diff(old.splitlines(True),new.splitlines(True),fromfile='avant/'+rel,tofile='apres/'+rel,n=3))
diff=''.join(diffs)
if len(diff)>MAX_DIFF_CHARS:raise ValueError('Diff trop grand pour revue Web.')
return changed,diff
class _HTMLCheck(HTMLParser):
pass
def run_checks(pid):
with LOCK:
meta=_load(pid)
if meta['status'] not in ('editing','tested'):raise ValueError('Proposition verrouillée.')
changed,diff=_diff(pid)
if not changed:raise ValueError('Aucune modification à tester.')
results=[]
for rel in changed:
path=_safe_file(_workspace(pid)/'source',rel)
content=path.read_text(encoding='utf-8')
try:
if rel.endswith('.py'):
ast.parse(content, filename=rel)
verdict='Syntaxe Python : OK (aucune exécution)'
elif rel.endswith('.js'):
if not shutil.which('node'):
raise ValueError('Node.js absent : vérification JS obligatoire avant approbation.')
process=subprocess.run(['node','--check','-'],input=content,text=True,capture_output=True,timeout=6)
if process.returncode:raise ValueError(process.stderr[:600])
verdict='Syntaxe JavaScript : OK (aucune exécution)'
elif rel.endswith('.html'):
parser=_HTMLCheck();parser.feed(content);parser.close()
verdict='Analyse HTML : OK (structure complète non garantie)'
elif rel.endswith('.css'):
if content.count('{')!=content.count('}'):
raise ValueError('Nombre d’accolades CSS incohérent.')
verdict='Contrôle CSS élémentaire : OK'
else:raise ValueError('Type non testé.')
results.append({'file':rel,'ok':True,'detail':verdict})
except (SyntaxError,ValueError,subprocess.TimeoutExpired) as exc:
results.append({'file':rel,'ok':False,'detail':str(exc)[:500]})
meta['checks']=results
meta['changed']=changed
meta['diff_sha256']=_digest(diff.encode())
meta['status']='tested' if all(x['ok'] for x in results) else 'editing'
meta['events'].append({'time':_utc(),'event':'Contrôles effectués'})
_save(pid,meta)
return {'status':meta['status'],'passed':all(x['ok'] for x in results),'checks':results,'changed':changed, 'warning':'Tests statiques uniquement ; aucune exécution du nouveau code ni validation visuelle automatisée.'}
def submit(pid):
with LOCK:
meta=_load(pid)
if meta['status']!='tested':raise ValueError('Exécute et réussis les contrôles avant de soumettre.')
changed,diff=_diff(pid)
if not changed or changed!=meta['changed'] or _digest(diff.encode())!=meta.get('diff_sha256'):
raise ValueError('Code modifié depuis les contrôles. Relancer les tests.')
meta['status']='pending';meta['events'].append({'time':_utc(),'event':'En attente de validation'});_save(pid,meta)
return {'id':pid,'status':'pending','changed':changed,'note':'Approbation nécessaire dans /maintenance.'}
def list_tasks():
if not DATA.exists():return []
out=[]
for wd in sorted(DATA.iterdir(),reverse=True):
if not wd.is_dir() or wd.is_symlink() or not ID_RE.fullmatch(wd.name):continue
try:
m=_load(wd.name)
out.append({'id':m['id'],'title':m['title'],'status':m['status'],'created_at':m['created_at'],'changed':m.get('changed',[])})
except (ValueError,KeyError,OSError,json.JSONDecodeError):pass
return out[:100]
def task_detail(pid):
meta=_load(pid)
changed,diff=_diff(pid)
return {'id':pid,'title':meta['title'],'status':meta['status'],'created_at':meta['created_at'],
'changed':changed,'diff':diff,'checks':meta.get('checks'),'events':meta.get('events',[]),
'approved_hash':meta.get('approved_hash'), 'diff_sha256':_digest(diff.encode('utf-8'))}
def approve(pid, decision, expected_hash):
if decision not in ('approve','reject'):raise ValueError('Décision inconnue.')
with LOCK:
meta=_load(pid)
if meta['status']!='pending':raise ValueError('Proposition non soumise ou déjà décidée.')
changed,diff=_diff(pid)
real_hash=_digest(diff.encode())
if not isinstance(expected_hash,str) or expected_hash!=real_hash or meta.get('diff_sha256')!=real_hash:
raise ValueError('Le code a changé, recharger la proposition.')
meta['status']='approved' if decision=='approve' else 'rejected'
meta['approved_hash']=real_hash if decision=='approve' else None
meta['events'].append({'time':_utc(),'event':'Validée par un administrateur' if decision=='approve' else 'Refusée'})
_save(pid,meta)
return {'id':pid,'status':meta['status'], 'note':'Approbation enregistrée. Déploiement CLI local uniquement.'}
def deploy(pid, *, apply=False):
"""Only called by local administrator CLI, never by MCP or Flask route."""
with LOCK:
meta=_load(pid)
if meta['status']!='approved':raise ValueError('Validation Web préalable requise.')
changed,diff=_diff(pid)
if not changed or meta.get('approved_hash')!=_digest(diff.encode()):
raise ValueError('Le correctif ne correspond plus à la version approuvée.')
for rel in changed:
original=_safe_file(ROOT,rel)
current=original.read_bytes() if original.exists() else None
current_hash=_digest(current) if current is not None else None
if current_hash!=meta['base'][rel]['sha256']:
raise ValueError('Production modifiée depuis la copie : '+rel)
staged=_safe_file(_workspace(pid)/'source',rel)
if not staged.is_file():raise ValueError('Fichier de stage absent : '+rel)
if not apply:
return {'ok':True,'dry_run':True,'changed':changed,'requires_restart':any(x.endswith('.py') for x in changed)}
BACKUPS.mkdir(parents=True,exist_ok=True)
bkp=BACKUPS/(pid+'-'+datetime.now().strftime('%Y%m%d_%H%M%S'))
bkp.mkdir(mode=0o700)
manifest={'id':pid,'files':{},'created_at':_utc()}
for rel in changed:
original=_safe_file(ROOT,rel)
current=original.read_bytes() if original.exists() else None
mode=original.stat().st_mode & 0o777 if current is not None else 0o644
manifest['files'][rel]={'existed':current is not None,'mode':mode,'before_sha256':_digest(current) if current is not None else None,
'after_sha256':_digest(_safe_file(_workspace(pid)/'source',rel).read_bytes())}
if current is not None:
dest=bkp/'original'/rel
_atomic(dest,current,0o600)
_atomic(bkp/'manifest.json',json.dumps(manifest,ensure_ascii=False,indent=2).encode())
done=[]
try:
for rel in changed:
path=_safe_file(ROOT,rel)
code=_safe_file(_workspace(pid)/'source',rel).read_bytes()
_atomic(path,code,manifest['files'][rel]['mode']);done.append(rel)
except BaseException:
for rel in reversed(done):
i=manifest['files'][rel];path=_safe_file(ROOT,rel)
if i['existed']:_atomic(path,(bkp/'original'/rel).read_bytes(),i['mode'])
elif path.exists():path.unlink()
raise
meta['status']='deployed'
meta['backup']=str(bkp)
meta['events'].append({'time':_utc(),'event':'Déployé depuis CLI administrateur'})
_save(pid,meta)
return {'ok':True,'changed':changed,'backup':str(bkp),'requires_restart':any(x.endswith('.py') for x in changed)}
def rollback(pid, *, apply=False):
with LOCK:
meta=_load(pid)
if meta['status']!='deployed':raise ValueError('Aucune version déployée à restaurer.')
bkp=Path(meta.get('backup',''))
if not bkp.is_dir() or bkp.is_symlink() or bkp.parent!=BACKUPS:
raise ValueError('Sauvegarde introuvable ou invalide.')
manifest=json.loads((bkp/'manifest.json').read_text())
for rel, info in manifest['files'].items():
rel=_rel(rel); current=_safe_file(ROOT,rel)
data=current.read_bytes() if current.exists() else None
if (_digest(data) if data is not None else None)!=info['after_sha256']:
raise ValueError('Fichier modifié depuis le déploiement : '+rel)
if not apply:return {'ok':True,'dry_run':True,'files':list(manifest['files'])}
for rel,info in manifest['files'].items():
path=_safe_file(ROOT,rel)
if info['existed']:_atomic(path,(bkp/'original'/rel).read_bytes(),info['mode'])
elif path.exists():path.unlink()
meta['status']='rolled_back';meta['events'].append({'time':_utc(),'event':'Retour arrière depuis CLI'})
_save(pid,meta)
return {'ok':True,'files':list(manifest['files'])}
@@ -0,0 +1,37 @@
"""Admin-only review UI for staged Alfred Developer proposals.
No web endpoint can deploy, restart, execute code or run shell commands.
"""
from flask import Blueprint, jsonify, render_template, request
from services import admin_console
from services import alfred_developer_core as dev
def register_developer_page(app):
bp=Blueprint('alfred_developer_ui',__name__)
@bp.get('/maintenance')
def maintenance_page():
return render_template('alfred_maintenance.html')
@bp.get('/api/control/developer/tasks')
@admin_console._require_admin
def tasks():
return jsonify(ok=True, tasks=dev.list_tasks())
@bp.get('/api/control/developer/tasks/<pid>')
@admin_console._require_admin
def detail(pid):
try:return jsonify(ok=True, task=dev.task_detail(pid))
except (ValueError,OSError,RuntimeError) as e:return jsonify(error=str(e)),400
@bp.post('/api/control/developer/tasks/<pid>/decision')
@admin_console._require_admin
def decide(pid):
data=request.get_json(silent=True) or {}
try:
return jsonify(ok=True, result=dev.approve(pid,data.get('decision'),data.get('diff_sha256')))
except (ValueError,OSError,RuntimeError) as e:
return jsonify(error=str(e)),400
app.register_blueprint(bp)
+365
View File
@@ -0,0 +1,365 @@
# ALFRED_DUPLEX_CONVERSATION_V2
"""Dona duplex V2: short stop commands, interrupted follow-ups and conversation window.
No acoustic echo cancellation is provided. A shared, echo-cancelled input device is
recommended. This code only stops Piper audio, never a Hermes task.
"""
from __future__ import annotations
import json
import os
import queue
import re
import threading
import time
import unicodedata
from collections import deque
def normalize(text):
text = unicodedata.normalize('NFKD', str(text or '').casefold())
return ' '.join(re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c))).split())
STOP_PHRASES = ('stop', 'stoppe', 'arrete', 'arretes', 'silence', 'tais toi', 'taisez vous', 'chut')
LEAD_WORDS = ('attends', 'attendez', 'une seconde', 'minute')
def without_wake(text, wake):
text = normalize(text)
wake = normalize(wake)
if wake and (text == wake or text.startswith(wake + ' ')):
text = text[len(wake):].strip()
return text
def stop_only(text, wake=''):
cleaned = without_wake(text, wake)
return cleaned in STOP_PHRASES or cleaned in LEAD_WORDS
def clean_question(text, wake):
# Keep original spelling and technical names (Wazuh, Proxmox, LXC).
text = str(text or '').strip(' ,.!?\t\n')
if wake:
prefix = re.match(r'^' + re.escape(wake) + r'\b[\s,.:;!?-]*', text, re.I)
if prefix:
text = text[prefix.end():].strip()
for lead in STOP_PHRASES + LEAD_WORDS:
prefix = re.match(r'^' + re.escape(lead) + r'\b[\s,.:;!?-]+', text, re.I)
if prefix:
text = text[prefix.end():].strip()
break
return text if len(text) >= 3 and not stop_only(text, wake) else ''
def looks_like_echo(candidate, spoken):
candidate = normalize(candidate)
spoken = normalize(spoken)
if not candidate or not spoken:
return False
if candidate in spoken:
return True
cw, sw = candidate.split(), set(spoken.split())
return len(cw) >= 3 and sum(w in sw for w in cw) / len(cw) >= 0.85
def _bool(name, default):
return os.getenv(name, str(default)).strip().lower() in ('1', 'yes', 'true', 'on', 'oui')
def config():
mode = os.getenv('DUPLEX_MODE', 'wake').strip().lower()
if mode not in {'off', 'wake', 'natural'}:
mode = 'off'
try:
threshold = float(os.getenv('DUPLEX_MIC_THRESHOLD', '0.025'))
except (TypeError, ValueError):
threshold = 0.025
try:
seconds = int(os.getenv('DUPLEX_CONVERSATION_SECONDS', '6'))
except (TypeError, ValueError):
seconds = 6
return {'mode': mode, 'threshold': min(0.30, max(0.008, threshold)),
'wake_word': os.getenv('WAKE_WORD', 'alfred').strip().lower(),
'stop_commands': _bool('DUPLEX_STOP_COMMANDS', True),
'handoff': _bool('DUPLEX_HANDOFF', True),
'conversation': _bool('DUPLEX_CONVERSATION', True),
'conversation_seconds': min(15, max(2, seconds)),
'aec': 'not_provided',
'limitations': 'Pas d AEC: risque de faux positifs par le haut-parleur. A utiliser avec une source audio AEC pour le mode naturel.'}
def _rms(block):
import numpy as np
samples = np.frombuffer(block, dtype=np.int16).astype(np.float32)
return float(np.sqrt(np.mean(samples * samples)) / 32768.0) if samples.size else 0.0
def _stream(rate, device):
import sounddevice as sd
frames = max(320, rate // 10)
q = queue.Queue(maxsize=64)
def cb(indata, _frames, _time_info, _status):
try:
q.put_nowait(bytes(indata))
except queue.Full:
pass
return sd.RawInputStream(samplerate=rate, blocksize=frames,
dtype='int16', channels=1, device=device, callback=cb), q
def _vosk_recognizer(rate, *, wake_only=False, wake_word=''):
from vosk import KaldiRecognizer
from services.audio import get_vosk_model
model_path = os.getenv('WAKE_VOSK_MODEL_PATH') if wake_only else os.getenv('VOSK_MODEL_PATH')
if not model_path or not os.path.isfile(model_path):
raise RuntimeError('Modele Vosk requis introuvable : ' + str(model_path))
if wake_only:
words = [wake_word, *STOP_PHRASES, *LEAD_WORDS, '[unk]']
return KaldiRecognizer(get_vosk_model(model_path), rate,
json.dumps(list(dict.fromkeys(words)), ensure_ascii=False))
return KaldiRecognizer(get_vosk_model(model_path), rate)
def transcribe_capture(pcm, rate, *, fallback=''):
"""Uses Whisper for final text when enabled, and local Vosk otherwise."""
if not pcm:
return normalize(fallback)
if os.getenv('STT_ENGINE', 'vosk').strip().lower() in ('whisper', 'wyoming', 'wyoming_whisper'):
try:
from services.wyoming_stt import transcribe_pcm
result = transcribe_pcm(pcm,
host=os.getenv('WYOMING_WHISPER_HOST', '192.168.1.13'),
port=int(os.getenv('WYOMING_WHISPER_PORT', '10300')),
language=os.getenv('WYOMING_WHISPER_LANGUAGE', 'fr'),
sample_rate=rate, connect_timeout=3.0,
response_timeout=float(os.getenv('WYOMING_WHISPER_TIMEOUT', '20')))
if result:
return result.strip()
except Exception as exc:
print('[ALFRED DUPLEX V2] Whisper en secours Vosk :', exc, flush=True)
try:
rec = _vosk_recognizer(rate)
rec.AcceptWaveform(pcm)
result = json.loads(rec.FinalResult() or '{}').get('text') or ''
return result.strip() or str(fallback).strip()
except Exception as exc:
print('[ALFRED DUPLEX V2] Transcription Vosk indisponible :', exc, flush=True)
return str(fallback).strip()
class DuplexMonitor:
def __init__(self, *, muted, on_interrupt, on_status=None):
self.muted = muted
self.on_interrupt = on_interrupt
self.on_status = on_status or (lambda data: None)
self._stop = threading.Event()
self._done = threading.Event()
self._thread = None
self._lock = threading.RLock()
self._interrupted = False
self._last_error = None
self._followup = ''
self._reason = None
self._spoken = ''
def start(self, spoken_text):
cfg = config()
if cfg['mode'] == 'off' or self.muted():
return False
with self._lock:
if self._thread and self._thread.is_alive():
return False
self._stop.clear()
self._done.clear()
self._interrupted = False
self._followup = ''
self._reason = None
self._last_error = None
self._spoken = spoken_text
self._thread = threading.Thread(target=self._listen,
args=(spoken_text, cfg), daemon=True, name='dona-duplex-v2')
self._thread.start()
return True
def stop(self):
self._stop.set()
thread = self._thread
if thread and thread is not threading.current_thread():
thread.join(timeout=1.3)
def status(self):
with self._lock:
return {'active': bool(self._thread and self._thread.is_alive() and not self._stop.is_set()),
'interrupted': self._interrupted, 'reason': self._reason,
'error': self._last_error, **config()}
def interrupted(self):
with self._lock:
return self._interrupted
def wait_for_followup(self, timeout=9.0):
self._done.wait(timeout=timeout)
with self._lock:
return self._followup
def _signal(self, data):
try:
self.on_status(data)
except Exception:
pass
def _fire(self, reason):
with self._lock:
if self._interrupted or self._stop.is_set() or self.muted():
return False
self._interrupted = True
self._reason = reason
print('[ALFRED DUPLEX V2] Interruption :', reason, flush=True)
try:
self.on_interrupt()
except Exception as exc:
self._last_error = str(exc)
self._signal({'mode': reason, 'interrupted': True})
return True
def _read_after_interruption(self, q, rate, cfg, seed, hint):
"""Same microphone stream: don't lose a follow-up immediately after the wake word."""
if not cfg['handoff']:
return ''
# Capture up to 8 seconds, including a short preroll from before interruption.
data = bytearray(seed)
start = time.monotonic()
last_voice = start
voiced = bool(clean_question(hint, cfg['wake_word']))
while time.monotonic() - start < 8 and not self._stop.is_set() and not self.muted():
try:
block = q.get(timeout=0.12)
except queue.Empty:
if voiced and time.monotonic() - last_voice > 1.1:
break
continue
data.extend(block)
if len(data) > rate * 2 * 12:
break
if _rms(block) >= cfg['threshold']:
voiced = True
last_voice = time.monotonic()
if voiced and time.monotonic() - last_voice >= 1.05:
break
if not voiced and time.monotonic() - start > 2.3:
return ''
text = clean_question(transcribe_capture(bytes(data), rate, fallback=hint), cfg['wake_word'])
if not text or looks_like_echo(text, self._spoken) or stop_only(text, cfg['wake_word']):
return ''
return text
def _listen(self, spoken, cfg):
try:
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
wake_only = cfg['mode'] == 'wake'
rec = _vosk_recognizer(rate, wake_only=wake_only, wake_word=cfg['wake_word'])
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
stream, q = _stream(rate, device)
preroll = deque(maxlen=18)
stable = ''
stable_at = 0.0
started = time.monotonic()
with stream:
while not self._stop.is_set() and not self.muted():
try:
block = q.get(timeout=0.12)
except queue.Empty:
continue
preroll.append(block)
if time.monotonic() - started < 0.65 or _rms(block) < cfg['threshold']:
continue
final = rec.AcceptWaveform(block)
payload = json.loads(rec.Result() if final else rec.PartialResult())
heard = normalize(payload.get('text' if final else 'partial') or '')
if not heard or looks_like_echo(heard, spoken):
stable = ''
continue
stop = cfg['stop_commands'] and stop_only(heard, cfg['wake_word'])
woke = cfg['wake_word'] in heard.split()
# Natural mode: a distinct 2+-word utterance, confirmed across
# multiple chunks. The speaker itself may still cause false positives.
natural = (cfg['mode'] == 'natural' and len(heard.split()) >= 2)
candidate = stop or woke or natural
if not candidate:
stable = ''
continue
now = time.monotonic()
if heard != stable:
stable, stable_at = heard, now
continue
if not final and now - stable_at < (0.20 if stop else 0.43):
continue
if not self._fire('stop' if stop else 'wake' if woke else 'natural'):
continue
self._signal({'mode': 'handoff', 'listening': True})
result = self._read_after_interruption(q, rate, cfg,
b''.join(preroll), heard)
with self._lock:
self._followup = result
if result:
print('[ALFRED DUPLEX V2] Nouvelle question :', repr(result), flush=True)
return
except Exception as exc:
self._last_error = str(exc)
print('[ALFRED DUPLEX V2] Capture indisponible :', exc, flush=True)
finally:
self._done.set()
def listen_conversation(self):
"""Short wake-free listening period AFTER Piper has finished speaking."""
cfg = config()
if not cfg['conversation'] or cfg['mode'] == 'off' or self.muted():
return ''
self.stop()
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
try:
stream, q = _stream(rate, device)
seconds = cfg['conversation_seconds']
self._signal({'mode': 'conversation', 'listening': True, 'timeout': seconds})
pcm = bytearray()
started = time.monotonic()
last_voice = None
voice_started = False
consecutive_voice = 0
with stream:
while time.monotonic() - started < seconds and not self.muted():
try:
block = q.get(timeout=0.12)
except queue.Empty:
if last_voice and time.monotonic() - last_voice > 1.15:
break
continue
# Ignore the output audio tail immediately after Piper.
if time.monotonic() - started < 0.4:
continue
pcm.extend(block)
if len(pcm) > rate * 2 * 12:
break
if _rms(block) >= cfg['threshold']:
consecutive_voice += 1
if consecutive_voice >= 3: # reject isolated clicks (< ~0.3 s)
voice_started = True
last_voice = time.monotonic()
else:
consecutive_voice = 0
if last_voice and time.monotonic() - last_voice > 1.15:
break
if not voice_started:
return ''
transcript = transcribe_capture(bytes(pcm), rate)
if stop_only(transcript, cfg['wake_word']) or looks_like_echo(transcript, self._spoken):
return ''
return clean_question(transcript, cfg['wake_word'])
except Exception as exc:
self._last_error = str(exc)
print('[ALFRED DUPLEX V2] Conversation indisponible :', exc, flush=True)
return ''
finally:
self._signal({'mode': 'conversation', 'listening': False})
@@ -0,0 +1,89 @@
"""Reset only the Hermes conversation ID used by Alfred.
Admin-protected, CSRF-protected, and concurrency-safe with ask_hermes().
Does NOT delete the actual Hermes conversation history or change MCP/skills.
"""
from __future__ import annotations
import fcntl
import os
from pathlib import Path
import secrets
from datetime import datetime, timezone
from flask import Blueprint, jsonify
from services import admin_console, hermes_client
class HermesSessionBusy(RuntimeError):
"""Hermes is currently processing a message."""
def archive_current_session() -> bool:
"""Return True when an existing session was archived.
The exact same thread lock and filesystem lock are used by ask_hermes.
Both locks are non-blocking here to keep HTTP responsive.
"""
if os.getenv('HERMES_SESSION_MODE', 'persistent').strip().lower() == 'legacy':
raise ValueError('Hermes est en mode legacy : les conversations sont déjà indépendantes.')
request_lock = hermes_client._REQUEST_LOCK
if not request_lock.acquire(blocking=False):
raise HermesSessionBusy('Hermes traite une demande. Réessaie lorsqu’il a terminé.')
try:
path = hermes_client._session_file()
if path.is_symlink():
raise ValueError('Fichier de session symbolique non autorisé.')
path.parent.mkdir(parents=True, exist_ok=True)
lock_path = path.with_name(path.name + '.lock')
flags = os.O_CREAT | os.O_RDWR
if hasattr(os, 'O_NOFOLLOW'):
flags |= os.O_NOFOLLOW
fd = os.open(lock_path, flags, 0o600)
with os.fdopen(fd, 'r+') as lock_file:
try:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise HermesSessionBusy('Une conversation Hermes est en cours. Réessaie plus tard.') from exc
try:
if not path.exists():
return False
if not path.is_file():
raise ValueError('État de session Hermes inattendu.')
# Archive the file even if its JSON is invalid: this action is also a recovery path.
archive_dir = path.parent / 'hermes_session_archives'
if archive_dir.is_symlink():
raise ValueError('Répertoire d’archives symbolique non autorisé.')
archive_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
stem = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
name = f'{path.stem}-{stem}-{secrets.token_hex(4)}.json'
os.replace(path, archive_dir / name)
print('[ALFRED] Nouvelle session Hermes demandée depuis l’interface administrateur.', flush=True)
return True
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
finally:
request_lock.release()
def register_hermes_session_page(app):
bp = Blueprint('alfred_hermes_session_button_v1', __name__)
@bp.post('/api/control/hermes/new-session')
@admin_console._require_admin
def new_session():
try:
archived = archive_current_session()
except HermesSessionBusy as exc:
return jsonify(ok=False, error=str(exc)), 409
except (OSError, ValueError, RuntimeError) as exc:
app.logger.warning('Nouvelle session Hermes impossible : %s', exc)
return jsonify(ok=False, error='Impossible de réinitialiser la session : '+str(exc)), 503
response = jsonify(ok=True, archived=archived,
message=('Session précédente sauvegardée. La prochaine demande ouvrira une nouvelle session Hermes.'
if archived else 'Aucune session précédente : Hermes en créera une à la prochaine demande.'))
response.headers['Cache-Control'] = 'no-store, private'
return response
app.register_blueprint(bp)
+48 -5
View File
@@ -3,6 +3,7 @@ import os
import subprocess
import tempfile
import time
import threading # ALFRED_DUPLEX_BARGE_IN_V1
import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
@@ -71,10 +72,31 @@ async def synthesize(text, wav_path):
await client.disconnect()
# ALFRED_DUPLEX_BARGE_IN_V1: control only this module's aplay process.
_playback_lock = threading.RLock()
_playback_cancel = threading.Event()
_playback_proc = None
def stop_playback():
"""Interrupts Piper playback or marks pending synth for cancellation."""
global _playback_proc
_playback_cancel.set()
with _playback_lock:
process = _playback_proc
if process is not None and process.poll() is None:
try:
process.terminate()
except OSError:
pass
def speak(text):
if not text or not text.strip():
return
_playback_cancel.clear() # ALFRED_DUPLEX_BARGE_IN_V1
fd, wav_path = tempfile.mkstemp(
prefix="alfred_piper_",
suffix=".wav"
@@ -90,11 +112,32 @@ def speak(text):
print("[ALFRED PIPER] Lecture...", flush=True)
subprocess.run(
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
check=True,
timeout=120
)
# ALFRED_DUPLEX_BARGE_IN_V1: allow a concurrent microphone detector
# to terminate playback, without killing Hermes or the entire service.
global _playback_proc
if _playback_cancel.is_set():
print("[ALFRED PIPER] Annulé avant lecture", flush=True)
return
proc = subprocess.Popen(["aplay", "-q", "-D", AUDIO_DEVICE, wav_path])
with _playback_lock:
_playback_proc = proc
if _playback_cancel.is_set() and proc.poll() is None:
proc.terminate()
try:
result = proc.wait(timeout=120)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
raise
finally:
with _playback_lock:
if _playback_proc is proc:
_playback_proc = None
if _playback_cancel.is_set():
print("[ALFRED PIPER] Lecture interrompue par la voix", flush=True)
return
if result:
raise subprocess.CalledProcessError(result, proc.args)
print(
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
@@ -0,0 +1,33 @@
---
name: alfred-developer
description: "Pour les demandes de modification de l'interface, du code Python, HTML, CSS, JavaScript, de l'ergonomie ou de fonctionnalités de Dona/Alfred ; préparer et tester dans une copie, puis soumettre une proposition à valider sur /maintenance."
version: 1.0.0
---
# Alfred Developer — modifications du propre programme de l'assistant
Lorsque l'utilisateur demande à Dona/Alfred de modifier son programme, exploiter **exclusivement le MCP `alfred_developer`** pour préparer les modifications. Le projet de production se trouve sous `/opt/alfred/app` et ne doit jamais être modifié directement.
## Méthode obligatoire
1. Appeler `create_task` avec une description exacte de la demande, puis noter l'ID.
2. Examiner les fichiers utiles avec `list_source` puis `read_source` ; ne pas supposer leur organisation.
3. Préparer des modifications minimales avec `modify_source` : copier le contenu intégral du fichier voulu, modifier uniquement le nécessaire et préserver le reste.
4. Appeler `run_checks`, résoudre les erreurs de syntaxe et relancer les contrôles.
5. Appeler `preview_changes` pour examiner les différences ; éviter les régressions.
6. Si les contrôles passent, appeler `submit_proposal`.
7. Annoncer **l'ID** et inviter l'utilisateur à examiner la proposition sur `/maintenance`.
## Règles de sécurité et de vérité
- **Jamais utiliser Terminal, `sed`, `cp`, `git`, Python direct ou d'autres MCP pour modifier `/opt/alfred/app`, redémarrer le service ou déployer.**
- Les outils MCP de développement ne permettent ni déploiement ni restauration.
- Ne modifier aucun `.env`, secret, token, mot de passe, base SQLite ou configuration de sécurité.
- La validation humaine et le déploiement CLI sont séparés. Une approbation Web ne déploie pas le code.
- Les contrôles V1 sont des **vérifications statiques**, pas des tests de fonctionnement en situation réelle : le dire clairement.
- Si des tests complémentaires, une modification de fichier protégé ou l'exécution du code sont nécessaires, demander l'intervention de l'administrateur.
- Ne pas inventer de statut, d'aperçu ni de résultat d'installation.
- Cette version n'est pas un agent de développement en arrière-plan indépendant : la session Hermes conduit le travail, et les propositions restent persistées dans l'espace Git.
## Exemple
« Dona, rends la carte météo plus transparente » → `create_task`, inspecter CSS, `modify_source`, `run_checks`, `submit_proposal`, répondre : « Proposition 12caractères prête à valider sur /maintenance. »
@@ -0,0 +1 @@
:root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,'Segoe UI',sans-serif;color:#f3f7fe;background:#09121d}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:linear-gradient(180deg,#06131fbf,#0a1526c7),url('/static/img/alfred-ambience.webp') center/cover fixed no-repeat}button,input{font:inherit}a{color:inherit;text-decoration:none}.dv-head{display:flex;justify-content:space-between;align-items:center;gap:15px;flex-wrap:wrap;padding:18px clamp(16px,3vw,45px);background:#07132096;border-bottom:1px solid #d3e3fd23;backdrop-filter:blur(18px)}.dv-brand{display:flex;align-items:center;gap:14px}.dv-initial{font-weight:900;font-size:53px;line-height:1;background:linear-gradient(120deg,#a3e4ff,#0875fb);background-clip:text;color:transparent}.dv-brand strong{font-size:24px;display:block}.dv-brand small{color:#aec3dc;font-size:12px}.dv-head nav{display:flex;flex-wrap:wrap;gap:5px}.dv-head nav a{padding:9px 11px;border-radius:9px;font-size:13px;color:#c8d9ed}.dv-head nav a:hover,.dv-head nav a.active{background:#1a6cb6a6;color:white}.dv-main{max-width:1550px;margin:auto;padding:27px clamp(14px,3vw,44px)}.dv-eyebrow{font-size:11px;color:#7cbeff;font-weight:800;letter-spacing:.13em}.dv-intro{display:flex;justify-content:space-between;gap:22px;align-items:end;margin-bottom:22px}.dv-intro h1{font-size:clamp(28px,3vw,43px);margin:4px 0}.dv-intro p:not(.dv-eyebrow){color:#bacbe0;font-size:14px;max-width:790px}.dv-panel{padding:20px;border:1px solid #cfebff36;border-radius:19px;background:rgba(11,24,41,.49);backdrop-filter:blur(19px);box-shadow:0 18px 55px #0003}.dv-layout{display:grid;grid-template-columns:minmax(260px,365px) minmax(0,1fr);gap:17px}.dv-section-title{display:flex;justify-content:space-between;align-items:center;gap:12px}.dv-section-title h2{margin:2px 0 15px;font-size:19px}.dv-muted,.dv-footnote{font-size:13px;color:#bad0e4;line-height:1.6}.dv-tasks{display:flex;flex-direction:column;gap:9px;margin-top:17px}.dv-task{text-align:left;width:100%;background:#061b31a8;border:1px solid #a2c5e73d;border-radius:11px;padding:13px;cursor:pointer;color:white}.dv-task:hover,.dv-task.selected{background:#1261aa60;border-color:#8cc9ff}.dv-task strong,.dv-task small{display:block}.dv-task strong{line-height:1.4}.dv-task small{margin-top:7px;color:#9fbdda}button{background:#145c9fb3;border:1px solid #92c3ec80;color:white;border-radius:11px;padding:10px 15px;cursor:pointer}button:hover{background:#2676bd}button:disabled{opacity:.45;cursor:default}.dv-primary{background:#176bc8}.dv-empty{border:1px dashed #acd4f864;border-radius:12px;padding:28px;color:#a9c5de}.dv-detail h3{font-size:20px;margin:5px 0}.dv-detail h4{font-size:13px;color:#badcfb;letter-spacing:.04em;margin-bottom:10px}.dv-checks{display:grid;gap:8px}.dv-check{font-size:13px;padding:9px 12px;border-radius:9px;background:#051627ba;border-left:3px solid #44cba2}.dv-check.fail{border-color:#f5a37d}.dv-diff{max-height:47vh;overflow:auto;white-space:pre;tab-size:2;background:#030e1be8;border:1px solid #a6cbe13e;border-radius:11px;padding:17px;color:#d9ecff;font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace}.dv-actions{display:flex;gap:11px;flex-wrap:wrap;margin-top:18px}.dv-next{margin-top:15px;border-radius:12px;padding:15px;background:#071f32c9;border:1px solid #40a7ee64}.dv-next code{display:block;padding:10px;margin-top:10px;overflow-x:auto;color:#a5d6ff}.dv-footnote{margin-top:19px}.dv-section-title span{font-size:12px;color:#91d0ff}.dv-panel form{display:flex;gap:10px;flex-wrap:wrap}.dv-panel input{padding:10px 12px;background:#061629;border:1px solid #a5cfed6b;border-radius:10px;color:white}#dvMessage{color:#e8c8a3}#dvMessage.ok{color:#86e6bf}:focus-visible{outline:2px solid #90d0ff;outline-offset:2px}@media(max-width:920px){.dv-layout{grid-template-columns:1fr}.dv-intro{align-items:start;flex-direction:column}.dv-head nav{width:100%}}
@@ -0,0 +1,23 @@
/* ALFRED_HERMES_NEW_SESSION_V1 — habillage Glass, compatible avec le menu V1/V2. */
#avHermesNewSession {cursor:pointer;}
.av-hermes-session-dialog {
position:fixed; inset:0; margin:auto; width:min(520px,calc(100vw - 28px));
max-height:calc(100dvh - 32px);overflow:auto;color:#eef6ff;
border:1px solid rgba(174,210,246,.36);border-radius:19px;
background:linear-gradient(145deg,rgba(18,37,58,.94),rgba(7,19,36,.96));
box-shadow:0 22px 65px rgba(0,0,0,.55);backdrop-filter:blur(22px);
padding:0;z-index:2147483640;
}
.av-hermes-session-dialog::backdrop {background:rgba(2,8,19,.67);backdrop-filter:blur(4px);}
.av-hermes-session-content {display:flex;flex-direction:column;gap:16px;padding:27px;margin:0;}
.av-hermes-session-content h2 {font-size:23px;color:#f4f9ff;margin:0;}
.av-hermes-session-content p {font-size:14px;line-height:1.6;margin:0;color:#d1deed;}
.av-hermes-session-note {font-size:12px!important;color:#a8c0d6!important;}
.av-hermes-session-message {min-height:18px;color:#89d9af!important;}
.av-hermes-session-message.is-error {color:#ffb7b7!important;}
.av-hermes-session-actions {display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;}
.av-hermes-session-actions button,.av-hermes-session-login {border-radius:11px;padding:11px 16px;font-weight:600;font-size:13px;cursor:pointer;}
.av-hermes-session-secondary {background:rgba(255,255,255,.10);color:white;border:1px solid rgba(255,255,255,.22);}
.av-hermes-session-primary,.av-hermes-session-login {background:#268ce8;color:white;border:1px solid rgba(119,198,255,.45);}
.av-hermes-session-login {align-self:flex-start;text-decoration:none;}
.av-hermes-session-actions button:disabled {opacity:.45;cursor:wait;}
@@ -0,0 +1,73 @@
/* ALFRED_REPONSE_CLOSE_V1 — actions toujours au-dessus du contenu rendu par Hermes. */
.alfred-glass-theme #stage {
padding-top: clamp(76px, 9vh, 104px) !important;
position: fixed;
}
.alfred-glass-theme #stage .av-reponse-actions {
position: absolute;
top: clamp(14px, 2.2vh, 25px);
right: clamp(15px, 2vw, 31px);
z-index: 32;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
pointer-events: auto;
}
/* #avBack est déplacé hors de .alfred-main-v1 pour éviter son contexte d'empilement. */
.alfred-glass-theme #stage #avBack.av-reponse-home,
.alfred-glass-theme #stage .av-reponse-close {
position: static !important;
inset: auto !important;
transform: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 43px;
padding: 10px 16px;
border: 1px solid rgba(181, 218, 248, .47);
border-radius: 13px;
background: rgba(18, 42, 65, .93);
color: #f6fbff;
font: 600 13px/1.2 Inter, ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0;
cursor: pointer;
box-shadow: 0 6px 18px rgba(0, 5, 15, .4);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
opacity: 1 !important;
visibility: visible !important;
}
.alfred-glass-theme #stage #avBack.av-reponse-home[hidden] { display: none !important; }
.alfred-glass-theme #stage .av-reponse-close {
width: 43px;
min-width: 43px;
padding: 9px;
border-radius: 50%;
background: rgba(37, 51, 70, .96);
}
.alfred-glass-theme #stage .av-reponse-close svg,
.alfred-glass-theme #stage #avBack.av-reponse-home svg {
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.alfred-glass-theme #stage .av-reponse-close:hover,
.alfred-glass-theme #stage #avBack.av-reponse-home:hover {
background: rgba(21, 103, 169, .98);
border-color: #9ed7ff;
}
.alfred-glass-theme #stage .av-reponse-close:focus-visible,
.alfred-glass-theme #stage #avBack.av-reponse-home:focus-visible {
outline: 3px solid #83d1ff;
outline-offset: 3px;
}
@media (max-width: 600px) {
.alfred-glass-theme #stage { padding-top: 88px !important; }
.alfred-glass-theme #stage .av-reponse-actions { top: 12px; right: 12px; gap: 7px; }
.alfred-glass-theme #stage #avBack.av-reponse-home,
.alfred-glass-theme #stage .av-reponse-close { font-size: 12px; padding: 8px 11px; min-height: 40px; }
.alfred-glass-theme #stage .av-reponse-close { width: 40px; min-width: 40px; padding: 8px; }
}
@@ -0,0 +1,148 @@
/* ALFRED_STT_INLINE_GLASS_V1 — transcription en texte libre au-dessus de la vague.
Charge apres alfred-main-v1.css et alfred-main-v2.css. */
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on {
display:flex !important;
flex-direction:column !important;
align-items:center !important;
justify-content:center !important;
gap:0 !important;
overflow:hidden;
}
/* Ancien aside STT : aucun cadre, aucune position flottante. */
.alfred-glass-theme .alfred-main-v1 .av-voice-hero .transcript-panel.av-stt-inline {
position:relative !important;
inset:auto !important;
left:auto !important; right:auto !important; top:auto !important; bottom:auto !important;
transform:none !important;
order:0;
z-index:3 !important;
display:flex !important;
flex-direction:column !important;
align-items:center !important;
justify-content:center !important;
flex:0 0 auto;
width:min(920px,96%) !important;
max-width:96% !important;
min-height:0 !important;
height:auto !important;
margin:0 auto !important;
padding:0 8px !important;
border:0 !important;
border-radius:0 !important;
outline:0 !important;
background:transparent !important;
box-shadow:none !important;
backdrop-filter:none !important;
-webkit-backdrop-filter:none !important;
opacity:1 !important;
text-align:center;
pointer-events:none;
}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .panel-heading {
min-height:0 !important;
width:100%;
display:flex;
align-items:center;
justify-content:center;
flex-wrap:wrap;
gap:8px 14px;
margin:0 0 4px !important;
padding:0 !important;
}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .panel-title {
display:inline-flex;
align-items:center;
gap:7px;
color:rgba(209,226,247,.83);
font-size:clamp(10px,.85vw,13px) !important;
font-weight:650;
letter-spacing:.08em;
text-shadow:0 2px 9px #0008;
}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .stt-indicator {background:#5e94df;}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline[data-active="true"] .stt-indicator {background:#61dfc4;}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .transcript-state {
color:#87c9ff !important;
font-size:clamp(10px,.85vw,12px);
font-weight:650;
white-space:nowrap;
}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .transcript-text {
display:-webkit-box;
-webkit-box-orient:vertical;
-webkit-line-clamp:2;
line-clamp:2;
overflow:hidden !important;
width:100%;
max-height:3.1em !important;
margin:0 !important;
color:#f0f7ff !important;
font-size:clamp(13px,1.18vw,18px) !important;
font-weight:450;
line-height:1.45 !important;
text-align:center;
text-shadow:0 2px 13px rgba(0,0,0,.85);
overflow-wrap:anywhere;
opacity:1 !important;
}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .transcript-text.is-placeholder {
color:rgba(205,223,243,.62) !important;
}
/* Vague et messages existants sous la transcription. Le canvas reste original. */
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-wave-host {
position:relative !important;
inset:auto !important;
left:auto !important; top:auto !important;
transform:none !important;
order:1;
z-index:1;
flex:0 0 clamp(46px,5.3vw,72px);
align-self:center;
width:min(800px,90%) !important;
height:clamp(46px,5.3vw,72px) !important;
margin:0 auto !important;
}
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-wave-host #wave,
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-wave-host canvas {
height:100% !important;
}
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-voice-controls {
position:relative !important;
inset:auto !important;
order:2;
min-height:0 !important;
height:auto !important;
flex:0 0 auto;
padding-top:0 !important;
margin:0 !important;
align-self:center !important;
}
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-voice-center {
min-width:0 !important;
}
/* Mode et messages d'etat, volontairement inchanges. */
@media (max-width:1100px) {
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on {
min-height:215px !important;
}
}
@media (max-width:600px) {
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on {
min-height:200px !important;
padding:8px 0;
}
.alfred-glass-theme .alfred-main-v1 .av-voice-hero .transcript-panel.av-stt-inline {
width:100% !important; max-width:100% !important;
}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .panel-heading {gap:3px 8px;}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .panel-title {font-size:10px !important;letter-spacing:.03em;}
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .transcript-text {font-size:13px !important;}
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-wave-host {
width:100% !important;
flex-basis:50px;
height:50px !important;
}
}
@media(prefers-reduced-motion:reduce) {
.alfred-glass-theme .alfred-main-v1 .av-stt-inline {transition:none !important;}
}
@@ -0,0 +1,136 @@
/* DONA_HOLOGRAPHIC_V1
* Thème transversal non destructif. Les contrôleurs et APIs existants restent les mêmes.
* Chargement APRÈS les feuilles Glass, y compris les correctifs précédents.
*/
:root { color-scheme:dark; --dh-white:#f1f4ff;--dh-muted:#a8b8d3;--dh-border:rgba(155,183,234,.23);--dh-panel:rgba(4,10,22,.81);--dh-blue:#5796ff;--dh-teal:#74e6d4;--dh-purple:#ab78ff;--dh-orange:#ffae73;--dh-shadow:0 24px 72px rgba(0,0,0,.38); }
html {scrollbar-color:#596b8c #060a14;}
body.dona-holo-v1 { color:var(--dh-white); background-color:#070913!important; background-image:
radial-gradient(ellipse 35% 39% at 3% 70%,rgba(251,101,44,.5),transparent 95%),
radial-gradient(ellipse 36% 52% at 96% 26%,rgba(18,125,236,.42),transparent 85%),
radial-gradient(ellipse 42% 35% at 50% 0%,rgba(119,68,173,.31),transparent 90%),
radial-gradient(ellipse 24% 32% at 82% 94%,rgba(243,97,42,.29),transparent 92%),
linear-gradient(125deg,#070810,#0a1020 54%,#090914) !important;
background-attachment:fixed!important; }
body.dona-holo-v1::before{content:"";position:fixed;z-index:0;inset:-10%;pointer-events:none;
background:radial-gradient(ellipse at 12% 18%,rgba(56,111,251,.17),transparent 33%),radial-gradient(ellipse at 78% 65%,rgba(227,100,165,.15),transparent 32%);
filter:blur(68px);animation:dh-ambient 19s ease-in-out infinite alternate;}
body.dona-holo-v1::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
background:linear-gradient(90deg,rgba(1,5,13,.13),rgba(1,5,13,.45) 43%,rgba(1,5,13,.18));}
@keyframes dh-ambient {0%{transform:translate(-1.5%,0)}100%{transform:translate(2%,1.4%)}}
/* Écrans d'administration, qui utilisent des conteneurs et feuilles CSS différents. */
body.dona-holo-v1 :is(.admin-layout,.id-shell,.id-wrap,.id-layout,.dv-main,.lv-layout,.lg-layout,.log-layout,.logs-layout){position:relative;z-index:1;}
body.dona-holo-v1 .alfred-main-v1{z-index:1;}
/* Priorité au panneau noir central de la maquette. Le décor reste diffus en périphérie. */
body.dona-holo-v1 .alfred-main-v1 .av-head {background:transparent!important;}
body.dona-holo-v1 .alfred-main-v1 .av-logo {background:linear-gradient(145deg,#95c2ff,#5187ff 55%,#c58bff);background-clip:text;-webkit-background-clip:text;color:transparent;filter:drop-shadow(0 1px 18px #4486ff73);}
body.dona-holo-v1 .alfred-main-v1 .av-brand small{color:#c4cde1;}
body.dona-holo-v1 .alfred-main-v1 .av-round{background:rgba(5,9,22,.74)!important;border:1px solid rgba(155,174,224,.24)!important;box-shadow:inset 0 1px rgba(255,255,255,.04);}
body.dona-holo-v1 .alfred-main-v1 .av-round[aria-pressed="true"]{background:rgba(108,28,58,.75)!important;border-color:rgba(255,116,151,.66)!important;}
body.dona-holo-v1 .alfred-main-v1 .av-summary{gap:clamp(9px,1.05vw,18px);}
body.dona-holo-v1 .alfred-main-v1 .av-stat-card{color:var(--dh-white)!important;
background:linear-gradient(140deg,rgba(6,13,31,.79),rgba(8,16,34,.68))!important;
border:1px solid rgba(126,157,227,.24)!important;border-radius:22px!important;
box-shadow:0 12px 44px #01051255,inset 0 1px #ffffff0a!important;
backdrop-filter:blur(20px) saturate(120%)!important;-webkit-backdrop-filter:blur(20px) saturate(120%)!important;
transition:transform .2s,border-color .2s,box-shadow .2s;}
body.dona-holo-v1 .alfred-main-v1 .av-stat-card:hover{transform:translateY(-2px);border-color:#8bb9ff83!important;box-shadow:0 14px 45px #0206128b,0 0 23px #336ce22c!important;background:rgba(10,21,42,.85)!important;}
body.dona-holo-v1 .av-circle{background:#1e376566;color:#94c9ff;}
body.dona-holo-v1 .av-circle.ai{background:#492b7a80;color:#d2b3ff;}
body.dona-holo-v1 .av-weather{background:linear-gradient(140deg,rgba(12,28,44,.74),rgba(8,19,37,.8))!important;}
body.dona-holo-v1 .av-shortcuts button{background:rgba(5,10,24,.82)!important;border:1px solid rgba(151,177,221,.27)!important;border-radius:21px!important;box-shadow:inset 0 1px rgba(255,255,255,.035);}
body.dona-holo-v1 .av-shortcuts button:hover{background:#102344e6!important;border-color:#65a1ff82!important;}
body.dona-holo-v1 .av-shortcuts .a-icon{color:#a9c6ff;}
body.dona-holo-v1 .av-status-line{background:rgba(6,11,27,.65);border-color:rgba(157,186,229,.23);}
body.dona-holo-v1 .av-wave-host canvas{filter:drop-shadow(0 0 9px rgba(91,137,255,.65))!important;}
/* La réponse qui était une énorme div pleine page devient une scène avec cartes vivantes. */
body.dona-holo-v1.alfred-glass-theme #stage {top:clamp(93px,11vh,128px)!important;right:clamp(12px,2vw,34px)!important;bottom:clamp(12px,2.3vh,30px)!important;left:clamp(12px,2vw,34px)!important;
background:linear-gradient(146deg,rgba(2,5,13,.97),rgba(3,7,18,.96) 55%,rgba(6,10,26,.95))!important;
border:1px solid rgba(154,180,231,.25)!important;border-radius:clamp(18px,2vw,31px)!important;
padding:clamp(60px,8vh,100px) clamp(14px,3vw,46px) clamp(14px,3vh,28px)!important;
box-shadow:0 36px 95px rgba(0,0,0,.55),inset 0 1px #d7dfff0d;
display:flex!important;align-items:center!important;justify-content:center!important;overflow:auto!important;}
body.dona-holo-v1.alfred-glass-theme #stage #content{width:min(1300px,100%)!important;max-width:none!important;max-height:none!important;min-height:0!important;overflow:visible!important;margin:auto;}
body.dona-holo-v1.alfred-glass-theme #stage .av-reponse-actions {top:16px!important;right:20px!important;}
body.dona-holo-v1.alfred-glass-theme #stage :is(#avBack.av-reponse-home,.av-reponse-close){background:rgba(17,27,51,.91)!important;border:1px solid #9bb9ec55!important;color:#f4f7ff!important;border-radius:15px!important;}
body.dona-holo-v1.alfred-glass-theme #stage .av-reponse-close{border-radius:50%!important;}
/* Réponse illustrée construite sur les données déjà présentes dans la réponse Hermes.
Les éléments ne sont pas des chiffres simulés : un métrique n'apparaît que s'il est cité. */
#content .dh-response{display:grid;grid-template-columns:minmax(250px,1.12fr) minmax(300px,.95fr);gap:clamp(12px,1.6vw,22px);align-items:stretch;min-width:0;}
#content .dh-primary{position:relative;min-width:0;min-height:335px;display:flex;flex-direction:column;gap:20px;
padding:clamp(22px,2.3vw,36px);border:1px solid rgba(96,142,255,.72);border-radius:24px;
background:linear-gradient(135deg,rgba(13,30,62,.9),rgba(6,14,31,.89) 62%,rgba(15,36,42,.82));
box-shadow:inset 0 1px #ffffff12,0 0 23px #3976fc2b,0 20px 35px #0004;}
#content .dh-primary:before{content:"";position:absolute;inset:-1px;pointer-events:none;border-radius:24px;background:linear-gradient(120deg,#5b83ff33,transparent 44%,#72e8cd17);opacity:.7;}
#content .dh-kicker{display:flex;align-items:center;gap:11px;font-size:11px!important;color:#a9bee6!important;letter-spacing:.16em;font-weight:700;text-transform:uppercase;}
#content .dh-kicker:before{content:"";height:9px;width:9px;background:#61a3ff;border-radius:50%;box-shadow:0 0 12px #579cff;}
#content .dh-headline{font-size:clamp(21px,2.1vw,33px)!important;letter-spacing:-.025em;font-weight:590;line-height:1.28;color:#f4f6ff;overflow-wrap:anywhere;}
#content .dh-excerpt{color:#c8d2e6!important;font-size:clamp(13px,1.02vw,16px)!important;line-height:1.7;white-space:pre-wrap;overflow-wrap:anywhere;flex:1;}
#content .dh-more{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:52px;padding:13px 16px;
background:rgba(37,58,95,.44);border:1px solid #aac9ff42;border-radius:13px;color:#f0f5ff;font:600 13px/1.3 inherit;cursor:pointer;text-align:left;}
#content .dh-more:hover{background:#294b748e;border-color:#95caff92;}
#content .dh-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:minmax(145px,1fr);gap:14px;min-width:0;}
#content .dh-fact{border:1px solid rgba(113,151,218,.35);border-radius:20px;padding:clamp(16px,1.7vw,25px);min-width:0;
background:linear-gradient(135deg,rgba(17,34,63,.94),rgba(9,22,48,.85));box-shadow:inset 0 1px #ffffff0d;display:flex;flex-direction:column;gap:8px;}
#content .dh-fact:nth-child(3n + 2){border-color:rgba(101,209,189,.29);background:linear-gradient(145deg,rgba(12,38,52,.94),rgba(13,26,45,.85));}
#content .dh-fact:nth-child(3n + 3){border-color:rgba(239,150,94,.27);background:linear-gradient(135deg,rgba(45,29,40,.91),rgba(29,22,37,.86));}
#content .dh-fact:nth-child(4n){border-color:rgba(167,124,239,.28);background:linear-gradient(135deg,rgba(31,26,60,.94),rgba(16,20,43,.86));}
#content .dh-fact-label{font-size:clamp(11px,.85vw,14px)!important;line-height:1.35;color:#c1cee8;}
#content .dh-fact-icon{font-size:21px;margin-bottom:6px;opacity:.9;}
#content .dh-fact-value{font-size:clamp(19px,2.25vw,36px)!important;font-weight:570;line-height:1.15;color:#f9faff;letter-spacing:-.04em;overflow-wrap:anywhere;}
#content .dh-fact-note{font-size:11px!important;color:#93a6c9;line-height:1.5;margin-top:auto;overflow-wrap:anywhere;}
#content .dh-tags{display:flex;flex-wrap:wrap;gap:7px;margin-top:6px;}
#content .dh-tag{padding:7px 11px;background:rgba(88,111,171,.2);border:1px solid rgba(143,166,218,.24);border-radius:999px;color:#d1daf6;font-size:12px;}
#content .dh-full{grid-column:1/-1;border:1px solid rgba(145,174,227,.27);background:rgba(11,20,39,.82);border-radius:18px;padding:20px;}
#content .dh-full[hidden]{display:none!important;}
#content .dh-full h3{font-size:16px;margin:0 0 12px;color:#deecff;text-align:left;}
#content .dh-full-text{white-space:pre-wrap;color:#ced8ea;line-height:1.65;font-size:14px;overflow-wrap:anywhere;}
#content .dh-source{font-size:11px;color:#8b9fc1;margin-top:9px;}
#content .dh-response:after{content:"";grid-column:1/-1;justify-self:center;width:min(490px,64%);height:3px;
background:linear-gradient(90deg,transparent,#3e92fe 30%,#c876f8 55%,#f7a569 73%,transparent);border-radius:50%;box-shadow:0 0 18px 5px #529bff5b;margin:7px 0 0;}
/* Les anciens types de cartes émis par les renderers restent pris en charge. */
body.dona-holo-v1 #stage #content > .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:17px;text-align:left;}
body.dona-holo-v1 #stage #content > .cards .card{padding:24px;border-radius:21px;background:linear-gradient(145deg,#13243ddf,#0a162ce5);border:1px solid #7c9cde57;text-align:left;}
body.dona-holo-v1 #stage #content > img.hero {max-height:calc(100dvh - 260px);width:auto;max-width:100%;object-fit:contain;border-radius:16px;}
/* Configuration et Surveillance */
body.dona-holo-v1.alfred-admin-glass .admin-layout{z-index:1;}
body.dona-holo-v1.alfred-admin-glass .ag-rail{background:rgba(3,7,20,.89)!important;border-color:var(--dh-border)!important;}
body.dona-holo-v1.alfred-admin-glass .admin-header{background:transparent!important;}
body.dona-holo-v1.alfred-admin-glass .brand-orb{background:linear-gradient(135deg,#438bff,#9368fa)!important;border-radius:18px;}
body.dona-holo-v1.alfred-admin-glass :is(.panel,.metric-card,.auth-box,.ag-row,.mcp-item){
background:linear-gradient(145deg,rgba(5,13,30,.91),rgba(9,18,36,.84))!important;
border:1px solid rgba(133,164,229,.29)!important;border-radius:21px!important;box-shadow:var(--dh-shadow);backdrop-filter:blur(18px);}
body.dona-holo-v1.alfred-admin-glass .page-top .eyebrow{color:#9cb9ff;}
body.dona-holo-v1.alfred-admin-glass .button.primary{background:linear-gradient(125deg,#3063d0,#556dca)!important;border-color:#98baff88!important;}
body.dona-holo-v1.alfred-admin-glass :is(.field input:not([type=range]):not([type=checkbox]),.field select,.ag-field input,.ag-field select){background:rgba(4,11,25,.87)!important;border-color:#799acc55!important;color:#f2f6ff!important;}
body.dona-holo-v1.alfred-admin-glass .admin-header nav :is(a,button){background:rgba(6,13,31,.78)!important;border-color:#8aace950!important;}
body.dona-holo-v1.alfred-admin-glass .admin-header nav .selected{border-color:#76a5fd!important;background:rgba(38,73,138,.92)!important;}
/* Pages dont les classes proviennent d'extensions installées séparément. */
body.dona-holo-v1 :is(.id-panel,.dv-panel,.lg-panel,.lv-panel,.log-panel,.logs-panel,.glass-panel,.id-editor,.dv-task,.dv-list,.dv-detail){background:linear-gradient(135deg,rgba(6,14,31,.92),rgba(12,20,42,.87))!important;
border:1px solid rgba(127,158,221,.3)!important;border-radius:22px!important;box-shadow:0 15px 35px rgba(0,0,0,.22);backdrop-filter:blur(15px);}
body.dona-holo-v1 :is(.dv-head,.id-header,.id-head,.lv-head,.lg-head,.logs-header,.log-header){position:relative;z-index:2;background:rgba(5,10,23,.66)!important;border-color:#819bd046!important;backdrop-filter:blur(15px);}
body.dona-holo-v1 :is(.dv-head,.id-header,.id-head) :is(a,button){border-radius:12px;}
body.dona-holo-v1 :is(.dv-main,.id-main,.lg-main,.lv-main,.logs-main,.log-main) h1{color:#f5f8ff;letter-spacing:-.035em;}
body.dona-holo-v1 :is(input,select,textarea){accent-color:#7fa7ff;}
body.dona-holo-v1 :is(.id-panel,.dv-panel,.lg-panel,.lv-panel,.log-panel,.logs-panel) :is(input:not([type=checkbox]):not([type=range]),textarea,select){background:rgba(5,11,27,.91)!important;color:#f2f6ff!important;border:1px solid #96b9ef55!important;border-radius:12px!important;}
body.dona-holo-v1 :is(.dv-primary,.id-button.primary){background:linear-gradient(120deg,#376cdd,#6763d5)!important;color:white!important;}
body.dona-holo-v1 :is(a,button,input,select,textarea):focus-visible{outline:2px solid #a9c5ff;outline-offset:3px;}
/* Page Journaux : log monospace lisible, pas de glow derrière le contenu. */
body.dona-holo-v1 :is(.lg-list,.logs-feed,.log-feed,.logs-table,.log-list,.lg-entries,pre.dv-diff){background:rgba(2,6,15,.79)!important;border:1px solid rgba(122,149,208,.25)!important;border-radius:14px!important;color:#d2e5ff;}
/* Les modules secondaires restent visibles au-dessus des effets de fond. */
body.dona-holo-v1 :is(dialog,.av-image-dialog,.camera-popup,#alfredTimersDock,#alfredTimerToast,.av-more-dialog,.audio-popover){z-index:100;}
@media(max-width:1050px){#content .dh-response{grid-template-columns:1fr;}#content .dh-primary{min-height:220px}#content .dh-facts{grid-auto-rows:minmax(125px,auto);}body.dona-holo-v1.alfred-glass-theme #stage{align-items:flex-start!important;}}
@media(max-width:700px){body.dona-holo-v1.alfred-glass-theme #stage{top:85px!important;left:8px!important;right:8px!important;bottom:8px!important;padding:80px 12px 18px!important;}#content .dh-response{display:block;}#content .dh-primary{padding:20px;margin-bottom:13px;}#content .dh-facts{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;}#content .dh-fact{padding:14px;min-height:130px;}#content .dh-full{margin-top:12px;}.alfred-glass-theme #stage .av-reponse-actions{top:12px!important;right:12px!important;}}
@media(prefers-reduced-motion:reduce){body.dona-holo-v1::before{animation:none}body.dona-holo-v1 .av-stat-card{transition:none}}
/* Finitions du panneau et affichage sans métriques */
#content .dh-response{text-align:left;}
#content .dh-response.dh-no-facts{grid-template-columns:minmax(0,900px);justify-content:center;}
#content .dh-more{font-family:inherit;font-size:13px;font-weight:600;line-height:1.3;}
body.dona-holo-v1, #content .dh-response{font-family:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;}
/* Fenêtres secondaires de l'accueil (listes, ComfyUI, caméra) dans la même palette. */
body.dona-holo-v1 :is(#alfredListsGlassDialog,.av-image-dialog,.av-comfy-dialog,.av-more-dialog,.av-popup,.camera-popup,.audio-popover){
background:linear-gradient(145deg,rgba(4,10,24,.97),rgba(9,19,41,.96))!important;
border:1px solid rgba(142,176,242,.38)!important;border-radius:22px!important;
box-shadow:0 32px 90px #000a,0 0 26px #4f7fff18!important;color:#f4f7ff!important;
backdrop-filter:blur(22px)!important;}
body.dona-holo-v1 :is(.al-list-section,.al-item){background:rgba(14,25,48,.75)!important;border-color:#8aa9e34a!important;}
body.dona-holo-v1 :is(.al-add-form button,.al-footer button){background:linear-gradient(120deg,#254a9f,#4f56bb)!important;border-color:#8baffb77!important;}
@@ -0,0 +1,45 @@
(() => {
'use strict';
const $=id=>document.getElementById(id);
let csrf='',selected=null,latest=null, busy=false;
const message=(txt,ok=false,id='dvMessage')=>{let e=$(id);e.textContent=txt;e.classList.toggle('ok',ok);};
async function fetchJson(url,options={}){
const r=await fetch(url,{credentials:'same-origin',cache:'no-store',...options});
const d=await r.json().catch(()=>({}));
if(!r.ok)throw Error(d.error || 'Erreur HTTP '+r.status);
return d;
}
async function auth(){
const d=await fetchJson('/api/control/auth');csrf=d.csrf||'';
$('dvLoginBox').hidden=!!d.authenticated;$('dvApp').hidden=!d.authenticated;
if(d.authenticated)await refresh();
}
async function getName(){try{const r=await fetchJson('/api/alfred/identity/name');let n=r.name||'Assistant';$('dvName').textContent=n;$('dvInitial').textContent=Array.from(n)[0]?.toLocaleUpperCase('fr-FR')||'A';document.title='Maintenance · '+n;}catch(_){}}
function renderTasks(tasks){$('dvCount').textContent=tasks.length+' propositions';const host=$('dvTasks');host.replaceChildren();
if(!tasks.length){let p=document.createElement('p');p.className='dv-muted';p.textContent='Aucune proposition pour le moment.';host.append(p);return;}
for(const t of tasks){let btn=document.createElement('button');btn.type='button';btn.className='dv-task'+(selected===t.id?' selected':'');
let strong=document.createElement('strong');strong.textContent=t.title;let small=document.createElement('small');small.textContent=t.id+' · '+t.status+' · '+t.changed.length+' fichier(s)';btn.append(strong,small);btn.addEventListener('click',()=>select(t.id));host.append(btn);}
}
async function refresh(){const r=await fetchJson('/api/control/developer/tasks');renderTasks(r.tasks);if(selected)await select(selected,false);}
async function select(id,again=true){selected=id;const d=await fetchJson('/api/control/developer/tasks/'+id);latest=d.task;
$('dvEmpty').hidden=true;$('dvSelected').hidden=false;$('dvTitle').textContent=latest.title;
$('dvState').textContent=latest.status;$('dvMeta').textContent='Proposition '+id+' · '+latest.changed.length+' fichier(s) · '+latest.created_at;
$('dvDiff').textContent=latest.diff||'Aucun changement détecté.';
const c=$('dvChecks');c.replaceChildren();for(const t of latest.checks||[]){let div=document.createElement('div');div.className='dv-check'+(t.ok?'':' fail');div.textContent=(t.ok?'✓ ':'✗ ')+t.file+' — '+t.detail;c.append(div);}
if(!c.childNodes.length)c.textContent='Contrôles pas encore réalisés.';
$('dvActions').hidden=latest.status!=='pending';$('dvNext').hidden=latest.status!=='approved';
$('dvCommand').textContent='sudo python3 /opt/alfred/app/tools/alfred_deploy.py deploy '+id+' --apply';
if(again)await refresh();
}
async function decide(decision){if(!latest||busy)return;const desc=decision==='approve'?'approuver':'refuser';
if(!confirm('Confirmer : '+desc+' la proposition '+latest.id+' ?'))return;
busy=true;try{const id=latest.id;const d=await fetchJson('/api/control/developer/tasks/'+id+'/decision',{
method:'POST',headers:{'Content-Type':'application/json','X-Alfred-CSRF':csrf},body:JSON.stringify({decision,diff_sha256:latest.diff_sha256})
});message('Décision enregistrée : '+d.result.status,true);await select(id);}catch(e){message(e.message);}finally{busy=false;}
}
$('dvLogin').addEventListener('submit',async e=>{e.preventDefault();try{await fetchJson('/api/control/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:$('dvPassword').value})});$('dvPassword').value='';await auth();}catch(err){message(err.message,false,'dvLoginInfo');}});
$('dvRefresh').addEventListener('click',()=>refresh().catch(err=>message(err.message)));
$('dvApprove').addEventListener('click',()=>decide('approve'));
$('dvReject').addEventListener('click',()=>decide('reject'));
getName();auth().catch(e=>{$('dvLoginBox').hidden=false;message(e.message,false,'dvLoginInfo');});
})();
@@ -0,0 +1,111 @@
/* ALFRED_HERMES_NEW_SESSION_V1 — reset sûr de la seule session conversationnelle Hermes. */
(() => {
'use strict';
function setup() {
const grid = document.querySelector('#avMoreDialog .av-more-grid');
if (!grid || document.getElementById('avHermesNewSession')) return false;
const button = document.createElement('button');
button.type = 'button';
button.id = 'avHermesNewSession';
button.className = 'av-hermes-new-session';
button.innerHTML = '<svg viewBox="0 0 24 24" class="a-icon" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 3v6h6"/><path d="M12 7v5l4 2"/></svg><span>Nouvelle session Hermes</span>';
grid.appendChild(button);
const dialog = document.createElement('dialog');
dialog.id = 'avHermesSessionDialog';
dialog.className = 'av-hermes-session-dialog';
dialog.setAttribute('aria-labelledby', 'avHermesSessionTitle');
dialog.innerHTML = `
<form method="dialog" class="av-hermes-session-content">
<h2 id="avHermesSessionTitle">Nouvelle session Hermes</h2>
<p>Repartir sans le contexte de l’ancienne conversation. La prochaine demande utilisera une nouvelle session avec les outils Hermes disponibles. La session actuelle sera sauvegardée.</p>
<p class="av-hermes-session-note">Les outils, les Skills et les réglages de Dona restent inchangés. Aucune tâche Hermes n'est annulée.</p>
<p id="avHermesSessionMessage" class="av-hermes-session-message" role="status" aria-live="polite"></p>
<div class="av-hermes-session-actions">
<button type="button" id="avHermesSessionCancel" class="av-hermes-session-secondary">Annuler</button>
<button type="button" id="avHermesSessionConfirm" class="av-hermes-session-primary">Créer une nouvelle session</button>
</div>
</form>`;
document.body.appendChild(dialog);
const msg = dialog.querySelector('#avHermesSessionMessage');
const confirm = dialog.querySelector('#avHermesSessionConfirm');
const cancel = dialog.querySelector('#avHermesSessionCancel');
let csrf = '';
let busy = false;
function setMessage(text, error = false) {
msg.textContent = text;
msg.classList.toggle('is-error', error);
}
button.addEventListener('click', async () => {
document.getElementById('avMoreDialog')?.close();
setMessage('');
confirm.hidden = false;
cancel.textContent = 'Annuler';
dialog.showModal();
confirm.disabled = true;
try {
const response = await fetch('/api/control/auth', {credentials:'same-origin',cache:'no-store'});
if (!response.ok) throw new Error('Vérification administrateur impossible.');
const auth = await response.json();
if (!auth.authenticated || !auth.csrf) {
setMessage('Connecte-toi d’abord en administrateur dans Configuration (/config), puis reviens ici.', true);
confirm.hidden = true;
cancel.textContent = 'Fermer';
const link = document.createElement('a');
link.href = '/config';
link.textContent = 'Ouvrir Configuration';
link.className = 'av-hermes-session-login';
const old = dialog.querySelector('.av-hermes-session-login');
old?.remove();
msg.after(link);
} else {
dialog.querySelector('.av-hermes-session-login')?.remove();
csrf = auth.csrf;
confirm.disabled = false;
}
} catch (e) {
setMessage(e.message, true);
}
});
cancel.addEventListener('click', () => { if (!busy) dialog.close(); });
confirm.addEventListener('click', async () => {
if (busy || !csrf) return;
busy = true;
confirm.disabled = true;
cancel.disabled = true;
setMessage('Création de la nouvelle session…');
try {
const response = await fetch('/api/control/hermes/new-session', {
method:'POST', credentials:'same-origin', cache:'no-store',
headers: {'X-Alfred-CSRF':csrf,'Content-Type':'application/json'},
body:'{}'
});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || 'Erreur HTTP '+response.status);
setMessage(data.message || 'Nouvelle session prête.');
confirm.hidden = true;
cancel.textContent = 'Fermer';
} catch (e) {
setMessage(e.message, true);
} finally {
busy = false;
cancel.disabled = false;
confirm.disabled = false;
}
});
return true;
}
function init() {
if (setup()) return;
const observer = new MutationObserver(() => { if (setup()) observer.disconnect(); });
observer.observe(document.body,{childList:true,subtree:true});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, {once:true});
else init();
})();
@@ -0,0 +1,15 @@
/* Add a maintenance link without changing any existing dashboard handler. */
(() => {
'use strict';
function run(){
const grid=document.querySelector('#avMoreDialog .av-more-grid');
if(grid&&!grid.querySelector('a[href="/maintenance"]')){
const link=document.createElement('a');link.href='/maintenance';link.textContent='Maintenance & développement';grid.append(link);
}
for(const nav of document.querySelectorAll('header nav')){
if(nav.querySelector('a[href="/maintenance"]'))continue;
const a=document.createElement('a');a.href='/maintenance';a.textContent='Maintenance';nav.append(a);
}
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',run,{once:true});else run();
})();
@@ -0,0 +1,51 @@
/* ALFRED_REPONSE_CLOSE_V1 — Boutons visibles dans la scène, sans modifier Hermes. */
(() => {
'use strict';
const stage = document.getElementById('stage');
const main = document.getElementById('alfredMainV1');
const returnButton = document.getElementById('avBack');
if (!stage || !main || !returnButton) {
console.warn('[Dona] Fermeture réponse : éléments Glass V2 manquants.');
return;
}
if (stage.dataset.alfredReplyCloseV1 === 'ready') return;
stage.dataset.alfredReplyCloseV1 = 'ready';
// La V1 crée le bouton « Retour à l’accueil » DANS #alfredMainV1 (z-index 1),
// alors que la scène #stage est un frère au-dessus (z-index 8).
// On déplace le bouton D'ORIGINE : son écouteur de clic et son id restent intacts.
const bar = document.createElement('div');
bar.className = 'av-reponse-actions';
bar.setAttribute('role', 'group');
bar.setAttribute('aria-label', 'Navigation dans la réponse');
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'av-reponse-close';
closeButton.setAttribute('aria-label', 'Fermer la réponse et revenir à l’accueil');
closeButton.title = 'Fermer la réponse';
closeButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 5l14 14M19 5 5 19"/></svg>';
returnButton.classList.add('av-reponse-home');
returnButton.removeAttribute('style');
returnButton.title = 'Fermer cette réponse et revenir à l’accueil';
bar.append(returnButton, closeButton);
stage.insertBefore(bar, stage.firstChild);
const goHome = () => {
// Utilise le gestionnaire déjà attaché à #avBack par Glass V1 :
// focusScene(false) + éventuel AlfredViews.goHome().
returnButton.click();
};
closeButton.addEventListener('click', goHome);
document.addEventListener('keydown', event => {
if (event.key !== 'Escape' || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
// Les boîtes de dialogue (caméra, ComfyUI, etc.) gardent leur propre Escape.
if (document.querySelector('dialog[open], [role="dialog"][aria-modal="true"]')) return;
if (main.classList.contains('is-focused')) {
event.preventDefault();
goHome();
}
});
})();
@@ -0,0 +1,28 @@
/* ALFRED_STT_INLINE_GLASS_V1 — deplacement de la transcription existante, sans dupliquer les elements. */
(() => {
'use strict';
function mount() {
const hero = document.querySelector('#alfredMainV1 .av-voice-hero');
const panel = document.querySelector('.transcript-panel');
if (!hero || !panel) return false;
if (panel.classList.contains('av-stt-inline')) return true;
// alfred.js et les autres extensions gardent les memes noeuds et les memes IDs.
// Les mises a jour Vosk/Whisper restent donc fonctionnelles sans nouveau listener.
hero.insertBefore(panel, hero.firstChild);
panel.classList.add('av-stt-inline');
hero.classList.add('av-stt-inline-on');
panel.setAttribute('aria-label', 'Transcription vocale en direct');
// Taille du canvas recalculee par le gestionnaire actuel en cas de resize.
window.requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
return true;
}
if (!mount()) {
// Les scripts Glass sont generalement synchrones, mais on gere un chargement differe.
let count = 0;
const interval = window.setInterval(() => {
if (mount() || ++count >= 16) window.clearInterval(interval);
}, 300);
}
})();
+140
View File
@@ -0,0 +1,140 @@
/* DONA_HOLOGRAPHIC_V1
* Cartes contextuelles issues du TEXTE réellement reçu d'Hermes.
* Aucune donnée, état de serveur, distance ou durée n'est calculé/inventé.
* Les autres vues (images, calendriers, cartes de renderer) restent inchangées.
*/
(() => {
'use strict';
const words = Object.freeze({zero:0,un:1,une:1,deux:2,trois:3,quatre:4,cinq:5,six:6,sept:7,huit:8,neuf:9,dix:10,onze:11,douze:12,treize:13,quatorze:14,quinze:15,seize:16,dixsept:17,dixhuit:18,dixneuf:19,vingt:20});
function numberValue(s){
if(!s)return null;
const normalized=String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[\s-]/g,'');
if(Object.prototype.hasOwnProperty.call(words,normalized))return String(words[normalized]);
const str=String(s).trim();return /^\d{1,5}$/.test(str)?str:null;
}
const number='(\\d{1,5}|vingt|dix[- ]?neuf|dix[- ]?huit|dix[- ]?sept|seize|quinze|quatorze|treize|douze|onze|dix|neuf|huit|sept|six|cinq|quatre|trois|deux|une|un)';
function getFacts(input) {
const text=String(input||'').replace(/\u00a0/g,' ').slice(0,16000);
const facts=[];const seen=new Set();
function add(key,icon,label,value,note='Mentionné dans la réponse'){
if(!value||seen.has(key)||facts.length>=4)return;
facts.push({key,icon,label,value:String(value),note});seen.add(key);
}
let m;
m=text.match(new RegExp('\\b'+number+'\\s+(?:machines?\\s+virtuelles?|VMs?\\b)','i'));
if(m)add('vm','⬡','Machines virtuelles',numberValue(m[1]),'Chiffre annoncé par Hermes');
m=text.match(new RegExp('\\b'+number+'\\s+(?:n[œo]uds?|nodes?)(?:\\s|\\.|,|$)','i'));
if(m)add('nodes','▦','Nœuds mentionnés',numberValue(m[1]),'Chiffre annoncé par Hermes');
m=text.match(/\b(\d{1,3})\s*\/\s*(\d{1,3})\s*(?:n[œo]uds|serveurs|nodes?)\b/i);
if(m)add('online','●','Nœuds disponibles',m[1]+' / '+m[2],'Valeur citée par Hermes');
const route=/\b(trajet|distance|route|entre\s+\S+\s+et\s+\S+|autoroute|en voiture)\b/i.test(text);
const building=/\b(hauteur|haut(?:e|s)?|b[aâ]timent|immeuble|tour|monument|construction)\b/i.test(text);
if(route){
m=text.match(/\b(\d{1,5}(?:[,.]\d+)?)\s*(kilom[eè]tres?|km)\b/i);
if(m)add('distance','↗','Distance indiquée',m[1].replace(',', '.')+' km','Distance issue de la réponse');
m=text.match(/\b(\d{1,3})\s*(?:à|a|–|-)\s*(\d{1,3})\s*(minutes?|min|heures?|h)\b/i);
if(m)add('duration','◷','Durée annoncée',m[1]+' à '+m[2]+' '+m[3],'Estimation communiquée par Hermes');
else {
m=text.match(/\b(\d{1,3})\s*(minutes?|min|heures?|h)\b/i);
if(m)add('duration','◷','Durée annoncée',m[1]+' '+m[2],'Estimation communiquée par Hermes');
}
}
if(building&&!route){
m=text.match(/\b(\d{1,5}(?:[,.]\d+)?)\s*(?:m[eè]tres?|m)\b/i);
if(m)add('height','♜','Hauteur indiquée',m[1]+' m','Valeur issue de la réponse');
}
if(/\b(temp[eé]rature|m[eé]t[eé]o|degr[eé]s)\b/i.test(text)){
m=text.match(/(-?\d{1,3}(?:[,.]\d+)?)\s*°\s*C\b/i);
if(m)add('temp','☀','Température citée',m[1]+' °C','Valeur issue de la réponse');
}
// Ne jamais induire qu'un nœud est en ligne à partir du seul nom.
if(facts.length<4){
m=text.match(/[«"']\s*([\wÀ-ÿ][\wÀ-ÿ_.-]{1,39})\s*[»"'][^.!?\n]{0,90}(arr[eê]t[eé]e?|[eé]teint(?:e)?)(?=[\s,.!?]|$)/i);
if(m)add('stopped','⏻','Équipement signalé arrêté',m[1],'État décrit dans la réponse');
}
if(facts.length===0){
m=text.match(/\b(?:il est(?: actuellement)?|heure(?: locale)?\s*[::])\s*(\d{1,2}[:h]\d{2})\b/i);
if(m)add('time','◷','Heure indiquée',m[1].replace('h',':'),'Issue de la réponse, non vérifiée ici');
}
const names=[];
const re=/(?:n[œo]ud|node)\s*['"«]?([\wÀ-ÿ_.-]{2,35})['"»]?\s*:/gi;
while((m=re.exec(text))&&names.length<10){if(!names.includes(m[1])) names.push(m[1]);}
return {facts,names};
}
function summaryOf(text,title){
const clean=String(text||'').trim();const t=String(title||'').trim();
const first=clean.split(/(?<=[.!?])\s+|\n{2,}/)[0]?.trim()||clean;
const headline=t && !/^(r[eé]ponse|hermes|texte)$/i.test(t)?t:first;
const truncated=headline.length>170?headline.slice(0,167).replace(/\s+\S*$/,'')+'…':headline;
let excerpt=clean;
if(clean.startsWith(first)&&first.length>35)excerpt=clean.slice(first.length).replace(/^\s+/,'');
if(!excerpt)excerpt=clean;
if(excerpt.length>290)excerpt=excerpt.slice(0,287).replace(/\s+\S*$/,'')+'…';
return {headline:truncated,excerpt};
}
if(typeof module==='object'&&module.exports){module.exports={getFacts,summaryOf,numberValue};}
if(typeof document==='undefined')return;
const body=document.body;
if(!body||body.dataset.donaHoloV1==='active')return;
body.dataset.donaHoloV1='active';body.classList.add('dona-holo-v1');
const main=document.getElementById('alfredMainV1');
const content=document.getElementById('content');
if(!main||!content)return; // Pages secondaires : seulement le thème transversal.
function el(tag,className,txt){const node=document.createElement(tag);if(className)node.className=className;if(txt!==undefined)node.textContent=String(txt);return node;}
let busy=false;let original='';
function renderCards(){
if(busy || content.firstElementChild?.classList.contains('dh-response'))return;
if(content.querySelector('.cards, img.hero, video, canvas, iframe'))return;
// N'afficher les cartes que pour une réponse réellement ouverte par Alfred.
if(!main.classList.contains('is-focused'))return;
const children=[...content.children];
if(!children.length)return;
const text=children.filter(n=>n.matches('h1,h2,p,pre')).map(n=>n.textContent.trim()).filter(Boolean).join('\n\n').trim();
if(!text || text.length<13)return;
if(/^(bonjour\.|à votre écoute\.|en attente|chargement)/i.test(text))return;
if(text===original && content.dataset.holoTransformed==='done')return;
const title=content.querySelector('h1,h2')?.textContent||'';
const raw=(title && text.startsWith(title+'\n\n'))?text.slice(title.length+2):text;
const {facts,names}=getFacts(text);
const summary=summaryOf(raw,title);
const wrap=el('section','dh-response');
wrap.setAttribute('aria-label','Synthèse contextuelle de la réponse Hermes');
const primary=el('article','dh-primary');
primary.append(el('div','dh-kicker','RÉPONSE · DONA'),el('div','dh-headline',summary.headline));
if(summary.excerpt && summary.excerpt!==summary.headline)primary.append(el('div','dh-excerpt',summary.excerpt));
else primary.append(el('div','dh-excerpt','Réponse disponible dans son intégralité.'));
const more=el('button','dh-more');more.type='button';more.setAttribute('aria-expanded','false');more.append(el('span','', '▤ Afficher la réponse complète'),el('span','','›'));
primary.append(more);wrap.append(primary);
if(facts.length||names.length){
const grid=el('div','dh-facts');
facts.forEach(f=>{
const c=el('article','dh-fact');
c.append(el('span','dh-fact-icon',f.icon),el('div','dh-fact-label',f.label),el('strong','dh-fact-value',f.value),el('div','dh-fact-note',f.note));
grid.append(c);
});
if(names.length && grid.children.length<4){
const c=el('article','dh-fact');c.append(el('span','dh-fact-icon','⬡'),el('div','dh-fact-label','Nœuds cités'));
const tags=el('div','dh-tags');names.forEach(n=>tags.append(el('span','dh-tag',n)));c.append(tags,el('div','dh-fact-note','Noms présents dans la réponse'));grid.append(c);
}
wrap.append(grid);
} else {
wrap.classList.add('dh-no-facts');
}
const full=el('section','dh-full');full.hidden=true;full.id='dhFullReply';
full.append(el('h3','', 'Réponse complète d’Hermes'),el('div','dh-full-text',text),el('p','dh-source','Affichage fidèle au texte reçu. Les faits n’ont pas été vérifiés séparément.'));
more.setAttribute('aria-controls','dhFullReply');
more.addEventListener('click',()=>{
const opened=full.hidden;full.hidden=!opened;more.setAttribute('aria-expanded',String(opened));
more.firstElementChild.textContent=opened?'▤ Masquer la réponse complète':'▤ Afficher la réponse complète';
more.lastElementChild.textContent=opened?'⌃':'›';
});
wrap.append(full);
busy=true;content.replaceChildren(wrap);content.dataset.holoTransformed='done';original=text;busy=false;
}
const obs=new MutationObserver(()=>{if(!busy)renderCards();});
obs.observe(content,{childList:true});
// Accès à l'écran de réponse après mise à jour de la classe focus, sans boucle.
main.addEventListener('click',()=>setTimeout(renderCards,0));
// Le renderer/socket réactualise #content : la mutation constitue l'événement canonique.
setTimeout(renderCards,100);
})();
@@ -6,6 +6,8 @@
<title>Identité · Assistant</title>
<link rel="stylesheet" href="/static/css/alfred-identity-glass.css?v=1">
<script src="/static/js/alfred-identity-glass.js?v=1" defer></script>
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
</head>
<body>
<header class="id-head">
@@ -37,5 +39,8 @@
</div>
</section>
</main>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
</body>
</html>
+5
View File
@@ -6,6 +6,8 @@
<title>Journaux · Alfred</title>
<link rel="stylesheet" href="/static/css/alfred-logs-v2.css?v=2">
<script src="/static/js/alfred-logs-v2.js?v=2" defer></script>
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
</head>
<body>
<header class="lg-header">
@@ -29,5 +31,8 @@
</main>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
</body>
</html>
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>Maintenance · Dona</title>
<link rel="stylesheet" href="/static/css/alfred-developer-glass.css?v=1">
<script defer src="/static/js/alfred-developer-glass.js?v=1"></script>
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
</head><body>
<header class="dv-head"><a class="dv-brand" href="/"><span class="dv-initial" id="dvInitial">A</span><span><strong id="dvName">Assistant</strong><small>Maintenance & développement</small></span></a>
<nav><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity">Identité</a><a href="/maintenance" class="active" aria-current="page">Maintenance</a></nav></header>
<main class="dv-main"><div class="dv-intro"><div><p class="dv-eyebrow">HERMES · DÉVELOPPEMENT CONTRÔLÉ</p><h1>Maintenance & modifications</h1><p>Hermes prépare le code dans une copie Git. Tu vérifies et approuves ici avant toute modification en production.</p></div><button id="dvRefresh" type="button">Actualiser</button></div>
<section class="dv-panel" id="dvLoginBox" hidden><h2>Accès administrateur</h2><p>Utilise le même mot de passe que Configuration.</p><form id="dvLogin"><input type="password" id="dvPassword" autocomplete="current-password" placeholder="Mot de passe administrateur" required><button type="submit">Connexion</button></form><p id="dvLoginInfo" role="alert"></p></section>
<div id="dvApp" class="dv-layout" hidden>
<section class="dv-panel dv-list"><div class="dv-section-title"><h2>Propositions</h2><span id="dvCount">—</span></div><p class="dv-muted">Demande à Hermes : « Prépare une modification de ton interface ».</p><div id="dvTasks" role="list" class="dv-tasks"></div></section>
<section class="dv-panel dv-detail"><div class="dv-section-title"><h2>Détail & validation</h2><span id="dvState">Sélectionne une proposition</span></div>
<div id="dvEmpty" class="dv-empty">Les modifications apparaîtront ici lorsqu'Hermes aura créé une proposition.</div>
<div id="dvSelected" hidden><h3 id="dvTitle"></h3><p class="dv-muted" id="dvMeta"></p><h4>Contrôles</h4><div id="dvChecks" class="dv-checks"></div>
<h4>Diff des fichiers</h4><pre id="dvDiff" class="dv-diff" tabindex="0"></pre><p class="dv-muted">Le diff peut contenir du code sensible. Ne l'exporte pas publiquement.</p>
<div class="dv-actions" id="dvActions"><button class="dv-primary" type="button" id="dvApprove">Approuver la proposition</button><button type="button" id="dvReject">Refuser</button></div>
<div class="dv-next" id="dvNext" hidden><strong>Après approbation</strong><p>Le déploiement reste manuel : depuis SSH, lance la commande de déploiement avec l'identifiant affiché. Aucun bouton Web ne modifie le code de production.</p><code id="dvCommand"></code></div>
<p id="dvMessage" role="status" aria-live="polite"></p></div>
</section>
</div><p class="dv-footnote">Sécurité : les contrôles V1 sont statiques (syntaxe Python et JavaScript, HTML, CSS). Ils ne garantissent pas qu'une modification sera fonctionnelle. Déployer uniquement après revue.</p></main>
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
</body></html>
+40
View File
@@ -10,6 +10,8 @@
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
</head>
<body data-page="config">
<div class="admin-layout">
@@ -68,6 +70,41 @@
</div>
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
</section>
<!-- ALFRED_DUPLEX_BARGE_IN_V1 -->
<section class="panel" id="alfredDuplexPanel">
<div class="panel-title"><span class="panel-symbol">≋</span><div>
<h2>Interruption vocale (duplex)</h2>
<p>Couper la parole à l'assistant pendant la lecture Piper</p>
</div></div>
<div class="field"><label for="DUPLEX_MODE">Mode d'interruption</label>
<select id="DUPLEX_MODE" data-setting>
<option value="off">Désactivé (comportement actuel)</option>
<option value="wake">Avec le nom d'activation — recommandé</option>
<option value="natural">Naturelle sans le nom — expérimental</option>
</select>
<small>En mode « avec le nom », prononce le mot de réveil configuré (Dona, si c'est ton WAKE_WORD). En mode naturel, un début de parole distinct du TTS peut interrompre la réponse.</small>
</div>
<div class="field"><label for="DUPLEX_MIC_THRESHOLD">Seuil micro (sensibilité)</label>
<input type="number" id="DUPLEX_MIC_THRESHOLD" data-setting min="0.008" max="0.30" step="0.005" placeholder="0.025">
<small>Augmenter pour limiter les déclenchements intempestifs ; valeur initiale 0.025.</small>
</div>
<!-- ALFRED_DUPLEX_CONVERSATION_V2 -->
<div class="field switch-field"><label for="DUPLEX_STOP_COMMANDS">Commandes « Stop », « Silence », « Arrête »
<small>Coupe uniquement Piper, sans nom d’activation ni annulation des tâches Hermes.</small></label>
<input id="DUPLEX_STOP_COMMANDS" type="checkbox" data-setting></div>
<div class="field switch-field"><label for="DUPLEX_HANDOFF">Nouvelle question pendant la réponse
<small>Interromps Dona et continue avec une question sans devoir répéter le nom.</small></label>
<input id="DUPLEX_HANDOFF" type="checkbox" data-setting></div>
<div class="field switch-field"><label for="DUPLEX_CONVERSATION">Mode Conversation
<small>Après une réponse Piper, une courte fenêtre permet de poser une autre question.</small></label>
<input id="DUPLEX_CONVERSATION" type="checkbox" data-setting></div>
<div class="field"><label for="DUPLEX_CONVERSATION_SECONDS">Fenêtre de conversation (secondes)</label>
<input id="DUPLEX_CONVERSATION_SECONDS" type="number" min="2" max="15" step="1" data-setting>
<small>6 s recommandées. Pour fermer cette fenêtre, attends simplement quelques secondes.</small></div>
<p class="muted microtext">Anti-écho : confirmation acoustique, comparaison avec les paroles Piper et délai de garde. Ceci ne remplace pas un vrai AEC ; le mode naturel reste expérimental.</p>
<p class="muted" style="margin-top:12px">⚠ Le mode naturel exige idéalement un microphone avec annulation d'écho acoustique. Le simple filtrage logiciel ne remplace pas un AEC. Le retour à l'écoute prend effet après l'interruption ; attends que l'assistant soit prêt avant la nouvelle question. Les réglages nécessitent un redémarrage.</p>
</section>
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
<p role="status" aria-live="polite" class="message" id="message"></p>
</main>
@@ -76,5 +113,8 @@
<script src="/static/js/alfred-admin-glass.js?v=1"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
</body>
</html>
+3
View File
@@ -34,6 +34,8 @@
#status { min-height:1.3em; margin:18px 0 0; color:#a5a9b0; font-size:.86rem; }
a { color:#cdd2da; text-decoration:none; }
</style>
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
</head>
<body>
<main>
@@ -128,5 +130,6 @@
</script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
</body>
</html>
+14
View File
@@ -18,6 +18,14 @@
<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=1">
</head>
<body>
<main class="alfred-shell">
@@ -196,5 +204,11 @@
<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-holographic-v1.js?v=1"></script>
</body>
</html>
+5
View File
@@ -3,6 +3,8 @@
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
</head>
<body data-page="surveillance">
<div class="admin-layout">
@@ -46,4 +48,7 @@
</div><script src="/static/js/alfred-admin-glass.js?v=1"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
</body></html>
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Local administrator-only command-line deployment / rollback.
This CLI is NOT exposed via MCP or Flask.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT))
from services import alfred_developer_core as dev
def main():
p=argparse.ArgumentParser(description='Alfred Developer: déploiement local contrôlé')
p.add_argument('action',choices=['deploy','rollback'])
p.add_argument('id')
p.add_argument('--apply',action='store_true',help='Appliquer réellement ; sinon simulation')
args=p.parse_args()
if args.apply and os.geteuid()!=0:
p.error('Seul un administrateur root local peut appliquer un changement.')
try:
if args.action=='deploy':out=dev.deploy(args.id,apply=args.apply)
else:out=dev.rollback(args.id,apply=args.apply)
print(out)
if args.apply and out.get('requires_restart'):
print('Fichiers Python modifiés : redémarrer alfred.service puis vérifier la santé du service.')
elif args.apply:
print('Actualise ton navigateur (Ctrl+F5) ; vérifier l’interface avant de conserver le changement.')
return 0
except (ValueError,OSError,RuntimeError) as e:
print('OPÉRATION REFUSÉE :',e,file=sys.stderr);return 1
if __name__=='__main__':raise SystemExit(main())