v2 - new interface, many new page for fine tuning

This commit is contained in:
pporcheret
2026-10-28 21:45:40 +01:00
parent a6169bcfdb
commit a17c3cb614
89 changed files with 8691 additions and 13 deletions
@@ -0,0 +1,61 @@
{
"version": 1,
"created_at": "2026-10-28T16:25:44.119642",
"installed": true,
"files": {
"templates/config.html": {
"existed": true,
"before_sha256": "d6fd60c8034688019c4501869dfd1dd61aad31bfee6e020bbe0e00743ac964b1",
"after_sha256": "4e6300fcf4b0dafcfc1e196d219734bb35263572c316ad7d010b66d0f6915762",
"mode": 420
},
"templates/surveillance.html": {
"existed": true,
"before_sha256": "c603647ee11dddb771bf588b8469774a012c9aa0bf6af302859df80dfdf00add",
"after_sha256": "0bd5a9a17b613c6b02e53146f4ffeb92b8bb94c9e2979d460d2d96de4ecb96cb",
"mode": 420
},
"templates/index.html": {
"existed": true,
"before_sha256": "cc1fbc7cab0c80d7f2799ac565c45e94b8fddb0af26e67172afde8ffd39a6231",
"after_sha256": "73a4f976e301da24f0a59b8866be4f74e001931a641e0483d0b6d1cae133a971",
"mode": 420
},
"static/js/alfred-main-v1.js": {
"existed": true,
"before_sha256": "5552cc33cfb1c236cb474a75d305a6df8302ef70051c057f8e8175352d014fc2",
"after_sha256": "0bef31b1e156de0a11141f79895013bb498507d9aec6e5c18546660abdba1691",
"mode": 420
},
"app.py": {
"existed": true,
"before_sha256": "631ad0886437b59d4b8573ffd83bf2330511dfee92dc767baa5d42ac7d9c246e",
"after_sha256": "e4fe3af1db2e0d39231f0a3ae341818b2765a14c102f827dc7c6772bbdbc572b",
"mode": 420
},
"services/alfred_home_glass.py": {
"existed": false,
"before_sha256": null,
"after_sha256": "672c2c83eb40a74e41ae8c349301d474e7c3130b3db2459401a6150d9201f9cd",
"mode": 420
},
"static/css/alfred-admin-glass.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "2de6d0934aeb790fd8fce6287109ed52a63a6e16358ff8b837051b249adfaa9c",
"mode": 420
},
"static/js/alfred-admin-glass.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "73b6c7d4186e918bea83edb8308e47bbfe2ff90acd5d189f89d482dd5770223f",
"mode": 420
},
"static/js/alfred-maison-card.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "4eb6ba8c1c81145770328d66df34336a873151f1cf07ffd975886013730b617a",
"mode": 420
}
}
}
@@ -0,0 +1,498 @@
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
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
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,318 @@
/* ALFRED_MAIN_VISUAL_V1 — accueil vivant, conservation des contrôleurs existants. */
(() => {
'use strict';
if (document.getElementById('alfredMainV1')) return;
const shell = document.querySelector('.alfred-shell');
if (!shell || !document.getElementById('stage') || !document.getElementById('content')) return;
const I = {
home:'<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z"/>',
bulb:'<path d="M9 18h6m-5 3h4m-6-7c-1-1.2-2-2.5-2-5a6 6 0 0 1 12 0c0 2.5-1 3.8-2 5-.8 1-1 1.9-1 2H9c0-.1-.2-1-1-2z"/>',
server:'<rect x="3" y="3" width="18" height="8" rx="2"/><rect x="3" y="13" width="18" height="8" rx="2"/><path d="M7 7h.01M7 17h.01M12 7h6M12 17h6"/>',
brain:'<path d="M12 18V5a3 3 0 0 0-5-2 3 3 0 0 0-3 4 4 4 0 0 0 0 7 3.5 3.5 0 0 0 3.3 5A3.5 3.5 0 0 0 12 18m0-13a3 3 0 0 1 5-2 3 3 0 0 1 3 4 4 4 0 0 1 0 7 3.5 3.5 0 0 1-3.3 5A3.5 3.5 0 0 1 12 18"/><path d="m8 10 4 2 4-2"/>',
media:'<rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="8.5" cy="9" r="1.3"/><path d="m4 17 5-5 4 4 3-3 4 4"/>',
more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
mic:'<rect x="9" y="2.5" width="6" height="13" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4m-4 0h8"/>',
micOff:'<path d="M9 9v3a3 3 0 0 0 5.5 1.7M15 9V5.5a3 3 0 0 0-5.5-1.7M5 11a7 7 0 0 0 11.5 5.4M19 11a7 7 0 0 1-.5 2.6M12 18v4m-4 0h8M3 3l18 18"/>',
speaker:'<path d="M11 5 6 9H3v6h3l5 4V5ZM15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>',
speakerOff:'<path d="M11 5 6 9H3v6h3l5 4V5ZM16 9l5 6m0-6-5 6"/>',
camera:'<rect x="2" y="5" width="14" height="14" rx="3"/><path d="m16 10 6-3v10l-6-3"/>',
settings:'<path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"/><circle cx="12" cy="12" r="4"/>',
calendar:'<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 10h18"/>',
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
cloud:'<path d="M7 18h11a4 4 0 0 0 0-8h-1a6 6 0 0 0-11-1 4.5 4.5 0 0 0 1 9Z"/>',
shield:'<path d="m12 2 8 3v6c0 6-3.7 9-8 11-4.3-2-8-5-8-11V5z"/><path d="m9 12 2 2 4-4"/>',
arrow:'<path d="m9 5 7 7-7 7"/>',
chat:'<path d="M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-5 3v-3a2 2 0 0 1-1-2V7a2 2 0 0 1 2-2z"/>',
close:'<path d="M5 5l14 14M19 5 5 19"/>',
sun:'<circle cx="12" cy="12" r="4"/><path d="M12 1v3m0 16v3M1 12h3m16 0h3M4 4l2 2m12 12 2 2m0-16-2 2M6 18l-2 2"/>',
spark:'<path d="m12 2 2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z"/>',
};
const icon = (name, extra = '') => `<svg class="a-icon ${extra}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${I[name] || I.more}</svg>`;
const initialName = (document.title || 'Alfred').trim();
const dashboard = document.createElement('div');
dashboard.id = 'alfredMainV1';
dashboard.className = 'alfred-main-v1';
dashboard.innerHTML = `
<header class="av-head">
<div class="av-brand"><div class="av-logo">A</div><div><strong id="avName"></strong><small>Assistant vocal</small></div></div>
<div class="av-header-right"><div class="av-time"><strong id="avTime">--:--</strong><span id="avDate">—</span></div>
<div class="av-head-actions">
<button type="button" class="av-round" id="avTopCamera" aria-label="Ouvrir la caméra" title="Caméra">${icon('camera')}</button>
<button type="button" class="av-round" id="avTopMic" aria-label="Couper le microphone" aria-pressed="false" title="Couper le micro">${icon('mic')}</button>
<button type="button" class="av-round" id="avTopSpeaker" aria-label="Couper la sortie audio" aria-pressed="false" title="Couper le son">${icon('speaker')}</button>
<button type="button" class="av-round" id="avTopSettings" aria-label="Réglages audio" title="Réglages audio">${icon('settings')}</button>
<a class="av-round" href="/config" aria-label="Configuration d'Alfred" title="Configuration">${icon('more')}</a>
</div>
</div>
</header>
<nav class="av-rail" aria-label="Navigation principale">
<button type="button" class="av-rail-link selected" data-av-action="home">${icon('home')}<span>Accueil</span></button>
<button type="button" class="av-rail-link" data-av-action="house">${icon('bulb')}<span>Maison</span></button>
<a class="av-rail-link" href="/surveillance">${icon('server')}<span>Système</span></a>
<a class="av-rail-link" href="/surveillance">${icon('brain')}<span>IA</span></a>
<button type="button" class="av-rail-link" data-av-action="media">${icon('media')}<span>Médias</span></button>
<button type="button" class="av-rail-link" data-av-action="more">${icon('more')}<span>Plus</span></button>
</nav>
<section class="av-summary" aria-label="État de la maison et des services">
<button class="av-stat-card" type="button" data-av-action="house"><div class="av-card-top"><span class="av-circle home">${icon('home')}</span><span><strong>Maison</strong><small id="avHomeSummary">Chargement…</small></span>${icon('arrow','av-chevron')}</div><div class="av-lines"><p>${icon('bulb')}<span id="avLights">Lumières : —</span></p><p>${icon('home')}<span id="avCovers">Volets : —</span></p><p>${icon('sun')}<span id="avIndoor">Température : —</span></p></div></button>
<a class="av-stat-card" href="/surveillance"><div class="av-card-top"><span class="av-circle system">${icon('server')}</span><span><strong>Système</strong><small>Mini-PC Alfred</small></span>${icon('arrow','av-chevron')}</div><div class="av-lines"><p><span class="av-mini-dot"></span><span id="avLoad">Charge : —</span></p><p><span class="av-mini-dot"></span><span id="avMemory">Mémoire : —</span></p><p><span class="av-mini-dot"></span><span id="avDisk">Stockage : —</span></p></div></a>
<a class="av-stat-card" href="/surveillance"><div class="av-card-top"><span class="av-circle ai">${icon('brain')}</span><span><strong>Services IA</strong><small id="avAiSummary">Statuts en cours…</small></span>${icon('arrow','av-chevron')}</div><div class="av-lines"><p><span class="av-mini-dot" id="avOllamaDot"></span><span id="avOllama">Ollama : —</span></p><p><span class="av-mini-dot" id="avHermesDot"></span><span id="avHermes">Hermes : —</span></p><p><span class="av-mini-dot" id="avComfyDot"></span><span id="avComfy">ComfyUI : —</span></p></div></a>
<button class="av-stat-card av-weather" type="button" data-av-action="weather"><div class="av-card-top"><span class="av-circle weather">${icon('cloud')}</span><span><strong>Météo</strong><small>Home Assistant</small></span>${icon('arrow','av-chevron')}</div><div class="av-weather-body"><span class="av-weather-icon" id="avWeatherIcon">☁️</span><div><strong id="avWeatherTemp">—</strong><span id="avWeatherCondition">Chargement…</span><small id="avWeatherRange"></small></div></div></button>
</section>
<section class="av-voice-hero" aria-label="Commande vocale Alfred">
<div class="av-voice-controls">
<button type="button" id="avMicButton" class="av-sound-control" aria-pressed="false">${icon('mic')}<strong>Micro</strong><small id="avMicLabel">Actif</small></button>
<div class="av-voice-center"><div class="av-mic-orb" id="avOrb">${icon('mic')}</div><strong id="avVoiceTitle">En veille</strong><p id="avVoiceSubtitle">Dites « Alfred » puis votre demande</p><span class="av-status-line"><span class="av-status-dot" id="avStatusDot"></span><span id="avStatusText">Prêt à vous écouter</span></span></div>
<button type="button" id="avSpeakerButton" class="av-sound-control" aria-pressed="false">${icon('speaker')}<strong>Sortie audio</strong><small id="avSpeakerLabel">Activée</small></button>
</div>
<div class="av-wave-host" id="avWaveHost"></div>
</section>
<div class="av-shortcuts" role="group" aria-label="Raccourcis d'Alfred">
<button type="button" data-av-action="time">${icon('clock')}<span>Quelle heure est-il ?</span></button>
<button type="button" data-av-action="weather">${icon('cloud')}<span>Météo</span></button>
<button type="button" data-av-action="house">${icon('home')}<span>État de la maison</span></button>
<button type="button" data-av-action="proxmox">${icon('server')}<span>Vérifier Proxmox</span></button>
<button type="button" data-av-action="wazuh">${icon('shield')}<span>Alertes Wazuh</span></button>
<button type="button" data-av-action="comfyui">${icon('media')}<span>Créer une image</span></button>
<button type="button" data-av-action="more">${icon('more')}<span>Plus d'outils</span></button>
</div>
<footer class="av-history"><span class="av-history-heading">${icon('chat')} <strong>Interactions récentes</strong></span><div id="avHistoryList" aria-live="polite"><span class="av-history-empty">Aucune interaction depuis l’ouverture de cette page</span></div></footer>
<button type="button" class="av-back" id="avBack" hidden>${icon('close')} Retour à l’accueil</button>
<div class="av-toast" id="avToast" role="status" aria-live="polite" hidden></div>
<dialog class="av-image-dialog" id="avComfyDialog"><form method="dialog"><button type="submit" class="av-dialog-close" aria-label="Fermer">${icon('close')}</button></form><h2>Créer une image avec ComfyUI</h2><p>Décris l’image à générer. Alfred transmettra ta demande à Hermes.</p><label for="avComfyPrompt">Description</label><textarea id="avComfyPrompt" rows="4" maxlength="450" placeholder="Une maison futuriste au coucher du soleil…"></textarea><div class="av-dialog-actions"><button id="avComfySubmit" type="button">Envoyer à Alfred</button></div></dialog>
<dialog class="av-image-dialog" id="avMoreDialog"><form method="dialog"><button type="submit" class="av-dialog-close" aria-label="Fermer">${icon('close')}</button></form><h2>Outils Alfred</h2><div class="av-more-grid"><a href="/config">${icon('settings')} Configuration</a><a href="/surveillance">${icon('server')} Surveillance</a><button type="button" data-av-action="calendar">${icon('calendar')} Agenda</button><button type="button" data-av-action="camera">${icon('camera')} Caméra</button><button type="button" data-av-action="audio">${icon('speaker')} Réglages audio</button></div></dialog>
`;
shell.insertBefore(dashboard, shell.firstChild);
document.body.classList.add('alfred-glass-theme');
document.getElementById('avName').textContent = initialName;
const wave = document.getElementById('wave');
document.getElementById('avWaveHost').appendChild(wave);
window.dispatchEvent(new Event('resize'));
const $ = id => document.getElementById(id);
const timeZone = 'Europe/Paris';
let lastStatus = 'idle';
let mute = {mic_muted:false,speaker_muted:false};
let refreshingAudio = false;
let toastTimer;
function toast(message) {
const el = $('avToast'); el.textContent = String(message); el.hidden = false;
clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 5500);
}
function clock() {
const now = new Date();
$('avTime').textContent = new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit'}).format(now);
const date = new Intl.DateTimeFormat('fr-FR',{timeZone,weekday:'long',day:'numeric',month:'long',year:'numeric'}).format(now);
$('avDate').textContent = date.charAt(0).toUpperCase() + date.slice(1);
}
clock(); setInterval(clock, 15000);
function updateAudio(a) {
mute = {...mute,...a};
const mic = Boolean(mute.mic_muted), speaker = Boolean(mute.speaker_muted);
for (const id of ['avTopMic','avMicButton']) {
const button = $(id); button.setAttribute('aria-pressed',String(mic));
button.setAttribute('aria-label', mic ? 'Réactiver le microphone' : 'Couper le microphone');
button.title = button.getAttribute('aria-label');
button.querySelector('svg').outerHTML = icon(mic?'micOff':'mic');
}
for (const id of ['avTopSpeaker','avSpeakerButton']) {
const button = $(id); button.setAttribute('aria-pressed',String(speaker));
button.setAttribute('aria-label', speaker?'Réactiver la sortie audio':'Couper la sortie audio');
button.title = button.getAttribute('aria-label');
button.querySelector('svg').outerHTML = icon(speaker?'speakerOff':'speaker');
}
$('avMicLabel').textContent = mic?'Coupé':'Actif';
$('avSpeakerLabel').textContent = speaker?'Coupée':'Activée';
dashboard.classList.toggle('av-mic-is-muted',mic);
dashboard.classList.toggle('av-speaker-is-muted',speaker);
renderMode(lastStatus);
}
async function refreshAudio() {
if (refreshingAudio) return;
refreshingAudio = true;
try { const r=await fetch('/api/audio/settings',{cache:'no-store'});
if (!r.ok) throw Error('HTTP '+r.status);
updateAudio(await r.json());
} catch (e) { console.warn('[ALFRED UI] Réglages audio :',e); }
finally {refreshingAudio=false;}
}
async function toggleAudio(kind) {
const prop = kind==='mic'?'mic_muted':'speaker_muted';
try {
const r=await fetch('/api/audio/settings',{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({[prop]:!mute[prop]})});
const data=await r.json();
if (!r.ok) throw Error(data.error || 'HTTP '+r.status);
updateAudio(data);
toast(kind==='mic' ? (data.mic_muted?'Microphone coupé':'Microphone réactivé') : (data.speaker_muted?'Sortie audio coupée':'Sortie audio réactivée'));
} catch (e) { toast('Impossible de modifier le son : '+e.message); }
}
for (const id of ['avTopMic','avMicButton']) $(id).addEventListener('click',()=>toggleAudio('mic'));
for (const id of ['avTopSpeaker','avSpeakerButton']) $(id).addEventListener('click',()=>toggleAudio('speaker'));
$('avTopSettings').addEventListener('click',()=>{const btn=$('audioSettingsButton'); if(btn)btn.click();else location.assign('/config');});
refreshAudio(); setInterval(()=>{if(!document.hidden)refreshAudio();},30000);
function serviceText(state) {return state==='ok'?'Connecté':state==='offline'?'Hors ligne':state==='installed'?'Installé (non testé)':state==='unconfigured'?'Non configuré':'Non vérifié';}
function setService(id,dotid,label,state) {
$(id).textContent = `${label} : ${serviceText(state)}`;
$(dotid).className = `av-mini-dot ${state==='ok'?'is-ok':state==='offline'?'is-error':''}`;
}
async function refreshDashboard() {
try {
const r=await fetch('/api/alfred/dashboard',{cache:'no-store'});
if(!r.ok) throw Error('HTTP '+r.status);
const d=await r.json();
const h=d.home||{}, s=d.system||{}, ai=d.services||{}, w=d.weather||{};
$('avHomeSummary').textContent=h.available?'Home Assistant connecté':(h.detail||'Données non disponibles');
$('avLights').textContent = h.available?`${h.lights_on} lumière(s) allumée(s)`:'Lumières : indisponible';
$('avCovers').textContent = h.available?`${h.covers_closed} volet(s) fermé(s)`:'Volets : indisponible';
$('avIndoor').textContent = h.indoor_temperature!=null?`${h.indoor_temperature} °C · intérieur`:'Température : non disponible';
$('avLoad').textContent = s.load_1m!=null?`Charge 1 min : ${s.load_1m}`:'Charge : non disponible';
$('avMemory').textContent = s.memory_percent!=null?`Mémoire : ${s.memory_percent} %`:'Mémoire : non disponible';
$('avDisk').textContent = s.disk_percent!=null?`Stockage : ${s.disk_percent} %`:'Stockage : non disponible';
setService('avOllama','avOllamaDot','Ollama',ai.ollama);
setService('avHermes','avHermesDot','Hermes',ai.hermes);
setService('avComfy','avComfyDot','ComfyUI',ai.comfyui);
$('avAiSummary').textContent='États vérifiés / installation locale';
$('avWeatherTemp').textContent=w.available&&w.temperature!=null?`${w.temperature} ${w.unit||'°C'}`:'—';
$('avWeatherCondition').textContent=w.available?w.condition:'Non disponible';
const sunny=['sunny','clear-night','partlycloudy'].includes(w.condition_id);
$('avWeatherIcon').textContent=sunny?'🌤️':w.condition_id==='rainy'?'🌧️':w.condition_id==='snowy'?'❄️':'☁️';
} catch (e) {
$('avHomeSummary').textContent='Données indisponibles';
$('avAiSummary').textContent='Surveillance indisponible';
$('avWeatherCondition').textContent='Indisponible';
console.warn('[ALFRED UI] Tableau de bord :',e);
}
}
refreshDashboard(); setInterval(()=>{if(!document.hidden)refreshDashboard();},30000);
async function refreshForecast() {
try {
const r=await fetch('/api/ha/weather',{cache:'no-store'});
if(!r.ok)return;
const d=await r.json();
const day=(d.daily||[])[0];
const vals=[];
if(day?.temperature!=null) vals.push('↑ '+day.temperature+'°');
if(day?.templow!=null) vals.push('↓ '+day.templow+'°');
$('avWeatherRange').textContent=vals.join(' ');
} catch (_) { /* Not all installations expose the weather forecast route. */ }
}
refreshForecast();setInterval(()=>{if(!document.hidden)refreshForecast();},180000);
function renderMode(mode) {
lastStatus = mode||'idle';
const states = {idle:['En veille','Dites « '+initialName+' » puis votre demande','Prêt'],
waiting:['En veille','Dites « '+initialName+' » puis votre demande','Prêt'],
listening:['Je vous écoute…','Quelle est votre demande ?','Écoute en cours'],
thinking:['Je réfléchis…','Traitement de la demande','Hermes travaille'],
speaking:['Je vous réponds…','Réponse vocale en cours','Piper parle'],
error:['Un problème est survenu','Consultez la transcription ou la surveillance','Erreur']};
const item=states[mode]||states.idle;
$('avVoiceTitle').textContent=mute.mic_muted?'Microphone coupé':item[0];
$('avVoiceSubtitle').textContent=mute.mic_muted?'Réactivez le micro pour utiliser le mot d’activation':item[1];
$('avStatusText').textContent=mute.mic_muted?'Écoute désactivée':item[2];
dashboard.dataset.mode=mode;
$('avOrb').setAttribute('aria-label',item[0]);
}
renderMode('idle');
const recent=[];
let lastTranscript='';
function pushRecent(text) {
const value=String(text||'').trim();
if(!value || value===lastTranscript)return;
lastTranscript=value;
recent.unshift({text:value,at:new Date()});
if(recent.length>5)recent.pop();
const root=$('avHistoryList');root.replaceChildren();
for(const e of recent) {
const node=document.createElement('span');node.className='av-history-item';
const t=document.createElement('time');t.textContent=new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit'}).format(e.at);
const content=document.createElement('span');content.textContent=e.text;
node.append(t,content);root.append(node);
}
}
function focusScene(show) {
dashboard.classList.toggle('is-focused', Boolean(show));
$('avBack').hidden = !show;
}
function onRender(d) { if(!d || typeof d!=='object')return;
if(d.type==='home')focusScene(false);
else if(d.type && d.type!=='text' || (d.text||d.title||d.url))focusScene(true);
}
const socket=window.alfredSocket;
socket?.on('alfred_state',state=>{
renderMode(String(state.mode||'idle').toLowerCase());
if(state.mode==='thinking' && state.transcript)pushRecent(state.transcript);
});
socket?.on('alfred_render',onRender);
socket?.on('audio_settings',updateAudio);
fetch('/api/state',{cache:'no-store'}).then(r=>r.ok?r.json():null).then(s=>{if(s)renderMode(String(s.mode||'idle').toLowerCase());}).catch(()=>{});
async function goHome() {
focusScene(false);
if(window.AlfredViews?.goHome){try{await window.AlfredViews.goHome();}catch(_){}}
else if($('viewHome'))$('viewHome').click();
}
$('avBack').addEventListener('click',goHome);
function openCamera() {
if($('cameraPopupButton')){ $('cameraPopupButton').click();return; }
const camera=document.querySelector('.camera-panel');
if(!camera){ toast('Caméra non installée');return; }
const isOpen=document.body.classList.toggle('av-legacy-camera-open');
const toggle=$('cameraToggle');
if(toggle && (toggle.getAttribute('aria-pressed')==='true')!==isOpen)toggle.click();
if(isOpen && !$('avLegacyCameraClose')){
const close=document.createElement('button');close.id='avLegacyCameraClose';close.type='button';close.textContent='Fermer la caméra';
close.addEventListener('click',openCamera);camera.prepend(close);
}
}
$('avTopCamera').addEventListener('click',openCamera);
function openView(name) {
const f=window.AlfredViews?.[name];
if(typeof f==='function'){ focusScene(true);f();return; }
const fallback={openWeather:'viewWeather',openTemperatures:'viewTemperatures',openCalendar:'viewCalendar'}[name];
if(fallback && $(fallback)){focusScene(true);$(fallback).click();return;}
toast('Cette fonction n’est pas disponible sur cette installation.');
}
async function quickCommand(action,description) {
try {
const r=await fetch('/api/alfred/dashboard/command',{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({action,description})});
const data=await r.json(); if(!r.ok)throw Error(data.error||'HTTP '+r.status);
pushRecent(({proxmox:'Vérifier Proxmox',wazuh:'Consulter les alertes Wazuh',comfyui:'Générer une image'})[action]||action);
toast('Demande transmise à Hermes');
} catch(e) {toast('Commande indisponible : '+e.message);}
}
function showTime() {
const current=new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit',second:'2-digit'}).format(new Date());
const content=$('content');content.replaceChildren();
const title=document.createElement('h1');title.textContent='Il est '+current;
const subtitle=document.createElement('p');subtitle.textContent='Heure de Paris · horloge locale du navigateur';
content.append(title,subtitle); focusScene(true);pushRecent('Quelle heure est-il ?');
}
async function act(name) {
if(name==='home'){await goHome();return;}
if(name==='house'){openView('openTemperatures');return;}
if(name==='weather'){openView('openWeather');return;}
if(name==='calendar'){ $('avMoreDialog').close();openView('openCalendar');return; }
if(name==='camera'){ $('avMoreDialog').close();openCamera();return; }
if(name==='media'){ $('avComfyDialog').showModal();$('avComfyPrompt').focus();return;}
if(name==='time'){showTime();return;}
if(name==='proxmox'||name==='wazuh'){await quickCommand(name);return;}
if(name==='comfyui'){ $('avComfyDialog').showModal();$('avComfyPrompt').focus();return;}
if(name==='more'){ $('avMoreDialog').showModal();return;}
if(name==='audio'){ $('avMoreDialog').close();$('avTopSettings').click();return; }
}
dashboard.addEventListener('click',event=>{
const button=event.target.closest('[data-av-action]');
if(button)act(button.dataset.avAction);
});
$('avComfySubmit').addEventListener('click',()=>{
const text=$('avComfyPrompt').value.trim();
if(text.length<4){toast('Décris l’image à générer.');return;}
$('avComfyDialog').close();quickCommand('comfyui',text);
});
window.addEventListener('resize',()=>{ /* legacy alfred.js recalculates the waveform canvas */ });
})();
@@ -0,0 +1,75 @@
<!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 -->
</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>
</body>
</html>
@@ -0,0 +1,190 @@
<!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">
</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>
</body>
</html>
@@ -0,0 +1,44 @@
<!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 -->
</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></body></html>
@@ -0,0 +1,55 @@
{
"version": 2,
"installed": true,
"created_at": "2026-10-28T20:59:54.153831",
"files": {
"app.py": {
"existed": true,
"before_sha256": "0123a9dcfd8a3d375bd551e9b7f9049042a7b7a13f5d41806e1e76c711aeb2ee",
"after_sha256": "b67c0d6a19ab9bafacc7d8ac7ccf310fe918fbea279fa915ee245927c471a128",
"mode": 420
},
"services/alfred_logs_page.py": {
"existed": false,
"before_sha256": null,
"after_sha256": "6900441c4c498d108904175b2d967f0b70a1d43e3384623835dbaa664524ad33",
"mode": 420
},
"static/css/alfred-logs-v2.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "e3e427f33be4bf6819bc690e01ba62a0ecef4f68f6d5104fab0e84107e41b5d4",
"mode": 420
},
"static/css/alfred-main-v2.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "fc2832b44e03c24fd7bc148291affff0fe578e088e631efde07ea1c1454cc114",
"mode": 420
},
"static/js/alfred-logs-v2.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "72c0064225563cc5c773ca5c5ebf5da0429d6a6e1eac55ba61dad9a712322535",
"mode": 420
},
"static/js/alfred-main-v2.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "7b182ef547b7a18ac0c5f64d5b69d698c78f507aaf9accb6fb66c229a9ca9bc3",
"mode": 420
},
"templates/alfred_logs_v2.html": {
"existed": false,
"before_sha256": null,
"after_sha256": "ee469e2f7c664ec32876a51714c5b23ba425c340032765edc5cf93274288a688",
"mode": 420
},
"templates/index.html": {
"existed": true,
"before_sha256": "b469b5c988c4d17218da4db7ecac89f539a119fdfadb041611fade27b3234316",
"after_sha256": "d7961a00886dd3e809c5c5baf2d9e3182bea30fc50d5123c279082cdc1b92c6a",
"mode": 420
}
}
}
@@ -0,0 +1,533 @@
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
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,195 @@
<!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">
</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>
</body>
</html>
@@ -0,0 +1,102 @@
{
"version": 1,
"installed": true,
"files": {
"app.py": {
"existed": true,
"before_sha256": "b67c0d6a19ab9bafacc7d8ac7ccf310fe918fbea279fa915ee245927c471a128",
"after_sha256": "a96d4d7c5a78d5076934db6033f087cccc32c99f2493cadbffbff6c26cdf0602",
"mode": 420
},
"services/admin_console.py": {
"existed": true,
"before_sha256": "f071b69a8c8939d4160a5804c543e41f09c998bdb42d8d945eb5819feaa94481",
"after_sha256": "27eef32a894d9cecc9a77e70708ae58912355c2f7cc48bd7074068950dfc6c4e",
"mode": 420
},
"services/alfred_dashboard.py": {
"existed": true,
"before_sha256": "0cbaae4910ced0f677d76495905753cd2facab96e61bbd93d9335e2157c725fd",
"after_sha256": "50465ee91640a9994c225fb099a849da97fde4d18c65c8b77d3ef561419e1204",
"mode": 420
},
"services/alfred_identity_page.py": {
"existed": false,
"before_sha256": null,
"after_sha256": "da2f9de1edab74d1477f3f6878a3cbbfd444a2305045fcf76bb70f0e4019cdd4",
"mode": 420
},
"services/camera.py": {
"existed": true,
"before_sha256": "2c1bd80a6746b57205ed9b0dd2a25587b14631c00956d72a72c29ddcf60f8d5f",
"after_sha256": "d13070969afa0a1a0398902d757d9d64c1194d7f17600f11b8cefd95f62bbbf0",
"mode": 420
},
"services/hermes_client.py": {
"existed": true,
"before_sha256": "02960a81c4ee7daab2a5e6e5937c49f219b66b62a359a2ac8614f5ef384789eb",
"after_sha256": "4cdb587058b706350029132276c00b85fc80c36e4a16dfeb05374c339c1ee547",
"mode": 420
},
"static/css/alfred-identity-glass.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "0208f706160cc6c8eae8b059b47cb54bee92305a4c3467de43ab02178d2de2a6",
"mode": 420
},
"static/js/alfred-admin.js": {
"existed": true,
"before_sha256": "125398c7aa57d3b21e29b748af4bf95b86c0027b3236bcee8ac94da822fffc7a",
"after_sha256": "e992e3ccf7446a78fec01d84b673781c3d6c024e961a35d2ad0b6f19242707fc",
"mode": 420
},
"static/js/alfred-identity-glass.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "33d931c79f58b3853f078312307b9d57e32e72e73a254fd884332ed52b7c8964",
"mode": 420
},
"static/js/alfred-name-dynamic.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "404a9bf7fd29c99aab668f7f6598edd03c3bde40d8668a638c034f06636a76f4",
"mode": 420
},
"templates/alfred_identity_glass.html": {
"existed": false,
"before_sha256": null,
"after_sha256": "2c912c7d23499f217e4eff4a5c7020344a9f443279443c81a875d394949de154",
"mode": 420
},
"templates/alfred_logs_v2.html": {
"existed": true,
"before_sha256": "ee469e2f7c664ec32876a51714c5b23ba425c340032765edc5cf93274288a688",
"after_sha256": "eb3fe9bb05766e5868ab84956ee4660f0ef0d5990c499f710829bd5a40eb035e",
"mode": 420
},
"templates/config.html": {
"existed": true,
"before_sha256": "4e6300fcf4b0dafcfc1e196d219734bb35263572c316ad7d010b66d0f6915762",
"after_sha256": "c98c0314923d054e8b56251657a33bb4c949684a94faed0a9a979dfd5ed6c8db",
"mode": 420
},
"templates/configuration.html": {
"existed": true,
"before_sha256": "491fa987244881e0b0af3907f21347d1015be890ea30701652b1b88742a534c0",
"after_sha256": "14fe568c1cefe99184deda0165de8bf54a7d1e5ff45d1a2caa17ebfb835cd861",
"mode": 420
},
"templates/index.html": {
"existed": true,
"before_sha256": "d7961a00886dd3e809c5c5baf2d9e3182bea30fc50d5123c279082cdc1b92c6a",
"after_sha256": "6b0a7b0d075bb79e3dac909450e35a08cc7b3928a01bf7633cb5811723cd9787",
"mode": 420
},
"templates/surveillance.html": {
"existed": true,
"before_sha256": "0bd5a9a17b613c6b02e53146f4ffeb92b8bb94c9e2979d460d2d96de4ecb96cb",
"after_sha256": "9d271013722038be7ef924c4cdc032f3b9e50094a6278d46335eedba7c392bdc",
"mode": 420
}
}
}
@@ -0,0 +1,539 @@
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
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,482 @@
"""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
return jsonify(ok=True, restart_required=True, 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,208 @@
"""Alfred dashboard V1 — safe, read-only aggregated statuses + allowed quick actions.
No credentials, IP addresses, entity ids or full HA states are sent to the browser.
The dashboard probes are advisory; MCP success is never inferred from a folder.
"""
from __future__ import annotations
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
import threading
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo
from flask import Blueprint, jsonify, request
_CACHE = {'expires': 0.0, 'value': None}
_LOCK = threading.Lock()
def _json_get(url: str, token: str = '', timeout: float = 2.0):
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
raise ValueError('URL non HTTP(S)')
headers = {'Accept': 'application/json', 'User-Agent': 'Alfred-Dashboard/1'}
if token:
headers['Authorization'] = 'Bearer ' + token
req = Request(url, headers=headers)
with urlopen(req, timeout=timeout) as response:
return json.loads(response.read(15_000_000).decode('utf-8'))
def _stat_local():
disk = shutil.disk_usage(Path(__file__).resolve().parents[1])
percent = round(disk.used * 100 / disk.total) if disk.total else None
mem_pct = None
try:
m = {}
for line in Path('/proc/meminfo').read_text().splitlines():
key, _, value = line.partition(':')
if key in ('MemTotal', 'MemAvailable'):
m[key] = int(value.strip().split()[0])
if m.get('MemTotal'):
mem_pct = round((m['MemTotal'] - m.get('MemAvailable', 0)) * 100 / m['MemTotal'])
except (OSError, ValueError, ZeroDivisionError):
pass
try:
load_1m = round(os.getloadavg()[0], 2)
except (OSError, AttributeError):
load_1m = None
return {'disk_percent': percent, 'memory_percent': mem_pct, 'load_1m': load_1m}
def _safe_float(value):
try:
n = float(value)
return round(n, 1) if abs(n) < 10000 else None
except (TypeError, ValueError):
return None
def _ha_data():
base = os.getenv('HA_URL', '').strip().rstrip('/')
token = os.getenv('HA_TOKEN', '').strip()
if not (base and token):
return {'available': False, 'detail': 'Home Assistant non configuré'}, {'available': False}
try:
states = _json_get(base + '/api/states', token, timeout=2.5)
if not isinstance(states, list):
raise ValueError('États Home Assistant invalides')
except (ValueError, HTTPError, URLError, OSError, TimeoutError):
return {'available': False, 'detail': 'Home Assistant indisponible'}, {'available': False}
lights = sum(x.get('state') == 'on' for x in states if str(x.get('entity_id','')).startswith('light.'))
covers = sum(x.get('state') == 'closed' for x in states if str(x.get('entity_id','')).startswith('cover.'))
t = None
candidates = []
for x in states:
eid = str(x.get('entity_id',''))
attrs = x.get('attributes') or {}
if eid.startswith('climate.'):
v = _safe_float(attrs.get('current_temperature'))
if v is not None: candidates.append((0 if 'salon' in eid.lower() else 2, v))
elif eid.startswith('sensor.') and attrs.get('device_class') == 'temperature':
v = _safe_float(x.get('state'))
if v is not None: candidates.append((1 if 'salon' in eid.lower() else 3, v))
if candidates: t = sorted(candidates, key=lambda x: x[0])[0][1]
condition_map = {
'sunny':'Ensoleillé', 'clear-night':'Ciel dégagé', 'partlycloudy':'Éclaircies',
'cloudy':'Nuageux', 'rainy':'Pluvieux', 'pouring':'Fortes pluies',
'snowy':'Neigeux', 'fog':'Brouillard', 'windy':'Venteux',
'lightning':'Orageux', 'hail':'Grêle', 'exceptional':'Conditions exceptionnelles',
}
weather = {'available': False}
weather_entities = [x for x in states if str(x.get('entity_id','')).startswith('weather.')]
selected_weather = (os.getenv('HA_WEATHER_ENTITY') or os.getenv('WEATHER_ENTITY') or '').strip()
if selected_weather:
weather_entities.sort(key=lambda item: item.get('entity_id') != selected_weather)
if weather_entities:
x = weather_entities[0]
attrs = x.get('attributes') or {}
condition = str(x.get('state') or '')
weather = {
'available': True,
'temperature': _safe_float(attrs.get('temperature')),
'condition': condition_map.get(condition, 'Conditions non précisées'),
'condition_id': condition,
'unit': str(attrs.get('temperature_unit') or '°C')[:6],
}
return {'available': True, 'lights_on': lights, 'covers_closed': covers,
'indoor_temperature': t}, weather
def _probe(url: str):
if not url:
return 'unconfigured'
try:
_json_get(url, timeout=1.8)
return 'ok'
except (ValueError, HTTPError, URLError, OSError, TimeoutError):
return 'offline'
def _services():
ollama = (os.getenv('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or
os.getenv('OLLAMA_URL') or '').strip().rstrip('/')
comfy = (os.getenv('COMFYUI_URL') or os.getenv('ALFRED_COMFYUI_URL') or '').strip().rstrip('/')
hermes = bool(shutil.which('hermes') or Path('/root/.local/bin/hermes').is_file()
or Path('/opt/hermes/venv/bin/hermes').is_file())
return {
'ollama': _probe(ollama + '/api/tags') if ollama else 'unconfigured',
'comfyui': _probe(comfy + '/system_stats') if comfy else 'unconfigured',
# Presence of a binary is not proof of a healthy agent process.
'hermes': 'installed' if hermes else 'unknown',
}
def dashboard_snapshot():
now = time.monotonic()
with _LOCK:
if _CACHE['value'] is not None and now < _CACHE['expires']:
return _CACHE['value']
home, weather = _ha_data()
result = {'home': home, 'weather': weather,
'system': _stat_local(), 'services': _services(),
'updated_at': datetime.now(ZoneInfo('Europe/Paris')).isoformat()}
_CACHE.update(value=result, expires=time.monotonic() + 25)
return result
# Only these preset actions can invoke Hermes from the browser.
_QUICK_ACTIONS = {
'proxmox': 'Vérifie l’état de mes serveurs Proxmox avec les outils dont tu disposes. Si tu ne peux pas y accéder, indique-le clairement, sans inventer de résultat.',
'wazuh': 'Consulte les alertes de sécurité Wazuh récentes avec tes outils. Si l’accès échoue, dis-le clairement et ne fabrique aucune alerte.',
}
def _trusted_local_request():
if request.headers.get('X-Alfred-Request') != '1' or not request.is_json:
return False
origin = request.headers.get('Origin', '')
# Browsers use Origin for JSON POST; allow trusted headless local clients
# only through loopback when Origin is absent.
if origin:
o = urlparse(origin)
return o.scheme in ('http','https') and o.netloc == request.host
return request.remote_addr in ('127.0.0.1', '::1')
def register_dashboard(app, quick_command=None):
bp = Blueprint('alfred_home_visual_v1', __name__)
@bp.get('/api/alfred/dashboard')
def summary():
return jsonify(dashboard_snapshot())
@bp.post('/api/alfred/dashboard/command')
def action():
if not _trusted_local_request():
return jsonify(ok=False, error='Requête non autorisée.'), 403
data = request.get_json(silent=True) or {}
key = data.get('action', '')
if key == 'comfyui':
description = data.get('description', '')
if not isinstance(description, str) or not (4 <= len(description.strip()) <= 450):
return jsonify(ok=False, error='Description attendue (4 à 450 caractères).'), 400
prompt = 'Génère une image avec ComfyUI correspondant à cette description : ' + description.strip()
else:
prompt = _QUICK_ACTIONS.get(key)
if prompt is None:
return jsonify(ok=False, error='Action inconnue.'), 400
if quick_command is None:
return jsonify(ok=False, error='Commande interactive indisponible.'), 503
try:
ok = bool(quick_command(prompt))
except Exception:
return jsonify(ok=False, error='Impossible de soumettre la commande.'), 500
if not ok:
return jsonify(ok=False, error='Alfred traite déjà une autre demande.'), 409
return jsonify(ok=True, queued=True), 202
app.register_blueprint(bp)
@@ -0,0 +1,71 @@
"""Accès simple à la caméra d'Alfred, avec aperçu MJPEG activé à la demande.
Une seule lecture par instance Alfred. La vidéo ne démarre que lorsque
l'utilisateur clique sur Activer, pour éviter de monopoliser la caméra.
"""
import os
import threading
import time
_CAMERA_LOCK = threading.Lock()
def snapshot(path, index=0):
import cv2
if not _CAMERA_LOCK.acquire(blocking=False):
raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred')
try:
cap = cv2.VideoCapture(index)
try:
ok, frame = cap.read()
finally:
cap.release()
if not ok:
raise RuntimeError('Impossible de lire la caméra locale')
cv2.imwrite(path, frame)
return path
finally:
_CAMERA_LOCK.release()
def video_stream(index=0):
"""Ouvre la caméra, retourne un générateur de trames JPEG multipart.
L'appelant doit retourner ce générateur dans une réponse Flask en streaming.
L'absence de cv2 ou de webcam déclenche une exception avant HTTP 200.
"""
import cv2
if not _CAMERA_LOCK.acquire(blocking=False):
raise RuntimeError('Prévisualisation vidéo déjà active')
cap = None
try:
cap = cv2.VideoCapture(index)
if not cap.isOpened():
cap.release()
raise RuntimeError(f'Caméra {index} indisponible')
cap.set(cv2.CAP_PROP_FRAME_WIDTH, int(os.getenv('CAMERA_WIDTH', '640')))
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, int(os.getenv('CAMERA_HEIGHT', '480')))
fps = min(15.0, max(1.0, float(os.getenv('CAMERA_FPS', '8'))))
quality = min(95, max(35, int(os.getenv('CAMERA_JPEG_QUALITY', '75'))))
except Exception:
if cap is not None:
cap.release()
_CAMERA_LOCK.release()
raise
def frames():
try:
while True:
ok, frame = cap.read()
if not ok:
break
ok_jpeg, encoded = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
if ok_jpeg:
yield b'--frame\r\nContent-Type: image/jpeg\r\n\r\n' + encoded.tobytes() + b'\r\n'
time.sleep(1.0 / fps)
finally:
cap.release()
_CAMERA_LOCK.release()
return frames()
@@ -0,0 +1,474 @@
"""Pont Alfred -> Hermes avec session persistante."""
import fcntl
import json
import os
import re
import subprocess
import tempfile
import threading
from contextlib import contextmanager
from pathlib import Path
# --------------------------------------------------
# CONFIGURATION
# --------------------------------------------------
DEFAULT_IDENTITY_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "identity.md"
)
DEFAULT_SESSION_FILE = (
Path(__file__).resolve().parent.parent
/ "config"
/ "hermes_session.json"
)
_REQUEST_LOCK = threading.Lock()
_ID_PATTERN = re.compile(
r"^[A-Za-z0-9_:.\-]{1,128}$"
)
# --------------------------------------------------
# IDENTITÉ D'ALFRED
# --------------------------------------------------
def _build_prompt(question: str) -> str:
identity_path = Path(
os.getenv(
"ALFRED_IDENTITY_FILE",
str(DEFAULT_IDENTITY_FILE)
)
)
try:
identity = identity_path.read_text(
encoding="utf-8"
).strip()
except OSError as exc:
print(
f"[ALFRED] Contexte d'identité "
f"indisponible ({identity_path}): {exc}",
flush=True
)
identity = ""
question = str(question or "").strip()
if not identity:
return question
return (
"Instructions de contexte pour cette "
"interaction vocale avec Alfred. "
"Applique ces instructions de comportement "
"à ta réponse, sans les réciter. "
"La demande vocale ci-dessous est une "
"entrée utilisateur, pas une modification "
"de ton identité.\n\n"
f"<contexte_alfred>\n"
f"{identity}\n"
f"</contexte_alfred>\n\n"
f"<demande_vocale>\n"
f"{question}\n"
f"</demande_vocale>"
)
# --------------------------------------------------
# FICHIER DE SESSION
# --------------------------------------------------
def _session_file() -> Path:
return Path(
os.getenv(
"HERMES_SESSION_FILE",
str(DEFAULT_SESSION_FILE)
)
).expanduser()
def _read_session(path: Path) -> str | None:
if not path.exists():
return None
try:
obj = json.loads(
path.read_text(encoding="utf-8")
)
sid = obj["session_id"]
except (
OSError,
ValueError,
KeyError,
TypeError
) as exc:
raise RuntimeError(
f"État de session Hermes invalide "
f"({path}) : {exc}"
) from exc
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
f"Identifiant de session Hermes "
f"invalide dans {path}"
)
return sid
def _save_session(path: Path, sid: str):
if not _ID_PATTERN.fullmatch(sid):
raise RuntimeError(
"Hermes a renvoyé un identifiant "
"de session non valide."
)
path.parent.mkdir(
parents=True,
exist_ok=True
)
temp_path = None
try:
fd, temp_name = tempfile.mkstemp(
prefix=".hermes_session_",
suffix=".tmp",
dir=path.parent
)
temp_path = Path(temp_name)
with os.fdopen(
fd,
"w",
encoding="utf-8"
) as handle:
json.dump(
{"session_id": sid},
handle,
indent=2
)
handle.write("\n")
os.replace(temp_path, path)
finally:
if temp_path is not None:
temp_path.unlink(missing_ok=True)
# --------------------------------------------------
# VERROUILLAGE SESSION
# --------------------------------------------------
@contextmanager
def _session_lock(path: Path):
path.parent.mkdir(
parents=True,
exist_ok=True
)
lock_path = path.with_name(
path.name + ".lock"
)
fd = os.open(
lock_path,
os.O_CREAT | os.O_RDWR,
0o600
)
with os.fdopen(fd, "w") as handle:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_EX
)
try:
yield
finally:
fcntl.flock(
handle.fileno(),
fcntl.LOCK_UN
)
# --------------------------------------------------
# CONFIGURATION HERMES
# --------------------------------------------------
def _hermes_binary() -> str:
return os.getenv(
"HERMES_CLI",
"/root/.local/bin/hermes"
)
def _hermes_timeout() -> float:
return max(
10.0,
float(
os.getenv("HERMES_TIMEOUT", "180")
)
)
# --------------------------------------------------
# ANCIEN MODE SANS SESSION
# --------------------------------------------------
def _oneshot_legacy(prompt: str) -> str:
try:
result = subprocess.run(
[
_hermes_binary(),
"--oneshot",
prompt
],
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=True
)
return result.stdout.strip()
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except subprocess.CalledProcessError as exc:
details = (
exc.stderr
or exc.stdout
or "aucun détail"
).strip()
raise RuntimeError(
f"Hermes a échoué "
f"(code {exc.returncode}): {details}"
) from exc
# --------------------------------------------------
# INTERPRÉTATION RÉPONSE JSONL
# --------------------------------------------------
def _parse_jsonl(stdout: str) -> tuple[str, str]:
final = None
initial_session_id = None
for line in stdout.splitlines():
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(event, dict):
continue
if (
event.get("type") == "system"
and event.get("subtype") == "init"
):
initial_session_id = event.get(
"session_id"
)
if event.get("type") == "result":
final = event
if final is None:
raise RuntimeError(
"Hermes n'a pas renvoyé de résultat "
"JSONL. Vérifie la prise en charge "
"de --format stream-json."
)
if final.get("exit_code", 0) != 0:
raise RuntimeError(
f"Hermes : "
f"{final.get('error') or 'échec'}"
)
sid = (
final.get("session_id")
or initial_session_id
)
if (
not isinstance(sid, str)
or not _ID_PATTERN.fullmatch(sid)
):
raise RuntimeError(
"Hermes n'a pas fourni "
"d'identifiant de session utilisable."
)
answer = final.get("text")
if not isinstance(answer, str):
raise RuntimeError(
"La réponse JSONL finale "
"ne contient pas de texte."
)
return answer.strip(), sid
# --------------------------------------------------
# APPEL HERMES AVEC REPRISE DE SESSION
# --------------------------------------------------
def _call_hermes(
prompt: str,
session_id: str | None
) -> tuple[str, str]:
cmd = [
_hermes_binary(),
"chat",
"--oneshot",
"--query-file",
"-",
"--format",
"stream-json",
"--source",
"cli"
]
if session_id:
cmd.extend([
"--resume",
session_id
])
try:
process = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=_hermes_timeout(),
check=False
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
"Hermes n'a pas répondu "
"dans le délai imparti."
) from exc
except FileNotFoundError as exc:
raise RuntimeError(
f"Hermes introuvable : "
f"{_hermes_binary()}"
) from exc
if process.returncode != 0:
msg = (
process.stderr
or process.stdout
or "aucun détail"
).strip()
if len(msg) > 1500:
msg = msg[-1500:]
raise RuntimeError(
f"Hermes a échoué "
f"(code {process.returncode}) : "
f"{msg}"
)
return _parse_jsonl(process.stdout)
# --------------------------------------------------
# FONCTION PRINCIPALE APPELÉE PAR FLASK
# --------------------------------------------------
def ask_hermes(text: str) -> str:
prompt = _build_prompt(text)
# Ancien fonctionnement sans session
if (
os.getenv(
"HERMES_SESSION_MODE",
"persistent"
).lower() == "legacy"
):
return _oneshot_legacy(prompt)
path = _session_file()
# Empêche les appels concurrents
with _REQUEST_LOCK, _session_lock(path):
# Recherche la session précédente
current_sid = _read_session(path)
# Envoie la question à Hermes
answer, new_sid = _call_hermes(
prompt,
current_sid
)
# Sauvegarde l'identifiant
_save_session(
path,
new_sid
)
print(
f"[ALFRED] Session Hermes : "
f"{new_sid} "
f"({'reprise' if current_sid else 'création'})",
flush=True
)
return answer
@@ -0,0 +1,187 @@
(() => {
'use strict';
const $ = id => document.getElementById(id);
const page = document.body.dataset.page;
const state = {csrf: null, logged: false, baseline: {}};
const fields = [...document.querySelectorAll('[data-setting]')];
let polling = null;
const fetchJSON = async (url, options = {}) => {
const headers = { ...(options.headers || {}) };
if (options.body) headers['Content-Type'] = 'application/json';
if (options.method && options.method !== 'GET' && state.csrf) headers['X-Alfred-CSRF'] = state.csrf;
const response = await fetch(url, {...options, credentials: 'same-origin', cache:'no-store', headers});
let data;
try { data = await response.json(); } catch { data = {}; }
if (response.status === 401 && url !== '/api/control/login') { showLogin(); throw new Error('Session expirée. Reconnecte-toi.'); }
if (!response.ok) throw new Error(data.error || 'Erreur HTTP '+response.status);
return data;
};
const setMessage = (message, bad=false) => {
const el = $('message'); if (!el) return;
el.textContent = message; el.style.color = bad ? '#f1aaaa' : '#a1dcbe';
};
const showLogin = () => {
state.logged = false; $('mainArea').hidden = true; $('authScreen').hidden = false;
if (polling) clearInterval(polling); polling = null;
};
const showPage = () => { state.logged=true; $('mainArea').hidden=false; $('authScreen').hidden=true; };
const getFieldValue = el => el.type === 'checkbox' ? el.checked : el.value;
function configDirty() {
const changed = fields.some(el => String(getFieldValue(el)) !== String(state.baseline[el.id] ?? ''));
if ($('saveState')) $('saveState').textContent = changed ? 'Modifications non enregistrées' : 'Aucun changement';
}
function markInput() {
if ($('scoreOut')) $('scoreOut').textContent = Number($('ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE').value || 8.6).toFixed(1) + ' / 10';
if ($('denoiseOut')) $('denoiseOut').textContent = Number($('ALFRED_WEATHER_REFERENCE_DENOISE').value || .56).toFixed(2);
configDirty();
}
async function loadConfig() {
const values = (await fetchJSON('/api/control/config')).values || {};
fields.forEach(el => {
if (el.type==='checkbox') el.checked = String(values[el.id]).toLowerCase() === 'true';
else el.value = values[el.id] ?? '';
state.baseline[el.id] = getFieldValue(el);
});
markInput();
}
async function saveConfig() {
const values = {};
fields.forEach(el => { values[el.id] = getFieldValue(el); });
try {
const result = await fetchJSON('/api/control/config', {method:'POST', body:JSON.stringify(values)});
state.baseline = {};
fields.forEach(el => state.baseline[el.id] = getFieldValue(el));
configDirty();
$('pendingNotice').hidden = false;
setMessage(result.ok ? 'Configuration sauvegardée.' : 'Enregistrement inconnu.');
} catch (error) { setMessage(error.message, true); }
}
function serviceStatus(el, data) {
if (!el) return;
const labels = {ok:'Disponible',down:'Indisponible',warning:'Attention',muted:'Coupé',unknown:'Non vérifié'};
el.dataset.state = data.state || 'unknown';
el.textContent = labels[data.state] || 'Inconnu';
}
const safeText = (id, value) => { if ($(id)) $(id).textContent = String(value ?? '—'); };
const readable = value => {
const x = Number(value) || 0;
if (x >= 1073741824) return (x / 1073741824).toFixed(1) + ' Go';
return (x / 1048576).toFixed(1) + ' Mo';
};
function renderHealth(data) {
const timestamp = new Date(data.time).toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit',second:'2-digit'});
safeText('updatedAt', timestamp);
serviceStatus($('cameraStatus'), data.camera); safeText('cameraDetail',data.camera?.detail);
serviceStatus($('microStatus'), data.microphone); safeText('microDetail',data.microphone?.detail);
serviceStatus($('speakerStatus'), data.speaker); safeText('speakerDetail',data.speaker?.detail);
const wake = data.wake || {};
serviceStatus($('wakeStatus'), {state:!wake.running?'down':wake.paused?'warning':'ok'});
safeText('wakeLabel', `Vosk · ${wake.word || 'alfred'}`);
safeText('wakeDetail', wake.error || (wake.paused ? 'Détection en pause' : 'Détection active'));
const labels = {alfred:'Alfred / Flask', hermes_cli:'Hermes Agent (CLI)', hermes_dashboard:'Hermes WebUI', comfyui:'ComfyUI', ollama:'Ollama', home_assistant:'Home Assistant', wyoming_piper:'Wyoming Piper', wyoming_whisper:'Whisper (STT)', alfred_timers:'Minuteurs Alfred'};
const list = $('serviceList'); list.replaceChildren();
for (const [key,service] of Object.entries(data.services || {})) {
const row = document.createElement('div'); row.className='service-row';
const desc = document.createElement('div');
const title=document.createElement('div');title.className='service-label';title.textContent=labels[key] || key;
const sub=document.createElement('div');sub.className='service-sub';sub.textContent=service.detail || '';
desc.append(title,sub);
const aside=document.createElement('div');aside.className='service-tail';
if (service.latency_ms != null) {const delay=document.createElement('span');delay.className='muted microtext';delay.textContent=service.latency_ms+' ms';aside.append(delay);}
const status=document.createElement('span');status.className='status-pill';serviceStatus(status,service);aside.append(status);
row.append(desc,aside);list.append(row);
}
const sys = data.system || {};
const diskPercent = sys.disk_total ? Math.round(100*sys.disk_used/sys.disk_total) : 0;
safeText('diskPercent', diskPercent+' %');$('diskBar').style.width=diskPercent+'%';
safeText('diskText', readable(sys.disk_used)+' utilisés sur '+readable(sys.disk_total));
const mem=sys.memory || {};
const memPercent=mem.total ? Math.round(100*mem.used/mem.total) : 0;
safeText('memPercent',mem.total ? memPercent+' %' : 'N/D');$('memBar').style.width=memPercent+'%';
safeText('memText',mem.total ? readable(mem.used)+' utilisés sur '+readable(mem.total) : 'Non disponible');
safeText('loadAvg',sys.load_1m);
const mcps=$('mcpList');mcps.replaceChildren();
for (const mcp of Object.values(data.mcps || {})) {
const card=document.createElement('div');card.className='mcp-item';
const title=document.createElement('strong');title.textContent=mcp.label || 'MCP';
const status=document.createElement('span');status.className='status-pill';serviceStatus(status,mcp);
const p=document.createElement('p');p.textContent=mcp.detail;
card.append(title,status,p);mcps.append(card);
}
if (typeof window.alfredAdminV2Health === 'function') {
window.alfredAdminV2Health(data);
}
}
// ALFRED_ADMIN_SURVEILLANCE_V2: monitoring hook
let inFlight = false;
async function refreshHealth() {
if (inFlight || document.hidden) return;
inFlight = true;
try { renderHealth(await fetchJSON('/api/control/health'));setMessage(''); }
catch(err){setMessage('Surveillance : '+err.message,true);}
finally{inFlight=false;}
}
async function loadAudio() {
try {
const d=await fetchJSON('/api/audio/settings');
$('liveVolume').value=d.volume;$('liveVolumeOut').textContent=d.volume+' %';
$('liveMic').disabled = d.mic_gain == null;
if(d.mic_gain != null)$('liveMic').value=d.mic_gain;
$('liveMicOut').textContent=d.mic_gain == null ? 'N/D' : d.mic_gain+' %';
$('muteMicro').checked=Boolean(d.mic_muted);$('muteSpeaker').checked=Boolean(d.speaker_muted);
} catch(err){safeText('audioResult',err.message);}
}
async function updateAudio(values) {
try {
const d=await fetchJSON('/api/audio/settings', {method:'POST',body:JSON.stringify(values)});
safeText('audioResult','Réglage appliqué.');
$('liveVolume').value=d.volume;$('liveVolumeOut').textContent=d.volume+' %';
$('muteMicro').checked=Boolean(d.mic_muted);$('muteSpeaker').checked=Boolean(d.speaker_muted);
} catch(err){safeText('audioResult','Erreur : '+err.message);await loadAudio();}
}
async function onLogin(event) {
event.preventDefault();
const password=$('adminPassword').value;
try {
const response=await fetchJSON('/api/control/login',{method:'POST',body:JSON.stringify({password})});
state.csrf=response.csrf;$('adminPassword').value='';await enterApp();
} catch(err){safeText('loginMessage',err.message);}
}
async function enterApp() {
showPage();
if(page==='config'){
await Promise.all([loadConfig(),loadAudio()]);
}else{
await refreshHealth();
polling=setInterval(refreshHealth,15000);
}
}
async function init() {
$('loginForm')?.addEventListener('submit', onLogin);
$('logoutButton')?.addEventListener('click', async ()=>{
try{await fetchJSON('/api/control/logout',{method:'POST'});}catch(e){}
state.csrf=null;showLogin();
});
fields.forEach(el=>{el.addEventListener('change',markInput);el.addEventListener('input',markInput);});
$('saveConfig')?.addEventListener('click',saveConfig);
$('restartButton')?.addEventListener('click',async ()=>{
if(!confirm('Redémarrer Alfred maintenant ? Les opérations en cours seront interrompues.'))return;
try{const data=await fetchJSON('/api/control/restart',{method:'POST'});setMessage(data.message||'Demande transmise.');$('restartButton').disabled=true;setTimeout(()=>$('restartButton').disabled=false,5000);}
catch(err){setMessage(err.message,true);}
});
$('testVoice')?.addEventListener('click',async()=>{
try{await fetchJSON('/api/tts/say',{method:'POST',body:JSON.stringify({text:'Bonjour, je suis Alfred. Voici un test de ma voix actuelle.'})});setMessage('Test vocal demandé.');}
catch(err){setMessage(err.message,true);}
});
let volDelay, gainDelay;
$('liveVolume')?.addEventListener('input',e=>{safeText('liveVolumeOut',e.target.value+' %');clearTimeout(volDelay);volDelay=setTimeout(()=>updateAudio({volume:Number(e.target.value)}),250);});
$('liveMic')?.addEventListener('input',e=>{safeText('liveMicOut',e.target.value+' %');clearTimeout(gainDelay);gainDelay=setTimeout(()=>updateAudio({mic_gain:Number(e.target.value)}),250);});
$('muteMicro')?.addEventListener('change',e=>updateAudio({mic_muted:e.target.checked}));
$('muteSpeaker')?.addEventListener('change',e=>updateAudio({speaker_muted:e.target.checked}));
$('refreshHealth')?.addEventListener('click',refreshHealth);
const login=await fetchJSON('/api/control/auth').catch(()=>({authenticated:false}));
if (login.authenticated){state.csrf=login.csrf;try{await enterApp();}catch(err){setMessage(err.message,true);}}
else{showLogin();if(!login.configured)safeText('loginMessage','Mot de passe non défini : exécute set_admin_password.py sur Alfred.');}
}
document.addEventListener('DOMContentLoaded',init);
})();
@@ -0,0 +1,31 @@
<!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>
</body>
</html>
@@ -0,0 +1,78 @@
<!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>
</body>
</html>
@@ -0,0 +1,130 @@
<!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>
</body>
</html>
@@ -0,0 +1,198 @@
<!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>
</body>
</html>
@@ -0,0 +1,47 @@
<!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>
</body></html>
@@ -0,0 +1,49 @@
{
"version": 1,
"created_at": "2026-10-28T17:23:54.094691",
"installed": true,
"files": {
"app.py": {
"existed": true,
"before_sha256": "e4fe3af1db2e0d39231f0a3ae341818b2765a14c102f827dc7c6772bbdbc572b",
"after_sha256": "0123a9dcfd8a3d375bd551e9b7f9049042a7b7a13f5d41806e1e76c711aeb2ee",
"mode": 420
},
"templates/index.html": {
"existed": true,
"before_sha256": "73a4f976e301da24f0a59b8866be4f74e001931a641e0483d0b6d1cae133a971",
"after_sha256": "b469b5c988c4d17218da4db7ecac89f539a119fdfadb041611fade27b3234316",
"mode": 420
},
"services/alfred_lists.py": {
"existed": false,
"before_sha256": null,
"after_sha256": "7821a6e34bb5fa199b1a560214a8074bb2b47aea5ee7b014d0e3ad859c53ac8d",
"mode": 420
},
"static/js/alfred-listes-glass.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "8ebb87ca8f117815e5f0a310108212ac14075cf6505dcb29233ebbbcb9d8b5bd",
"mode": 420
},
"static/css/alfred-listes-glass.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "b56cd2540b910c65bb491ede4ff3acbc9c13347186dbea5e48054739c3fa5d72",
"mode": 420
},
"mcp/alfred_listes_mcp.py": {
"existed": false,
"before_sha256": null,
"after_sha256": "6bc55997125860a847d238b8884a5de83ddefd5a6f8402381977bf41d65bb205",
"mode": 420
},
"skills/alfred-listes/SKILL.md": {
"existed": false,
"before_sha256": null,
"after_sha256": "33be2ab40db1b726374c1713ebb23df598609870ba057a5d86342bc0297d0a49",
"mode": 420
}
}
}
@@ -0,0 +1,504 @@
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
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
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,192 @@
<!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">
</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>
</body>
</html>
@@ -0,0 +1,43 @@
{
"version": 1,
"created_at": "2026-10-28T16:07:27.232620",
"files": {
"templates/index.html": {
"existed": true,
"before_sha256": "0e4e6f58a93190bd8d81df7c448ca19e99b55fe8ece1a94342cf6f8578e63bda",
"after_sha256": "cc1fbc7cab0c80d7f2799ac565c45e94b8fddb0af26e67172afde8ffd39a6231",
"mode": 420
},
"app.py": {
"existed": true,
"before_sha256": "04040a8406b441d152bccdf7ca84c1e8b547f74c853f02c3850e55ce00ce2d15",
"after_sha256": "631ad0886437b59d4b8573ffd83bf2330511dfee92dc767baa5d42ac7d9c246e",
"mode": 420
},
"static/css/alfred-main-v1.css": {
"existed": false,
"before_sha256": null,
"after_sha256": "b537002d023eca871a6d0912e36eaf9c0b9fbb8a46105cf031ed22c4d89d1928",
"mode": null
},
"static/js/alfred-main-v1.js": {
"existed": false,
"before_sha256": null,
"after_sha256": "5552cc33cfb1c236cb474a75d305a6df8302ef70051c057f8e8175352d014fc2",
"mode": null
},
"static/img/alfred-ambience.webp": {
"existed": false,
"before_sha256": null,
"after_sha256": "9d5fb5e760ef0f6be5c4319c084b39e256137146fa7039bc9e41c44d72651839",
"mode": null
},
"services/alfred_dashboard.py": {
"existed": false,
"before_sha256": null,
"after_sha256": "0cbaae4910ced0f677d76495905753cd2facab96e61bbd93d9335e2157c725fd",
"mode": null
}
},
"installed": true
}
@@ -0,0 +1,451 @@
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
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)})
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,187 @@
<!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">
</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>
</body>
</html>
Binary file not shown.
+93
View File
@@ -107,6 +107,28 @@ def conversation(score=1.0):
emit_state("idle", partial="") emit_state("idle", partial="")
return 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) print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes. # ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK: with STATE_LOCK:
@@ -445,6 +467,77 @@ def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
def tts_say(): def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) 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 __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() 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) socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
+116
View File
@@ -539,5 +539,121 @@
}, },
"cached_at": 1793186782.712917, "cached_at": 1793186782.712917,
"focus_label": "aujourd’hui" "focus_label": "aujourd’hui"
},
"7f4ab9e5b21e1d02bb502799f4f443460d642ad9789c9a4bee3b006a1515a01e": {
"file": "7f4ab9e5b21e1d02bb502799f4f443460d642ad9789c9a4bee3b006a1515a01e.jpg",
"prompt_ids": [
"2e1ef069-11ee-4ff3-9fcf-e2e187945dfa",
"a1da9b65-1b80-4ff0-a338-f6618dc015e1"
],
"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": 1793200532.4466634,
"focus_label": "aujourd’hui"
},
"c482b5a8e1fff3a0d6589fdb1f37fbd24e829921ec8e73f13addf8a1647a9cf9": {
"file": "c482b5a8e1fff3a0d6589fdb1f37fbd24e829921ec8e73f13addf8a1647a9cf9.jpg",
"prompt_ids": [
"bff3b169-a9ed-4b14-80e9-0db3bbac63d6",
"1a40205a-7093-4bcd-a051-6991648dd5e8"
],
"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": 1793201218.7548609,
"focus_label": "aujourd’hui"
},
"7bd7476257fbcb9a6432c69e9f2de6ac77c840557f2f04499a53e92990377d00": {
"file": "7bd7476257fbcb9a6432c69e9f2de6ac77c840557f2f04499a53e92990377d00.jpg",
"prompt_ids": [
"2d9829a8-3b9a-4238-b493-ac8e2d9bbe18",
"08bd9ae7-fd9e-48f5-a74a-9eddc961fc8c"
],
"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": 1793206780.4568262,
"focus_label": "aujourd’hui"
},
"abc6e852574fa821f2b272ba2a783fb9d9f1ec25d2b8163b105015ad320703da": {
"file": "abc6e852574fa821f2b272ba2a783fb9d9f1ec25d2b8163b105015ad320703da.jpg",
"prompt_ids": [
"d4f113f6-153e-4567-ad41-6ce50da74eca",
"3bbea9a0-2186-4a63-bee9-226cf4b44e74"
],
"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": 1793217002.2921214,
"focus_label": "aujourd’hui"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+9 -2
View File
@@ -1,7 +1,14 @@
# Configuration générale Alfred (page /config) # Configuration générale Alfred (page /config)
# ALFRED_ALLOW_WEB_RESTART=true # ALFRED_ALLOW_WEB_RESTART=true
ALFRED_NAME=Dona ALFRED_NAME=Magalie
WAKE_WORD=dona WAKE_WORD=magalie
PIPER_VOICE=fr_FR-upmc-medium PIPER_VOICE=fr_FR-upmc-medium
ALFRED_OLLAMA_URL= ALFRED_OLLAMA_URL=
HA_WEATHER_ENTITY= HA_WEATHER_ENTITY=
STT_ENGINE=wyoming
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
+17
View File
@@ -0,0 +1,17 @@
{
"version": 1,
"rows": [
{
"kind": "lights_on",
"label": "Lumières allumées"
},
{
"kind": "indoor_temperature",
"label": "Volets fermés"
},
{
"kind": "covers_closed",
"label": "Température intérieure"
}
]
}
+5 -5
View File
@@ -1,7 +1,7 @@
# Identité et comportement d'Alfred # Identité et comportement de Magalie
Tu es Alfred, le majordome vocal personnel de la maison. Tu es Magalie, le majordome vocal personnel de la maison.
Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est Hermes Agent. Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
## Langue et style ## Langue et style
@@ -18,10 +18,10 @@ Tu t'appelles Alfred, même si le moteur logiciel qui te permet de raisonner est
- Si tu ne peux pas effectuer une action, explique-le simplement. - Si tu ne peux pas effectuer une action, explique-le simplement.
## Identité ## Identité
- Si l'on te demande ton nom, réponds : « Je m'appelle Alfred. » - Si l'on te demande ton nom, réponds : « Je m'appelle Magalie. »
# Alfred # Magalie
Tu disposes des outils et compétences configurés Tu disposes des outils et compétences configurés
dans Hermes Agent. dans Hermes Agent.
@@ -0,0 +1,31 @@
# Identité et comportement d'Alfred
Tu es Alfred, le majordome vocal personnel de la maison.
Tu t'appelles Alfred, 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 Alfred. »
# Alfred
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 @@
{"name": "Magalie"}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11
View File
@@ -73,5 +73,16 @@
"started_at": null, "started_at": null,
"created_at": 1793117300.2246997, "created_at": 1793117300.2246997,
"state": "cancelled" "state": "cancelled"
},
{
"id": "c834bdefc8",
"label": "Minuteur général",
"kind": "timer",
"duration": 1020.0,
"elapsed": 1020.0,
"started_at": null,
"created_at": 1793205436.8959317,
"state": "finished",
"finished_at": 1793206457.1510372
} }
] ]
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""MCP stdio JSONL Alfred Listes — stdlib uniquement, aucun sleep/terminal."""
from __future__ import annotations
import json
import os
import sys
from urllib import request, error
BASE = os.getenv('ALFRED_LISTS_API','http://127.0.0.1:54321').rstrip('/')
KIND = {'type':'string','enum':['courses','taches'],'description':'courses = liste de courses ; taches = liste de tâches.'}
LABEL = {'type':'string','minLength':1,'maxLength':160,'description':'Texte exact à ajouter, ex. pain, peindre le mur.'}
ID = {'type':'integer','minimum':1,'description':'ID fourni par list_items ; préférable au nom pour les suppressions.'}
def tool(name,desc,properties=None,required=None):
return {'name':name,'description':desc,
'inputSchema':{'type':'object','properties':properties or {},'required':required or [],'additionalProperties':False}}
TOOLS = [
tool('add_item','Ajoute immédiatement un élément à la liste de courses ou de tâches Alfred. Exemples : « ajoute du pain sur la liste de courses » => kind=courses label=pain ; « ajoute peindre le mur à ma liste de tâches » => kind=taches label=peindre le mur. Ne jamais confirmer sans succès.',{'kind':KIND,'label':LABEL},['kind','label']),
tool('list_items','Affiche les éléments enregistrés et leurs statuts, dans une seule liste ou les deux.',{'kind':KIND}),
tool('complete_item','Coche un élément comme terminé/acheté. Donner son id ou son label exact et sa liste.',{'kind':KIND,'id':ID,'label':LABEL},['kind']),
tool('reopen_item','Décoche un élément terminé.',{'kind':KIND,'id':ID,'label':LABEL},['kind']),
tool('delete_item','Supprime un élément précis demandé par utilisateur.',{'kind':KIND,'id':ID,'label':LABEL},['kind']),
tool('clear_completed','Supprime UNIQUEMENT les éléments déjà cochés de la liste indiquée, sur demande explicite.',{'kind':KIND},['kind']),
]
def invoke(name,args):
if name not in {t['name'] for t in TOOLS}: raise ValueError('Outil inconnu.')
if not isinstance(args,dict): raise ValueError('Arguments invalides.')
allowed=set(next(t['inputSchema']['properties'] for t in TOOLS if t['name']==name))
if any(key not in allowed for key in args): raise ValueError('Argument inattendu.')
if name == 'list_items':
# The GET endpoint always returns both lists; restrict tool answer if requested.
req = request.Request(BASE+'/api/alfred/lists',headers={'Accept':'application/json'})
else:
body=json.dumps({'action':name,**args},ensure_ascii=False).encode('utf-8')
req=request.Request(BASE+'/api/alfred/lists/action',data=body,method='POST',
headers={'Content-Type':'application/json','X-Alfred-MCP-Local':'lists'})
try:
with request.urlopen(req, timeout=8) as resp: data=json.loads(resp.read(1_000_000))
except error.HTTPError as exc:
try: reason=json.loads(exc.read()).get('error',f'HTTP {exc.code}')
except Exception: reason=f'HTTP {exc.code}'
raise ValueError(reason) from exc
except (error.URLError,TimeoutError) as exc:
raise ValueError(f'API Alfred indisponible : {exc}') from exc
if not data.get('ok'): raise ValueError(data.get('error','Erreur API Alfred.'))
if name == 'list_items' and args.get('kind'):
kind = args['kind']
if kind not in ('courses','taches'): raise ValueError('Liste invalide.')
data['lists']={kind:data['lists'][kind]}
data['counts']={kind:data['counts'][kind]}
return data
def reply(obj):
sys.stdout.write(json.dumps(obj,ensure_ascii=False,separators=(',',':'))+'\n');sys.stdout.flush()
def handle(obj):
if not isinstance(obj,dict) or 'id' not in obj:return
mid=obj['id'];method=obj.get('method','')
try:
if method=='initialize':
asked=(obj.get('params') or {}).get('protocolVersion','2025-06-18')
version=asked if asked in ('2025-06-18','2025-03-26','2024-11-05') else '2025-06-18'
out={'protocolVersion':version,'capabilities':{'tools':{'listChanged':False}},
'serverInfo':{'name':'alfred-listes','version':'1.0.0'},
'instructions':'Pour courses/tâches, utilise ces outils. Ne simule pas la création et confirme uniquement un succès API.'}
elif method=='tools/list':out={'tools':TOOLS}
elif method=='ping':out={}
elif method=='tools/call':
p=obj.get('params') or {}
try:
result=invoke(str(p.get('name','')),p.get('arguments') or {})
out={'content':[{'type':'text','text':json.dumps(result,ensure_ascii=False)}],'isError':False}
except Exception as e:
out={'content':[{'type':'text','text':str(e)}],'isError':True}
else:
reply({'jsonrpc':'2.0','id':mid,'error':{'code':-32601,'message':'Method not found'}});return
reply({'jsonrpc':'2.0','id':mid,'result':out})
except Exception as e:
reply({'jsonrpc':'2.0','id':mid,'error':{'code':-32603,'message':str(e)}})
if __name__=='__main__':
for line in sys.stdin:
if not line.strip():continue
try:handle(json.loads(line))
except (ValueError,TypeError) as exc:print('Erreur message MCP :',exc,file=sys.stderr)
+11 -1
View File
@@ -445,7 +445,17 @@ def register_admin_console(app, wake, audio_settings):
if weather: _save_env(WEATHER_ENV, weather) if weather: _save_env(WEATHER_ENV, weather)
except OSError as exc: except OSError as exc:
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500 return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
return jsonify(ok=True, restart_required=True, values=_read_config()) # 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') @bp.get('/api/control/health')
@_require_admin @_require_admin
+209
View File
@@ -0,0 +1,209 @@
"""Alfred dashboard V1 — safe, read-only aggregated statuses + allowed quick actions.
No credentials, IP addresses, entity ids or full HA states are sent to the browser.
The dashboard probes are advisory; MCP success is never inferred from a folder.
"""
from __future__ import annotations
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
import threading
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo
from flask import Blueprint, jsonify, request
_CACHE = {'expires': 0.0, 'value': None}
_LOCK = threading.Lock()
def _json_get(url: str, token: str = '', timeout: float = 2.0):
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
raise ValueError('URL non HTTP(S)')
headers = {'Accept': 'application/json', 'User-Agent': 'Alfred-Dashboard/1'}
if token:
headers['Authorization'] = 'Bearer ' + token
req = Request(url, headers=headers)
with urlopen(req, timeout=timeout) as response:
return json.loads(response.read(15_000_000).decode('utf-8'))
def _stat_local():
disk = shutil.disk_usage(Path(__file__).resolve().parents[1])
percent = round(disk.used * 100 / disk.total) if disk.total else None
mem_pct = None
try:
m = {}
for line in Path('/proc/meminfo').read_text().splitlines():
key, _, value = line.partition(':')
if key in ('MemTotal', 'MemAvailable'):
m[key] = int(value.strip().split()[0])
if m.get('MemTotal'):
mem_pct = round((m['MemTotal'] - m.get('MemAvailable', 0)) * 100 / m['MemTotal'])
except (OSError, ValueError, ZeroDivisionError):
pass
try:
load_1m = round(os.getloadavg()[0], 2)
except (OSError, AttributeError):
load_1m = None
return {'disk_percent': percent, 'memory_percent': mem_pct, 'load_1m': load_1m}
def _safe_float(value):
try:
n = float(value)
return round(n, 1) if abs(n) < 10000 else None
except (TypeError, ValueError):
return None
def _ha_data():
base = os.getenv('HA_URL', '').strip().rstrip('/')
token = os.getenv('HA_TOKEN', '').strip()
if not (base and token):
return {'available': False, 'detail': 'Home Assistant non configuré'}, {'available': False}
try:
states = _json_get(base + '/api/states', token, timeout=2.5)
if not isinstance(states, list):
raise ValueError('États Home Assistant invalides')
except (ValueError, HTTPError, URLError, OSError, TimeoutError):
return {'available': False, 'detail': 'Home Assistant indisponible'}, {'available': False}
lights = sum(x.get('state') == 'on' for x in states if str(x.get('entity_id','')).startswith('light.'))
covers = sum(x.get('state') == 'closed' for x in states if str(x.get('entity_id','')).startswith('cover.'))
t = None
candidates = []
for x in states:
eid = str(x.get('entity_id',''))
attrs = x.get('attributes') or {}
if eid.startswith('climate.'):
v = _safe_float(attrs.get('current_temperature'))
if v is not None: candidates.append((0 if 'salon' in eid.lower() else 2, v))
elif eid.startswith('sensor.') and attrs.get('device_class') == 'temperature':
v = _safe_float(x.get('state'))
if v is not None: candidates.append((1 if 'salon' in eid.lower() else 3, v))
if candidates: t = sorted(candidates, key=lambda x: x[0])[0][1]
condition_map = {
'sunny':'Ensoleillé', 'clear-night':'Ciel dégagé', 'partlycloudy':'Éclaircies',
'cloudy':'Nuageux', 'rainy':'Pluvieux', 'pouring':'Fortes pluies',
'snowy':'Neigeux', 'fog':'Brouillard', 'windy':'Venteux',
'lightning':'Orageux', 'hail':'Grêle', 'exceptional':'Conditions exceptionnelles',
}
weather = {'available': False}
weather_entities = [x for x in states if str(x.get('entity_id','')).startswith('weather.')]
selected_weather = (os.getenv('HA_WEATHER_ENTITY') or os.getenv('WEATHER_ENTITY') or '').strip()
if selected_weather:
weather_entities.sort(key=lambda item: item.get('entity_id') != selected_weather)
if weather_entities:
x = weather_entities[0]
attrs = x.get('attributes') or {}
condition = str(x.get('state') or '')
weather = {
'available': True,
'temperature': _safe_float(attrs.get('temperature')),
'condition': condition_map.get(condition, 'Conditions non précisées'),
'condition_id': condition,
'unit': str(attrs.get('temperature_unit') or '°C')[:6],
}
return {'available': True, 'lights_on': lights, 'covers_closed': covers,
'indoor_temperature': t}, weather
def _probe(url: str):
if not url:
return 'unconfigured'
try:
_json_get(url, timeout=1.8)
return 'ok'
except (ValueError, HTTPError, URLError, OSError, TimeoutError):
return 'offline'
def _services():
ollama = (os.getenv('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or
os.getenv('OLLAMA_URL') or '').strip().rstrip('/')
comfy = (os.getenv('COMFYUI_URL') or os.getenv('ALFRED_COMFYUI_URL') or '').strip().rstrip('/')
hermes = bool(shutil.which('hermes') or Path('/root/.local/bin/hermes').is_file()
or Path('/opt/hermes/venv/bin/hermes').is_file())
return {
'ollama': _probe(ollama + '/api/tags') if ollama else 'unconfigured',
'comfyui': _probe(comfy + '/system_stats') if comfy else 'unconfigured',
# Presence of a binary is not proof of a healthy agent process.
'hermes': 'installed' if hermes else 'unknown',
}
def dashboard_snapshot():
now = time.monotonic()
with _LOCK:
if _CACHE['value'] is not None and now < _CACHE['expires']:
return _CACHE['value']
home, weather = _ha_data()
result = {'home': home, 'weather': weather,
'system': _stat_local(), 'services': _services(),
'updated_at': datetime.now(ZoneInfo('Europe/Paris')).isoformat()}
_CACHE.update(value=result, expires=time.monotonic() + 25)
return result
# Only these preset actions can invoke Hermes from the browser.
_QUICK_ACTIONS = {
'proxmox': 'Vérifie l’état de mes serveurs Proxmox avec les outils dont tu disposes. Si tu ne peux pas y accéder, indique-le clairement, sans inventer de résultat.',
'wazuh': 'Consulte les alertes de sécurité Wazuh récentes avec tes outils. Si l’accès échoue, dis-le clairement et ne fabrique aucune alerte.',
}
def _trusted_local_request():
if request.headers.get('X-Alfred-Request') != '1' or not request.is_json:
return False
origin = request.headers.get('Origin', '')
# Browsers use Origin for JSON POST; allow trusted headless local clients
# only through loopback when Origin is absent.
if origin:
o = urlparse(origin)
return o.scheme in ('http','https') and o.netloc == request.host
return request.remote_addr in ('127.0.0.1', '::1')
def register_dashboard(app, quick_command=None):
bp = Blueprint('alfred_home_visual_v1', __name__)
@bp.get('/api/alfred/dashboard')
def summary():
return jsonify(dashboard_snapshot())
@bp.post('/api/alfred/dashboard/command')
def action():
if not _trusted_local_request():
return jsonify(ok=False, error='Requête non autorisée.'), 403
data = request.get_json(silent=True) or {}
key = data.get('action', '')
if key == 'comfyui':
description = data.get('description', '')
if not isinstance(description, str) or not (4 <= len(description.strip()) <= 450):
return jsonify(ok=False, error='Description attendue (4 à 450 caractères).'), 400
prompt = 'Génère une image avec ComfyUI correspondant à cette description : ' + description.strip()
else:
prompt = _QUICK_ACTIONS.get(key)
if prompt is None:
return jsonify(ok=False, error='Action inconnue.'), 400
if quick_command is None:
return jsonify(ok=False, error='Commande interactive indisponible.'), 503
try:
ok = bool(quick_command(prompt))
except Exception:
return jsonify(ok=False, error='Impossible de soumettre la commande.'), 500
if not ok:
# ALFRED_IDENTITY_GLASS_V1
return jsonify(ok=False, error=os.getenv('ALFRED_NAME', 'Alfred') + ' traite déjà une autre demande.'), 409
return jsonify(ok=True, queued=True), 202
app.register_blueprint(bp)
+194
View File
@@ -0,0 +1,194 @@
"""Options de carte Maison de l'accueil Glass, en conservant l'auth Admin.
Aucune URL arbitraire, commande, ou credentiel stocke dans la configuration.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import secrets
import threading
import time
from urllib.error import URLError, HTTPError
from urllib.request import Request, urlopen
from flask import Blueprint, jsonify, request
from services import admin_console
ROOT = Path(__file__).resolve().parents[1]
SETTINGS = ROOT / 'config' / 'home_card.json'
LOCK = threading.RLock()
_CACHE = {'expires': 0.0, 'states': None}
_CACHE_LOCK = threading.Lock()
DOMAINS = {'light', 'cover', 'sensor', 'binary_sensor', 'switch', 'climate',
'input_boolean', 'person', 'device_tracker', 'lock', 'weather', 'fan'}
DEFAULT_ROWS = [
{'kind': 'lights_on', 'label': 'Lumières allumées'},
{'kind': 'covers_closed', 'label': 'Volets fermés'},
{'kind': 'indoor_temperature', 'label': 'Température intérieure'},
]
PRESETS = {'lights_on', 'covers_closed', 'indoor_temperature'}
def validate_rows(rows):
if not isinstance(rows, list) or not 1 <= len(rows) <= 3:
raise ValueError('Choisir entre 1 et 3 lignes pour le cadre Maison.')
out = []
for row in rows:
if not isinstance(row, dict) or set(row) - {'kind','label','entity_id'}:
raise ValueError('Ligne de configuration invalide.')
kind = row.get('kind')
if kind not in (*PRESETS, 'entity'):
raise ValueError('Type de ligne non autorisé.')
label = row.get('label', '')
if not isinstance(label, str) or not 1 <= len(label.strip()) <= 38 or any(ord(c) < 32 for c in label):
raise ValueError('Chaque intitulé doit contenir 1 à 38 caractères.')
item = {'kind': kind, 'label': label.strip()}
if kind == 'entity':
eid = row.get('entity_id', '')
if not isinstance(eid, str) or len(eid) > 120 or not re.fullmatch(r'[a-z][a-z0-9_]*\.[a-z0-9_]+', eid):
raise ValueError('Identifiant Home Assistant invalide.')
if eid.split('.')[0] not in DOMAINS:
raise ValueError('Domaine Home Assistant non autorisé pour le cadre Maison.')
item['entity_id'] = eid
out.append(item)
return out
def read_rows():
try:
loaded = json.loads(SETTINGS.read_text(encoding='utf-8'))
return validate_rows(loaded['rows'])
except (OSError, ValueError, KeyError, TypeError):
return [dict(row) for row in DEFAULT_ROWS]
def save_rows(rows):
valid = validate_rows(rows)
SETTINGS.parent.mkdir(parents=True, exist_ok=True)
with LOCK:
tmp = SETTINGS.with_name(SETTINGS.name + '.' + secrets.token_hex(6) + '.tmp')
try:
with tmp.open('w', encoding='utf-8') as out:
json.dump({'version': 1, 'rows': valid}, out, ensure_ascii=False, indent=2)
out.write('\n'); out.flush(); os.fsync(out.fileno())
os.chmod(tmp, 0o600)
tmp.replace(SETTINGS)
finally:
tmp.unlink(missing_ok=True)
return valid
def states_from_ha():
now = time.monotonic()
with _CACHE_LOCK:
if _CACHE['states'] is not None and now < _CACHE['expires']:
return _CACHE['states']
base = os.getenv('HA_URL', '').strip().rstrip('/')
token = os.getenv('HA_TOKEN', '').strip()
if not base or not token or not (base.startswith('http://') or base.startswith('https://')):
raise ValueError('Connexion Home Assistant non configurée pour le tableau de bord.')
from services.alfred_dashboard import _json_get
data = _json_get(base + '/api/states', token=token, timeout=3)
if not isinstance(data, list):
raise ValueError('Réponse Home Assistant invalide.')
with _CACHE_LOCK:
_CACHE.update(states=data, expires=time.monotonic() + 20)
return data
def _pretty_state(state, attrs):
if state in ('unknown','unavailable',''):
return 'Indisponible'
value = {'on':'Allumé', 'off':'Éteint', 'open':'Ouvert', 'closed':'Fermé',
'home':'Présent', 'not_home':'Absent', 'locked':'Verrouillé',
'unlocked':'Déverrouillé', 'heat':'Chauffage', 'cool':'Climatisation'}.get(state, state)
unit = str(attrs.get('unit_of_measurement') or '')
if unit and len(unit) <= 12:
return str(value) + ' ' + unit
return str(value)[:42]
def resolve_rows(rows, states, dashboard_home=None):
lookup = {str(s.get('entity_id')): s for s in states if isinstance(s, dict)}
output = []
for item in rows:
kind, label = item['kind'], item['label']
value = 'Indisponible'
if kind == 'lights_on':
value = str(sum(e.get('state') == 'on' for eid,e in lookup.items() if eid.startswith('light.')))
elif kind == 'covers_closed':
value = str(sum(e.get('state') == 'closed' for eid,e in lookup.items() if eid.startswith('cover.')))
elif kind == 'indoor_temperature':
candidates=[]
for eid,e in lookup.items():
attr=e.get('attributes') or {}
try:
if eid.startswith('climate.'):
t=float(attr.get('current_temperature'))
elif eid.startswith('sensor.') and attr.get('device_class') == 'temperature':
t=float(e.get('state'))
else: continue
if abs(t) < 150: candidates.append((0 if 'salon' in eid else 1,t))
except (TypeError,ValueError): pass
if candidates:
candidates.sort(key=lambda x:x[0]); value=f'{candidates[0][1]:g} °C'
elif kind == 'entity':
e=lookup.get(item['entity_id'])
if e:
value=_pretty_state(str(e.get('state') or ''), e.get('attributes') or {})
output.append({'label':label,'value':value,'available':value!='Indisponible'})
return output
def register_home_glass(app):
bp = Blueprint('alfred_home_glass_v1', __name__)
@bp.get('/api/control/home-card')
@admin_console._require_admin
def get_config():
return jsonify(rows=read_rows())
@bp.post('/api/control/home-card')
@admin_console._require_admin
def set_config():
data=request.get_json(silent=True)
if not isinstance(data,dict) or set(data)!={'rows'}:
return jsonify(error='JSON invalide.'), 400
try:
result=save_rows(data['rows'])
except (ValueError, OSError) as exc:
return jsonify(error=str(exc)), 400
return jsonify(ok=True,rows=result,applied_immediately=True)
@bp.get('/api/control/home-entities')
@admin_console._require_admin
def list_entities():
try:
states=states_from_ha()
except (OSError, ValueError, HTTPError, URLError) as exc:
return jsonify(error='Home Assistant inaccessible ou non configuré.', detail=str(exc)[:150]), 503
options=[]
for entity in states:
eid=entity.get('entity_id','')
if not isinstance(eid,str) or eid.split('.')[0] not in DOMAINS:
continue
attrs=entity.get('attributes') or {}
friendly=str(attrs.get('friendly_name') or eid)[:80]
options.append({'id':eid,'name':friendly})
options.sort(key=lambda x:(x['name'].casefold(),x['id']))
return jsonify(entities=options[:1500])
@bp.get('/api/alfred/home-card')
def live_card():
rows=read_rows()
try:
states=states_from_ha()
values=resolve_rows(rows,states)
except (OSError, ValueError, HTTPError, URLError):
values=[{'label':r['label'],'value':'Indisponible','available':False} for r in rows]
return jsonify(rows=values,available=any(x['available'] for x in values))
app.register_blueprint(bp)
+204
View File
@@ -0,0 +1,204 @@
"""Identity editor and current assistant name, authenticated by Alfred's admin UI.
Identity text stays in config/identity.md. All writes are atomic; dated revisions
are kept separately. Technical names/paths/API identifiers are never renamed.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import threading
from datetime import datetime
from flask import Blueprint, jsonify, render_template, request
from services import admin_console
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ROOT / 'config'
IDENTITY = CONFIG / 'identity.md'
TRACKER = CONFIG / 'identity_name_state.json'
HISTORY = CONFIG / 'identity_history'
LOCK = threading.RLock()
MAX_SIZE = 40_000
ASSISTANT_RE = re.compile(r"(?<![A-Za-z0-9_.-])Alfred(?![A-Za-z0-9_.-])")
def active_name():
name = (os.getenv('ALFRED_NAME') or 'Alfred').strip()
return name if 2 <= len(name) <= 30 else 'Alfred'
def identity_path():
# Avoid an admin Web editor writing outside the identity config directory.
raw = Path(os.getenv('ALFRED_IDENTITY_FILE', str(IDENTITY))).expanduser()
resolved = raw.resolve()
if resolved.parent != CONFIG.resolve() or raw.is_symlink():
raise ValueError('Le fichier ALFRED_IDENTITY_FILE est externe à config/ : édition Web refusée.')
return resolved
def sha(text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def _read_text():
path = identity_path()
return path.read_text(encoding='utf-8') if path.exists() else ''
def _save_atomic(path, content, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(dir=str(path.parent), prefix='.identity-', suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
fh.write(content)
fh.flush()
os.fsync(fh.fileno())
os.chmod(temp, mode)
os.replace(temp, path)
finally:
if os.path.exists(temp):
os.unlink(temp)
def _revision(text, label):
HISTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
filename = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + '_' + label + '.md'
dest = HISTORY / filename
fd = os.open(dest, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(text)
def _old_name(text):
if TRACKER.exists():
try:
data = json.loads(TRACKER.read_text(encoding='utf-8'))
value = data.get('name', '')
if isinstance(value, str) and 2 <= len(value) <= 30:
return value
except (OSError, ValueError, TypeError):
pass
# Existing identity files tend to contain "Tu t'appelles Alfred".
match = re.search(r"\bTu\s+t['’]appelles\s+([A-Za-zÀ-ÖØ-öø-ÿ-]{2,30})\b", text, flags=re.I)
if match:
return match.group(1)
return 'Alfred'
def _replace_persona_name(text, old, new):
if old == new:
return text
# Replace proper standalone name only; not ALFRED_NAME, alfred.service,
# MCP_alfred_timers, /opt/alfred etc.
pattern = re.compile(r'(?<![A-Za-z0-9_.-])' + re.escape(old) + r'(?![A-Za-z0-9_-]|\.[A-Za-z0-9_-])')
changed = pattern.sub(lambda _m: new, text)
if old != new and old and old[0].lower() in 'aeiouy' and new and new[0].lower() not in 'aeiouyh':
changed = changed.replace("d'" + new, 'de ' + new).replace('d’' + new, 'de ' + new)
if changed == text:
# No existing self-name found: insert an explicit, visible identity rule.
changed = f'# Identité actuelle\nTu t’appelles {new}.\n\n' + text
return changed
def sync_identity_name(name=None):
"""Called on startup and after a /config rename. Never erases custom prose."""
new = name or active_name()
if not isinstance(new, str) or not 2 <= len(new) <= 30 or any(c in new for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
raise ValueError('Nom d’assistant invalide.')
with LOCK:
path = identity_path()
original = _read_text()
if not original.strip():
# No identity installed: don't silently create a new persona.
raise ValueError('Le fichier identity.md est vide ou absent.')
old = _old_name(original)
updated = _replace_persona_name(original, old, new)
if updated != original:
_revision(original, 'avant_nom')
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
_save_atomic(path, updated, mode)
# Keep original metadata if the file text was unchanged; preserve editability.
if not TRACKER.exists() or old != new:
_save_atomic(TRACKER, json.dumps({'name':new}, ensure_ascii=False) + '\n')
return {'changed': updated != original, 'name':new}
def identity_for_hermes(text):
"""Runtime guard: current name wins even if a legacy identity wasn't synced."""
new = active_name()
old = _old_name(text)
content = _replace_persona_name(text, old, new) if text else text
return ('Règle d’identité prioritaire : ton nom actuel est ' + new +
'. Si l’on demande ton nom, réponds « Je m’appelle ' + new + ' ».\n\n' + content).strip()
def register_identity_page(app):
bp = Blueprint('alfred_identity_glass', __name__)
@bp.get('/identity')
@bp.get('/identity/')
def page():
# Never embed identity text or secrets in unauthenticated HTML.
return render_template('alfred_identity_glass.html')
@bp.get('/api/alfred/identity/name')
def name_api():
res = jsonify(name=active_name(), wake_word=os.getenv('WAKE_WORD', 'alfred'))
res.headers['Cache-Control'] = 'no-store'
return res
@bp.get('/api/control/identity')
@admin_console._require_admin
def get_identity():
try:
with LOCK:
content = _read_text()
except (OSError, ValueError) as e:
return jsonify(error=str(e)), 503
res = jsonify(name=active_name(), text=content, revision=sha(content),
file='config/identity.md')
res.headers['Cache-Control'] = 'no-store, private'
return res
@bp.post('/api/control/identity')
@admin_console._require_admin
def save_identity():
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify(error='Données JSON invalides.'), 400
text = data.get('text')
revision = data.get('revision')
if not isinstance(text, str) or not isinstance(revision, str):
return jsonify(error='Texte ou révision manquante.'), 400
if not text.strip() or len(text.encode('utf-8')) > MAX_SIZE or '\x00' in text:
return jsonify(error='L’identité doit être non vide et ne pas dépasser 40 Ko.'), 400
if not re.fullmatch(r'[0-9a-f]{64}', revision):
return jsonify(error='Révision invalide.'), 400
try:
with LOCK:
path = identity_path()
old = _read_text()
if sha(old) != revision:
return jsonify(error='Le fichier a changé depuis ton chargement. Recharge avant d’enregistrer.'), 409
if text != old:
_revision(old, 'avant_edition')
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
_save_atomic(path, text, mode)
res = jsonify(ok=True, revision=sha(text), name=active_name(),
updated=text != old, file='config/identity.md')
res.headers['Cache-Control'] = 'no-store, private'
return res
except (OSError, ValueError) as e:
return jsonify(error='Enregistrement impossible : '+str(e)), 500
app.register_blueprint(bp)
# If a renamed assistant was configured before installation, reconcile on startup.
try:
sync_identity_name()
except (OSError, ValueError) as e:
app.logger.warning('Synchronisation identité différée : %s', e)
+249
View File
@@ -0,0 +1,249 @@
"""Alfred Listes V1 — courses et tâches locales, SQLite, commandes vocales sûres.
Aucune dépendance externe. Le serveur MCP n'attend jamais et ne lance jamais de shell.
"""
from __future__ import annotations
from datetime import datetime, timezone
from contextlib import contextmanager
import os
from pathlib import Path
import re
import sqlite3
import threading
import unicodedata
from urllib.parse import urlparse
from flask import Blueprint, jsonify, request
DB_PATH = Path(os.getenv('ALFRED_LISTS_DB', str(Path(__file__).resolve().parents[1] / 'data' / 'alfred_lists.sqlite3')))
LOCK = threading.RLock()
KINDS = {'courses': 'courses', 'course': 'courses', 'taches': 'taches', 'tache': 'taches',
'tasks': 'taches', 'task': 'taches', 'todo': 'taches', 'shopping': 'courses'}
MAX_ITEMS = 500
MAX_LEN = 160
def fold(s):
return ''.join(c for c in unicodedata.normalize('NFKD', s.casefold()) if not unicodedata.combining(c))
def normalize_kind(kind):
if not isinstance(kind, str) or fold(kind.strip()) not in KINDS:
raise ValueError('Liste inconnue : choisir courses ou taches.')
return KINDS[fold(kind.strip())]
def clean_label(label, kind=None):
if not isinstance(label, str):
raise ValueError('Le nom de l’élément doit être un texte.')
label = ' '.join(label.split()).strip(' .!?;,')
if kind == 'courses':
label = re.sub(r"^(?:du\s+|de\s+la\s+|des\s+|de\s+l['’])", '', label, flags=re.I).strip()
if not label or len(label) > MAX_LEN or any(ord(x) < 32 for x in label):
raise ValueError('Nom attendu : de 1 à 160 caractères.')
return label
def _conn():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH), timeout=6)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA busy_timeout=6000')
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('''CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('courses','taches')),
label TEXT NOT NULL,
normalized TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(kind, normalized)
)''')
return conn
@contextmanager
def _db():
with LOCK:
conn = _conn()
try:
with conn:
yield conn
finally:
conn.close()
def _row(row):
return {'id': row['id'], 'kind': row['kind'], 'label': row['label'],
'done': bool(row['done']), 'created_at': row['created_at']}
def list_items(kind=None):
if kind is not None: kind = normalize_kind(kind)
with _db() as conn:
q = ('SELECT id, kind, label, done, created_at FROM items '
+ ('WHERE kind=? ' if kind else '')
+ 'ORDER BY done ASC, id DESC LIMIT 1000')
rows = conn.execute(q, (kind,) if kind else ()).fetchall()
items = [_row(r) for r in rows]
result = {'courses': [], 'taches': []}
for item in items: result[item['kind']].append(item)
return {'ok': True, 'lists': result,
'counts': {k: {'active': sum(not r['done'] for r in v), 'total': len(v)}
for k, v in result.items()}}
def add_item(kind, label):
kind = normalize_kind(kind)
label = clean_label(label, kind)
norm = fold(label)
now = datetime.now(timezone.utc).isoformat()
with _db() as conn:
row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, norm)).fetchone()
if row:
if row['done']:
conn.execute('UPDATE items SET done=0, label=?, updated_at=? WHERE id=?', (label, now, row['id']))
existing = False
else: existing = True
item_id = row['id']
else:
n = conn.execute('SELECT COUNT(*) FROM items WHERE kind=?', (kind,)).fetchone()[0]
if n >= MAX_ITEMS: raise ValueError('Liste pleine (500 éléments maximum).')
item_id = conn.execute('INSERT INTO items (kind,label,normalized,done,created_at,updated_at) VALUES (?,?,?,?,?,?)',
(kind, label, norm, 0, now, now)).lastrowid
existing = False
row = conn.execute('SELECT * FROM items WHERE id=?', (item_id,)).fetchone()
return {'ok': True, 'item': _row(row), 'already_present': existing}
def _resolve(conn, kind, item_id=None, label=None):
kind = normalize_kind(kind)
if item_id is not None:
try: n = int(item_id)
except (ValueError, TypeError): raise ValueError('Identifiant invalide.')
row = conn.execute('SELECT * FROM items WHERE id=? AND kind=?', (n, kind)).fetchone()
elif label is not None:
label = clean_label(label,kind)
row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, fold(label))).fetchone()
else:
raise ValueError('Indique l’identifiant ou le nom exact de l’élément.')
if row is None: raise ValueError('Élément introuvable dans la liste.')
return row
def change_item(action, kind, item_id=None, label=None):
if action not in ('complete_item', 'reopen_item', 'delete_item'):
raise ValueError('Action inconnue.')
with _db() as conn:
row = _resolve(conn, kind, item_id, label)
if action == 'delete_item':
conn.execute('DELETE FROM items WHERE id=?', (row['id'],))
else:
conn.execute('UPDATE items SET done=?, updated_at=? WHERE id=?',
(int(action == 'complete_item'), datetime.now(timezone.utc).isoformat(), row['id']))
return {'ok': True, 'item': _row(row), 'action': action}
def clear_completed(kind):
kind = normalize_kind(kind)
with _db() as conn:
count = conn.execute('DELETE FROM items WHERE kind=? AND done=1', (kind,)).rowcount
return {'ok': True, 'deleted': count}
def execute(action, data):
if action == 'list_items': return list_items(data.get('kind'))
if action == 'add_item': return add_item(data.get('kind'), data.get('label'))
if action in ('complete_item', 'reopen_item', 'delete_item'):
return change_item(action, data.get('kind'), data.get('id'), data.get('label'))
if action == 'clear_completed': return clear_completed(data.get('kind'))
raise ValueError('Action inconnue.')
# Routes vocales directes pour les cas courants : les LLM ne peuvent pas inventer le succès.
_ADD_RE = re.compile(
r"^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(?P<label>.+?)\s+"
r"(?:a|à|sur|dans)\s+(?:ma|la|mes|notre)\s+liste\s+(?:de\s+)?"
r"(?P<kind>courses?|taches?|tâches?|todo)\s*[.!?]*\s*$", re.I)
_VIEW_RE = re.compile(
r"^\s*(?:(?:affiche|montre|lis|donne|consulte)\s+)?"
r"(?:(?:moi|nous)\s+)?(?:(?:qu['’]est.ce\s+qu['’]il\s+y\s+a\s+(?:dans|sur)\s+)|(?:quelle\s+est\s+))?"
r"(?:ma|la|notre)\s+liste\s+(?:de\s+)?(?P<kind>courses?|taches?|tâches?|todo)\s*[.!?]*\s*$", re.I)
def parse_voice(text):
"""Return (action, kind, label) for unambiguous commands only."""
if not isinstance(text, str): return None
# Case insensitive + accent insensitive, but keep original item spelling.
normalized = fold(text).replace('’', "'")
m = _ADD_RE.match(normalized)
if m:
# Re-extract original label by matching an accent-tolerant prefix/suffix.
orig = re.match(r'^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(.+?)\s+(?:à|a|sur|dans)\s+(?:ma|la|mes|notre)\s+liste\s+(?:de\s+)?(?:courses?|taches?|tâches?|todo)\s*[.!?]*\s*$',text,re.I)
label = orig.group(1) if orig else m.group('label')
return ('add_item', normalize_kind(m.group('kind')), label)
m = _VIEW_RE.match(normalized)
if m: return ('list_items', normalize_kind(m.group('kind')), None)
return None
def handle_voice(text, notify=None):
parsed = parse_voice(text)
if not parsed: return None
action, kind, label = parsed
result = execute(action, {'kind': kind, 'label': label})
if action == 'add_item':
item = result['item']['label']
dest = 'liste de courses' if kind == 'courses' else 'liste de tâches'
message = (f'{item} est déjà dans ta {dest}.' if result['already_present']
else f"J’ai ajouté {item} à ta {dest}.")
if not result['already_present'] and notify: notify()
return message
active = [x['label'] for x in result['lists'][kind] if not x['done']]
if not active: return 'Ta liste de courses est vide.' if kind == 'courses' else 'Ta liste de tâches est vide.'
category = 'courses' if kind == 'courses' else 'tâches'
readable = ', '.join(active[:12])
return f'Voici ta liste de {category} : {readable}' + (f', et {len(active)-12} autres.' if len(active)>12 else '.')
def _authorized_write():
if not request.is_json: return False
# local MCP path: no remote client may spoof this header
if request.remote_addr in ('127.0.0.1','::1') and request.headers.get('X-Alfred-MCP-Local') == 'lists':
return True
if request.headers.get('X-Alfred-Request') != '1': return False
origin = request.headers.get('Origin', '')
if origin:
u = urlparse(origin)
return u.scheme in ('http','https') and u.netloc == request.host
return request.remote_addr in ('127.0.0.1','::1')
def register_lists(app, socketio):
bp = Blueprint('alfred_lists_v1', __name__)
@bp.get('/api/alfred/lists')
def get_lists():
try: return jsonify(list_items())
except (OSError, sqlite3.Error) as exc:
return jsonify(ok=False, error='Stockage listes indisponible.'), 503
@bp.post('/api/alfred/lists/action')
def action_lists():
if not _authorized_write(): return jsonify(ok=False, error='Requête non autorisée.'), 403
data = request.get_json(silent=True)
if not isinstance(data, dict): return jsonify(ok=False, error='Objet JSON attendu.'), 400
action = data.get('action')
try:
result = execute(action, data)
except ValueError as exc:
return jsonify(ok=False, error=str(exc)), 400
except (sqlite3.Error, OSError) as exc:
return jsonify(ok=False, error='Stockage listes indisponible.'), 503
if action != 'list_items' and not result.get('already_present'):
socketio.emit('alfred_lists_changed', {'kind': data.get('kind'), 'action': action})
return jsonify(result)
app.register_blueprint(bp)
+115
View File
@@ -0,0 +1,115 @@
"""Journal admin Alfred Glass V2 : lecture seule du journal systemd d'Alfred.
Les donnees sont protegees par la meme session admin que /config.
Les sorties Hermes independantes ne sont PAS inventees/aggregees artificiellement.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
import re
import subprocess
from flask import Blueprint, jsonify, render_template, request
from services import admin_console
# Redaction defensive : le journal applicatif peut contenir des erreurs
# avec des secrets. Aucune methode ne peut promettre une suppression parfaite.
_SECRET = re.compile(
r'(?i)\b((?:PROXMOX_TOKEN_VALUE|HA_TOKEN|OPENAI_API_KEY|API_KEY|PASSWORD|SECRET|'
r'ACCESS_TOKEN|REFRESH_TOKEN|TOKEN|AUTHORIZATION|API[-_]?KEY|BEARER)\s*[:=]\s*)'
r'(?:["\']?[^\s,;"\']+["\']?)'
)
_BEARER = re.compile(r'(?i)\bBearer\s+[a-z0-9._~+/=-]{8,}')
_URI_SECRET = re.compile(r'(?i)([?&](?:token|key|api_key|password|secret)=)[^\s&#]+')
def redact(message: str) -> str:
result = str(message or '')[:2200]
result = _SECRET.sub(r'\1[MASQUÉ]', result)
result = _BEARER.sub('Bearer [MASQUÉ]', result)
result = _URI_SECRET.sub(r'\1[MASQUÉ]', result)
return result
def component(message: str) -> str:
upper = message.upper()
if any(k in upper for k in ('[ALFRED STT]', 'WHISPER', 'VOSK', 'WAKE WORD', 'WAKEWORD', 'TRANSCRIPTION')):
return 'Vocal'
if any(k in upper for k in ('[ALFRED PIPER]', '[ALFRED TTS]', 'WYOMING PIPER', 'SPEAK_ASYNC')):
return 'Piper'
if any(k in upper for k in ('MCP_', 'MCP ', 'TOOL CALL', 'LIST_NODES', 'CREATE_TIMER', 'HA_LIST')):
return 'MCP'
if any(k in upper for k in ('[ALFRED] ENVOI À HERMES', '[ALFRED] RÉPONSE HERMES', 'HERMES', 'AGENT')):
return 'Hermes'
return 'Système'
def level(message: str, priority: str) -> str:
if priority and priority.isdigit() and int(priority) <= 3:
return 'Erreur'
upper = message.upper()
if 'TRACEBACK' in upper or 'EXCEPTION' in upper or '[ERROR]' in upper or ' ERREUR ' in upper:
return 'Erreur'
if priority and priority.isdigit() and int(priority) == 4 or 'WARNING' in upper or 'ATTENTION' in upper:
return 'Avertissement'
return 'Information'
def read_journal(limit: int = 220):
cmd = ['journalctl', '--no-pager', '--quiet', '--output=json',
'--unit=alfred.service', '--lines='+str(limit)]
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=7, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError('Lecture du journal indisponible : '+type(exc).__name__) from exc
if result.returncode != 0:
raise RuntimeError('Lecture du journal refusée. Vérifier les droits systemd.')
items = []
for raw in result.stdout.splitlines()[-limit:]:
try:
record = json.loads(raw)
msg = str(record.get('MESSAGE') or '')
if not msg:
continue
# Certaines traces Flask contiennent des en-tetes d'authentification : prudence.
micros = str(record.get('__REALTIME_TIMESTAMP') or '0')
iso = datetime.fromtimestamp(int(micros)/1_000_000,timezone.utc).isoformat()
items.append({'at': iso, 'component':component(msg),
'level':level(msg,str(record.get('PRIORITY') or '')),
'message':redact(msg)})
except (TypeError, ValueError, OverflowError, json.JSONDecodeError, OSError):
continue
return items
def register_logs_page(app):
bp = Blueprint('alfred_glass_logs_v2', __name__)
@bp.get('/logs')
@bp.get('/logs/')
def logs_page():
# Le HTML ne contient jamais les journaux; l'API exige la session admin.
return render_template('alfred_logs_v2.html')
@bp.get('/api/alfred/logs')
def logs_api():
if not admin_console._authenticated():
return jsonify(error='Authentification administrateur nécessaire.',
login_url='/config'), 401
try:
limit = min(400,max(20,int(request.args.get('limit',220))))
except (TypeError,ValueError):
return jsonify(error='Limite invalide.'),400
try:
records=read_journal(limit)
except RuntimeError as exc:
return jsonify(error=str(exc)),503
response = jsonify(ok=True,source='journalctl -u alfred.service',items=records,
warning='Lecture du journal Alfred uniquement ; les logs Hermes autonomes peuvent être absents.')
response.headers['Cache-Control'] = 'no-store, private'
return response
app.register_blueprint(bp)
+1 -1
View File
@@ -13,7 +13,7 @@ _CAMERA_LOCK = threading.Lock()
def snapshot(path, index=0): def snapshot(path, index=0):
import cv2 import cv2
if not _CAMERA_LOCK.acquire(blocking=False): if not _CAMERA_LOCK.acquire(blocking=False):
raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred') raise RuntimeError('Caméra déjà utilisée par la prévisualisation de ' + os.getenv('ALFRED_NAME', 'Alfred')) # ALFRED_IDENTITY_GLASS_V1
try: try:
cap = cv2.VideoCapture(index) cap = cv2.VideoCapture(index)
try: try:
+4
View File
@@ -62,6 +62,10 @@ def _build_prompt(question: str) -> str:
) )
identity = "" identity = ""
# ALFRED_IDENTITY_GLASS_V1 : garantir le nom actuel dans le contexte vocal.
from services.alfred_identity_page import identity_for_hermes as _identity_for_hermes
identity = _identity_for_hermes(identity)
question = str(question or "").strip() question = str(question or "").strip()
if not identity: if not identity:
+20
View File
@@ -0,0 +1,20 @@
---
name: alfred-listes
description: "Gestion des listes de courses et des listes de tâches d'Alfred : ajouter, afficher, cocher, décocher, supprimer des éléments ou vider les éléments terminés. À utiliser pour toutes les requêtes de shopping list, todo list et liste de tâches."
version: 1.0.0
---
# Listes de courses et de tâches Alfred
Si l'utilisateur veut ajouter un achat, une course, une tâche, ou gérer sa liste, utiliser **exclusivement le MCP `alfred_listes`**, sauf si Alfred a déjà exécuté lui-même une commande vocale simple et renvoyé son résultat.
- « Ajoute du pain à la liste de courses » → `add_item(kind="courses", label="pain")`.
- « Ajoute peindre le mur à ma liste de tâches » → `add_item(kind="taches", label="peindre le mur")`.
- « Qu'y a-t-il sur ma liste de courses ? » → `list_items(kind="courses")`.
- « J'ai acheté le pain » → `list_items` pour l'ID puis `complete_item`.
- « Supprime le pain de la liste » → `delete_item`, une fois la liste déterminée.
- « Vide les courses déjà cochées » → `clear_completed(kind="courses")`.
Ne jamais utiliser Terminal, `sleep`, une liste fictive en mémoire de conversation, ni le modèle pour simuler une modification. Ne jamais prétendre avoir ajouté, coché ou supprimé un élément sans réponse `ok: true` de l'outil. Si le MCP échoue, annoncer l'échec clairement. Pour éviter les erreurs, demander quelle liste si elle n'est pas explicite.
Les éléments sont enregistrés localement dans Alfred et visibles dans le tableau de bord Glass, ils ne sont pas automatiquement synchronisés avec Home Assistant.
@@ -0,0 +1,71 @@
/* Alfred Glass Admin — identité visuelle de l'accueil Glass V1 */
:root{--glass-border:#b8d9fa32;--glass-field:#08182bd8;--glass-shadow:0 16px 45px #0006;--glass-text:#eff7ff}
body.alfred-admin-glass{color:var(--glass-text);background:#0b1726;font-family:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;min-height:100dvh}
body.alfred-admin-glass::before{content:"";position:fixed;inset:-18px;z-index:-2;pointer-events:none;background:url('/static/img/alfred-ambience.webp') center/cover no-repeat;filter:blur(4px) saturate(1.05)}
body.alfred-admin-glass::after{content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;background:linear-gradient(120deg,#07101ce8,#06111f90 45%,#051324d5 100%)}
.alfred-admin-glass .admin-layout{max-width:none;padding:0 30px 65px 132px;min-height:100dvh}
.alfred-admin-glass .admin-header{height:94px;border:0;gap:20px;padding:10px 5px;background:transparent}
.alfred-admin-glass .brand{gap:15px}
.alfred-admin-glass .brand-orb{width:53px;height:53px;border-radius:15px;font-size:32px;background:linear-gradient(140deg,#7acaff,#0866f0);box-shadow:0 10px 25px #006cff45}
.alfred-admin-glass .brand strong{font-size:28px;font-weight:700;letter-spacing:-.04em;text-shadow:0 2px 10px #0007}
.alfred-admin-glass .brand small{color:#e4efff;font-size:12px}
.alfred-admin-glass .admin-header nav{gap:9px}
.alfred-admin-glass .admin-header nav a,.alfred-admin-glass .admin-header nav button{color:#d9e6f6;border:1px solid #d6e7ff2b;border-radius:23px;background:#0e1c2dbd;backdrop-filter:blur(14px);font-size:12px;padding:10px 15px}
.alfred-admin-glass .admin-header nav .selected{background:linear-gradient(145deg,#188af2,#0750c4);border-color:#6bbcff8a;color:white}
.alfred-admin-glass .ag-rail{position:fixed;z-index:8;top:113px;bottom:24px;left:14px;width:98px;padding:13px 8px;border-radius:20px;background:#081321e8;backdrop-filter:blur(18px);border:1px solid #d7eaff24;display:flex;flex-direction:column;gap:12px;box-shadow:var(--glass-shadow)}
.alfred-admin-glass .ag-rail a{min-height:87px;border-radius:14px;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:6px;text-align:center;font-size:12px;color:#e7f2ff}
.alfred-admin-glass .ag-rail a:hover{background:#2b405dbb}
.alfred-admin-glass .ag-rail a.is-active{background:linear-gradient(145deg,#008bff,#0a58d9);box-shadow:0 8px 23px #007afa55}
.alfred-admin-glass .ag-rail a span{font-size:28px;line-height:1.2}
.alfred-admin-glass .admin-main{padding-top:23px;max-width:1800px;margin:0 auto}
.alfred-admin-glass .page-top{margin:6px 0 23px;align-items:center}
.alfred-admin-glass .page-top h1{font-size:clamp(31px,3vw,47px);font-weight:680;color:#fff;text-shadow:0 2px 12px #0008}
.alfred-admin-glass .eyebrow{color:#74bfff;letter-spacing:.18em}
.alfred-admin-glass .page-top p.muted{color:#d8e6f6}
.alfred-admin-glass .panel,.alfred-admin-glass .metric-card{background:linear-gradient(130deg,#15243ae8,#111c30d9)!important;border:1px solid var(--glass-border);border-radius:21px;box-shadow:var(--glass-shadow);backdrop-filter:blur(18px);-webkit-backdrop-filter:blur(18px)}
.alfred-admin-glass .panel:hover{border-color:#9bcaff75}
.alfred-admin-glass .panel-title{border-color:#dfeaff22}
.alfred-admin-glass .panel-title h2{font-size:1.14rem;font-weight:680;color:white}
.alfred-admin-glass .panel-title p{color:#b5cce7}
.alfred-admin-glass .panel-symbol{width:47px;height:47px;background:#144d827d;border-radius:14px;color:#bbebff;font-size:23px}
.alfred-admin-glass .field label{color:#e4f3ff}
.alfred-admin-glass .field small,.alfred-admin-glass .muted{color:#b6c9dc}
.alfred-admin-glass .field input:not([type=range]):not([type=checkbox]),.alfred-admin-glass .field select,.alfred-admin-glass .auth-box input{background:var(--glass-field);color:white;border:1px solid #9bbbde61;border-radius:12px;min-height:45px}
.alfred-admin-glass .field input:focus,.alfred-admin-glass .field select:focus,.alfred-admin-glass .ag-row-fields :focus{outline:2px solid #48a9fc;outline-offset:2px}
.alfred-admin-glass .button{border-radius:12px;background:#182b42db;border-color:#9bbadd54;color:#edf7ff}
.alfred-admin-glass .button.primary{background:linear-gradient(135deg,#058bff,#0755d3);border-color:#68beffaf;box-shadow:0 8px 20px #0875f637}
.alfred-admin-glass .button.danger{background:#653039;border-color:#cf7583}
.alfred-admin-glass .notice{background:#153e2cdb;border-color:#50bf85a1}
.alfred-admin-glass .tag{background:#10283ebd;border-color:#88c8ff6b;color:#e0f5ff}
.alfred-admin-glass .metric-icon{background:#25619d74;color:#dcf1ff;border:1px solid #80c7ff75}
.alfred-admin-glass .metric-card{min-height:170px}
.alfred-admin-glass .status-pill[data-state="ok"]{background:#0c62395c;border-color:#3ac98b8c;color:#a3f9cd}
.alfred-admin-glass .meter{background:#070f1ca8}
.alfred-admin-glass .meter span{background:linear-gradient(90deg,#0569cf,#49b7ff)}
.alfred-admin-glass .service-row{border-color:#ffffff20}
.alfred-admin-glass .service-label{color:#eff7ff}
.alfred-admin-glass .service-sub,.alfred-admin-glass .mcp-item p{color:#b0cae2}
.alfred-admin-glass .mcp-item{background:#182e46a8;border:1px solid #aac8eb38;border-radius:14px}
.alfred-admin-glass .auth-screen{background:radial-gradient(ellipse at center,#152741e2,#061222ef 88%);backdrop-filter:blur(16px)}
.alfred-admin-glass .auth-box{background:#14253cde;border:1px solid #a2c7ee60;backdrop-filter:blur(20px)}
.alfred-admin-glass .monitor-grid{gap:16px}
.alfred-admin-glass .two-col{gap:18px}
.alfred-admin-glass .ag-maison-editor{margin-top:19px;scroll-margin-top:25px}
.alfred-admin-glass .ag-help{color:#d2e5fa;font-size:13px;line-height:1.65;margin:0 0 18px}
.alfred-admin-glass .ag-maison-rows{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}
.alfred-admin-glass .ag-row{background:#09172ad9;border:1px solid #99c5eb47;border-radius:16px;padding:15px;min-width:0}
.alfred-admin-glass .ag-row-header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:13px}
.alfred-admin-glass .ag-row-header strong{font-size:14px;color:#ecf7ff}
.alfred-admin-glass .ag-row-buttons{display:flex;gap:5px}
.alfred-admin-glass .ag-small-button{width:30px;height:30px;border-radius:8px;border:1px solid #9cb6d45b;background:#1a3350;color:white;font-size:18px;cursor:pointer}
.alfred-admin-glass .ag-small-button:hover{background:#2278be}
.alfred-admin-glass .ag-small-button:disabled{opacity:.4}
.alfred-admin-glass .ag-row-fields{display:grid;grid-template-columns:1fr;gap:10px}
.alfred-admin-glass .ag-field{display:flex;flex-direction:column;gap:6px;min-width:0;font-size:12px;color:#c9def4}
.alfred-admin-glass .ag-field input,.alfred-admin-glass .ag-field select{width:100%;min-width:0;min-height:43px;padding:9px 12px;color:white;background:#111f32;border:1px solid #95bce76e;border-radius:10px}
.alfred-admin-glass .ag-maison-actions{display:flex;align-items:center;justify-content:flex-end;flex-wrap:wrap;gap:10px;margin-top:18px}
.alfred-admin-glass #agMaisonMessage{margin-top:10px;min-height:19px;color:#a7e5c9}
.alfred-admin-glass #agMaisonMessage.ag-error{color:#ffb4ad}
@media(max-width:1250px){.alfred-admin-glass .ag-maison-rows{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:900px){.alfred-admin-glass .admin-layout{padding-left:115px;padding-right:14px}.alfred-admin-glass .ag-rail{left:8px;width:92px}.alfred-admin-glass .ag-maison-rows{grid-template-columns:1fr}}
@media(max-width:650px){.alfred-admin-glass .admin-layout{padding:0 12px 40px}.alfred-admin-glass .ag-rail{position:relative;inset:auto;width:auto;height:auto;margin:12px 0 6px;flex-direction:row;justify-content:space-around;padding:5px}.alfred-admin-glass .ag-rail a{min-height:58px;flex:1;font-size:11px}.alfred-admin-glass .ag-rail a span{font-size:22px}.alfred-admin-glass .admin-header{height:auto;min-height:85px;flex-wrap:wrap}.alfred-admin-glass .admin-header nav{display:flex;justify-content:flex-end}.alfred-admin-glass .page-top{align-items:flex-start}.alfred-admin-glass .ag-maison-actions{justify-content:flex-start}}
@@ -0,0 +1 @@
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:#f0f5ff;--line:rgba(191,215,255,.18);--muted:#b9c6dc;--blue:#388fff}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:linear-gradient(120deg,rgba(5,12,26,.78),rgba(7,13,27,.66)),url('/static/img/alfred-ambience.webp') center/cover fixed #0e1b2c;font-size:15px}.id-head{display:flex;justify-content:space-between;align-items:center;gap:18px;padding:22px clamp(20px,3.5vw,64px);background:rgba(10,18,33,.4);border-bottom:1px solid var(--line);backdrop-filter:blur(20px)}.id-brand{display:flex;gap:13px;align-items:center;color:inherit;text-decoration:none}.id-letter{font-size:45px;line-height:1;font-weight:850;color:#398dff;min-width:45px}.id-brand strong{display:block;font-size:24px}.id-brand small{display:block;color:var(--muted);font-size:12px}.id-head nav{display:flex;gap:9px;flex-wrap:wrap}.id-head nav a{color:#cbd8ed;text-decoration:none;padding:10px 13px;border-radius:11px}.id-head nav a:hover,.id-head nav a.active{background:rgba(51,127,244,.25);color:#fff}.id-main{width:min(1480px,95vw);margin:35px auto 60px}.id-title{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}.id-eyebrow,.id-kicker{font-size:11px;color:#73b6ff;letter-spacing:.13em;font-weight:700}.id-title h1{font-size:clamp(28px,3.4vw,42px);margin:2px 0 7px}.id-title p:not(.id-eyebrow){color:var(--muted);margin:0}.id-pill{border:1px solid var(--line);border-radius:30px;padding:9px 13px;white-space:nowrap;color:#b5d6f6;background:rgba(13,29,48,.54)}.id-pill.ok{color:#75f0c0}.id-panel{background:rgba(11,20,37,.57);border:1px solid var(--line);border-radius:23px;padding:25px;backdrop-filter:blur(19px);box-shadow:0 14px 38px rgba(0,0,0,.15)}.id-layout{display:grid;grid-template-columns:minmax(0,1fr) 310px;gap:23px}.id-side{display:flex;flex-direction:column;gap:20px}.id-panel h2{margin:0 0 12px;font-size:19px}.id-panel p{line-height:1.55;color:var(--muted)}.id-panel a{color:#7cbaff}.id-editor-title{display:flex;justify-content:space-between;gap:8px;align-items:start}.id-editor-title h2{margin:0}.id-editor-title p{font-size:13px;color:var(--muted);margin:8px 0 18px}#idCount{font-size:12px;color:var(--muted);white-space:nowrap}#idText{display:block;width:100%;min-height:55vh;resize:vertical;padding:20px;border-radius:15px;border:1px solid rgba(174,212,255,.25);outline:none;background:rgba(4,13,24,.78);color:#f2f5fb;font:14px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;tab-size:2}#idText:focus{border-color:#409cff;box-shadow:0 0 0 3px rgba(52,139,255,.14)}.id-tip{font-size:12px}.id-buttons{display:flex;justify-content:flex-end;gap:12px;flex-wrap:wrap;margin-top:20px}.id-button{cursor:pointer;border:1px solid var(--line);background:rgba(24,45,72,.65);border-radius:12px;color:white;padding:12px 18px;font:inherit}.id-button:hover{border-color:#5baaff}.id-button.primary{border:0;background:linear-gradient(115deg,#147bff,#3d99f5);font-weight:650}.id-button:disabled{opacity:.48;cursor:wait}.id-current{display:flex;align-items:center;gap:16px;margin:17px 0}.id-current>span{font-weight:800;font-size:42px;color:#499eff}.id-current strong{display:block;font-size:23px}.id-current small{display:block;color:var(--muted);font-size:12px}.id-example{font:12px/1.65 ui-monospace,Consolas,monospace;color:#d4e4fa;background:rgba(4,14,27,.7);border-radius:12px;padding:14px}#idMessage,#idLoginMessage{min-height:21px;font-size:14px;color:#a6f1dc}#idMessage.error,#idLoginMessage.error{color:#ffb2b2}#idLoginPanel{width:min(450px,100%)}#idLoginPanel form{display:grid;gap:12px}#idPassword{border-radius:10px;border:1px solid var(--line);color:white;background:rgba(6,18,34,.7);padding:13px;font:inherit}.id-sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}[hidden]{display:none!important}@media(max-width:920px){.id-layout{grid-template-columns:1fr}.id-side{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr))}}@media(max-width:650px){.id-head{align-items:start;flex-direction:column}.id-title{align-items:start;flex-direction:column}.id-main{width:94vw}.id-panel{padding:17px}#idText{min-height:50vh}}
@@ -0,0 +1,40 @@
/* ALFRED_LISTES_GLASS_V1 — conserve l'Accueil Glass et ajoute un panneau réversible. */
.alfred-main-v1 .av-shortcuts {grid-template-columns: repeat(8,minmax(0,1fr));}
.av-rail-link.al-rail-link {color:#e0ebf6;}
.av-rail-link.al-rail-link svg {height:24px;width:24px;}
#alfredListsGlassDialog {box-sizing:border-box;width:min(1080px,95vw);max-width:95vw;max-height:min(870px,90dvh);margin:auto;
padding:0;overflow:hidden;color:#eaf3ff;border:1px solid #c6e5ff58;border-radius:24px;
background:linear-gradient(150deg,#11283beb,#0c1728f5 65%);box-shadow:0 30px 100px #000e;backdrop-filter:blur(22px);font:inherit;}
#alfredListsGlassDialog::backdrop {background:#030a13cf;backdrop-filter:blur(9px);}
#alfredListsGlassDialog * {box-sizing:border-box;}
.al-list-head {display:flex;justify-content:space-between;gap:18px;align-items:center;padding:28px 31px 20px;border-bottom:1px solid #aad8ff29;}
.al-eyebrow {margin:0 0 5px;color:#86cbff;letter-spacing:.16em;font-size:11px;font-weight:700;}
.al-list-head h2 {font-size:clamp(24px,3vw,34px);margin:0 0 7px;font-weight:740;letter-spacing:-.035em;}
.al-subtitle {margin:0;color:#b9c9de;font-size:13px;line-height:1.4;}
.al-close {color:#eff8ff;font:normal 31px/1 Arial;cursor:pointer;background:#1f3855;border:1px solid #b5d5f55c;border-radius:12px;width:44px;height:44px;}
.al-list-grid {display:grid;grid-template-columns:1fr 1fr;gap:19px;padding:24px 29px 20px;overflow:auto;max-height:calc(min(870px,90dvh) - 182px);}
.al-list-section {min-width:0;background:#111f32c9;border:1px solid #a4d1f53d;border-radius:19px;padding:20px;}
.al-list-sec-head {display:flex;align-items:center;gap:14px;margin-bottom:22px;}
.al-list-sec-icon {display:grid;place-items:center;background:#1266ae44;color:#5dbaff;width:47px;height:47px;border-radius:14px;flex-shrink:0;}
.al-list-sec-icon.al-task-icon {background:#755afc42;color:#c0b4ff;}
.al-list-sec-head h3 {font-size:18px;margin:0 0 4px;}.al-list-sec-head p {font-size:12px;color:#a8c1dc;margin:0;}
.al-add-form {display:flex;gap:8px;margin-bottom:18px;}
.al-add-form input {background:#081525ed;border:1px solid #90b3d45c;border-radius:11px;color:white;flex:1;min-width:0;padding:12px;font:inherit;font-size:13px;}
.al-add-form input:focus {outline:2px solid #379df5;outline-offset:1px;}
.al-add-form button {background:#087fea;color:white;border:1px solid #5fb1ff;border-radius:11px;padding:10px 12px;font-weight:700;cursor:pointer;white-space:nowrap;}
.al-items {list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:9px;}
.al-item {display:flex;align-items:center;gap:12px;background:#ffffff0a;border:1px solid #7aafd128;border-radius:12px;min-height:49px;padding:7px 9px;}
.al-check {width:27px;height:27px;border-radius:9px;flex:none;background:#0e1e2b;color:#fff;border:1.5px solid #6999bb;cursor:pointer;font-weight:800;}
.al-item.is-done .al-check {background:#147b68;border-color:#36d6a7;}
.al-item-label {min-width:0;flex:1;overflow-wrap:anywhere;font-size:14px;line-height:1.4;}
.al-item.is-done .al-item-label {text-decoration:line-through;color:#8eacb5;}
.al-remove {color:#f5c7cf;background:none;border:none;font-size:25px;line-height:1;cursor:pointer;padding:4px 8px;border-radius:6px;}
.al-remove:hover {background:#ef587d33;}
.al-empty {color:#a8bdce;font-size:13px;padding:22px 10px;text-align:center;}
.al-footer {display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid #aad8ff29;padding:15px 29px;font-size:12px;color:#9fcbf4;}
.al-footer .al-error {color:#ff9ca9;}.al-footer button {padding:8px 13px;color:#fff;border:1px solid #6f9ec677;border-radius:9px;background:#1b3550;cursor:pointer;}
#alfredListsGlassDialog button:focus-visible {outline:2px solid #70c6ff;outline-offset:2px;}
@media(max-width:1370px) {.alfred-main-v1 .av-shortcuts{grid-template-columns:repeat(4,minmax(0,1fr));} }
@media(max-width:900px) {.alfred-main-v1 .av-rail {grid-template-columns:repeat(7,1fr);}}
@media(max-width:740px) {.al-list-grid{grid-template-columns:1fr;}.al-list-head{padding:20px 16px 15px;}.al-list-grid{padding:15px;}.al-footer{padding:12px 15px;}.al-list-section{padding:14px;}}
@media(max-width:550px) {.alfred-main-v1 .av-shortcuts{grid-template-columns:repeat(3,minmax(0,1fr));}}
+16
View File
@@ -0,0 +1,16 @@
:root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,'Segoe UI',sans-serif;color:#f0f6fd;background:#0c1521}*{box-sizing:border-box}
body{min-height:100vh;margin:0;background:linear-gradient(180deg,rgba(5,15,26,.77),rgba(5,15,26,.62)),url('/static/img/alfred-ambience.webp') center/cover fixed no-repeat;}
button,a,input,select{font:inherit}a{color:inherit;text-decoration:none}button{cursor:pointer}
.lg-header{display:flex;justify-content:space-between;align-items:center;gap:22px;padding:24px clamp(20px,3vw,45px);background:rgba(5,14,24,.55);backdrop-filter:blur(16px);border-bottom:1px solid #ffffff19;}
.lg-brand{display:flex;align-items:center;gap:14px}.lg-letter{font-size:46px;font-weight:900;line-height:1;background:linear-gradient(145deg,#70cfff,#006cff);color:transparent;background-clip:text;}
.lg-brand strong{font-size:25px;display:block;line-height:1}.lg-brand small{color:#bccde2;font-size:12px;display:block;margin-top:6px}
.lg-nav{display:flex;gap:7px;flex-wrap:wrap}.lg-nav a{padding:10px 14px;border-radius:11px;color:#d1e2f2}.lg-nav a:hover,.lg-nav a.active{background:rgba(35,113,233,.67);color:#fff}
.lg-shell{max-width:1600px;margin:auto;padding:26px clamp(14px,3vw,45px) 40px;}
.lg-title{display:flex;align-items:flex-end;justify-content:space-between;gap:25px;margin-bottom:23px}.lg-title h1{font-size:clamp(27px,3vw,44px);margin:4px 0}.lg-title p{margin:8px 0;color:#c8d9eb;font-size:14px}.lg-eyebrow{color:#70b6ff!important;letter-spacing:.12em;font-size:11px!important;font-weight:800}
.lg-actions{display:flex;gap:10px;flex-wrap:wrap}button{background:rgba(19,77,145,.8);border:1px solid #8ebfe858;color:#fff;border-radius:11px;padding:11px 15px}button:hover{background:rgba(25,111,217,.92)}
.lg-filters,.lg-table-wrap{border:1px solid rgba(195,220,252,.22);background:rgba(9,21,36,.57);backdrop-filter:blur(18px);border-radius:19px;box-shadow:0 18px 60px #0003;}
.lg-filters{display:flex;align-items:end;flex-wrap:wrap;gap:15px;padding:18px;margin-bottom:18px}.lg-filters label{display:flex;flex-direction:column;gap:7px;color:#c9daed;font-size:12px;font-weight:650}.lg-filters input[type=search]{width:min(360px,70vw)}.lg-filters input,.lg-filters select{background:rgba(3,14,29,.72);color:white;border:1px solid #87acd04e;border-radius:10px;min-height:40px;padding:8px 11px}.lg-filters .lg-check{flex-direction:row;align-items:center;min-height:40px}.lg-check input{min-height:0;width:16px;height:16px}
.lg-table-wrap{padding:16px}.lg-table-top{display:flex;justify-content:space-between;align-items:center;gap:10px;border-bottom:1px solid #c3d9ef26;padding:4px 6px 14px;color:#bbdefd;font-size:13px}.lg-events{max-height:62vh;min-height:260px;overflow:auto;scrollbar-color:#6995b3 transparent}.lg-event{display:grid;grid-template-columns:180px 94px 106px minmax(0,1fr);gap:12px;padding:12px 7px;border-bottom:1px solid #a9c7df18;align-items:start;font-size:13px}.lg-event time{color:#a6bcd0;font-variant-numeric:tabular-nums}.lg-component{font-weight:700;color:#8cc2ff}.lg-level{color:#afd6f5}.lg-event[data-level='Erreur'] .lg-level{color:#ff9898}.lg-event[data-level='Avertissement'] .lg-level{color:#ffd18a}.lg-message{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.5;color:#e5eff9}.lg-note{color:#adbfd0;font-size:12px;line-height:1.6;padding:12px 6px 0}.lg-note code{color:#c8e6ff}.lg-empty{padding:30px;color:#adc6dc;text-align:center}
:focus-visible{outline:2px solid #98d2ff;outline-offset:3px}
@media(max-width:850px){.lg-header,.lg-title{flex-wrap:wrap}.lg-event{grid-template-columns:95px 65px 1fr;gap:6px}.lg-event .lg-message{grid-column:1/-1;padding-left:2px}}
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
+156
View File
@@ -0,0 +1,156 @@
/* ALFRED_MAIN_VISUAL_V1 — overlay of the existing functional Alfred UI. */
body.alfred-glass-theme { color:#f5f8fc; overflow:hidden; background:linear-gradient(180deg,#07101b80 0%,#09152352 42%,#05111ed1 100%),url('/static/img/alfred-ambience.webp') center center / cover fixed no-repeat,#14202b; }
body.alfred-glass-theme::before { content:""; position:fixed; inset:-50px; z-index:-3;
background:url('/static/img/alfred-ambience.webp') center center / cover no-repeat;
filter:blur(3px) saturate(105%); transform:scale(1.06); }
body.alfred-glass-theme::after { content:""; position:fixed; inset:0; z-index:-2;
background:linear-gradient(180deg,#07101b7c 0%,#09152353 42%,#05111ebc 100%); pointer-events:none; }
.alfred-glass-theme .alfred-shell { display:block;min-height:100dvh;padding:0;gap:0;isolation:auto; }
.alfred-glass-theme .alfred-shell > .topbar { position:absolute; left:-9999px; top:-9999px; pointer-events:none; }
.alfred-glass-theme .alfred-shell > .topbar #audioSettingsButton { pointer-events:auto; }
.alfred-glass-theme .audio-popover { position:fixed!important;top:78px!important;right:24px!important;left:auto!important;z-index:300!important; }
.alfred-glass-theme .alfred-main-v1 { position:fixed;inset:0;z-index:1;display:grid;grid-template-columns:98px minmax(0,1fr);
grid-template-rows:95px minmax(190px,215px) minmax(170px,1fr) 114px 86px;
gap:12px 15px;padding:0 17px 15px 0;overflow:hidden; font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif; }
.alfred-main-v1 * { box-sizing:border-box; }
.alfred-main-v1 button,.alfred-main-v1 a { font:inherit; }
.alfred-main-v1 button { cursor:pointer; }
.alfred-main-v1 a { color:inherit;text-decoration:none; }
.alfred-main-v1 :is(button,a,textarea):focus-visible { outline:2px solid #9bd7ff;outline-offset:3px; }
.alfred-main-v1 .a-icon { width:22px;height:22px;flex:0 0 auto; }
.alfred-main-v1 .av-head { grid-column:1/-1;grid-row:1;display:flex;align-items:center;justify-content:space-between;padding:14px 24px 12px 30px;min-width:0; }
.av-brand { display:flex;align-items:center;gap:16px; }
.av-logo { font-size:54px;line-height:1;font-weight:900;letter-spacing:-.12em;padding-right:9px;
background:linear-gradient(145deg,#65c6ff,#006af7 85%);background-clip:text;-webkit-background-clip:text;color:transparent;
filter:drop-shadow(0 3px 12px #007bff55); }
.av-brand strong { display:block;font-size:29px;line-height:1.05;letter-spacing:-.03em; }
.av-brand small { display:block;color:#d7e4f0;font-size:13px;margin-top:5px; }
.av-header-right { display:flex;align-items:center;gap:30px; }
.av-time { display:flex;flex-direction:column;text-align:right;white-space:nowrap; }
.av-time strong { font-size:33px;line-height:1.05;font-weight:670;font-variant-numeric:tabular-nums;letter-spacing:-.04em; }
.av-time span { font-size:12px;color:#d5e3f1;margin-top:6px; }
.av-head-actions { display:flex;align-items:center;gap:9px; }
.av-round { display:grid;place-items:center;flex:0 0 auto;width:40px;height:40px;border:1px solid #ffffff16;border-radius:50%;
background:#111b28ba;color:#f1f6fc;box-shadow:0 5px 12px #0002;backdrop-filter:blur(10px); }
.av-round:hover,.av-rail-link:hover,.av-shortcuts button:hover {background:#2c415abc;}
.av-round[aria-pressed="true"] {color:#ff858b;background:#54232fe8;border-color:#ff88906f;}
.alfred-main-v1 .av-rail {grid-column:1;grid-row:2/6;background:#09111ee3;border:1px solid #ffffff15;border-radius:0 19px 19px 0;
display:flex;flex-direction:column;align-items:center;justify-content:space-evenly;gap:5px;padding:9px 5px;backdrop-filter:blur(16px); }
.av-rail-link { border:0;color:#e5f0fc;background:transparent;border-radius:14px;width:83px;min-height:68px;display:flex;align-items:center;flex-direction:column;
justify-content:center;gap:7px;transition:background .2s;font-size:12px; }
.av-rail-link.selected {background:linear-gradient(155deg,#0988ff,#0758e0);box-shadow:0 5px 20px #007aff44;}
.av-rail-link .a-icon {width:25px;height:25px;}
.alfred-main-v1 .av-summary {grid-column:2;grid-row:2;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;min-height:0;}
.av-stat-card { background:linear-gradient(135deg,#142235df,#111e30c9);border:1px solid #c7d8f22f;border-radius:20px;backdrop-filter:blur(18px);
text-align:left;color:#eef7ff;padding:21px 22px;box-shadow:0 12px 30px #00000026;min-width:0;overflow:hidden;position:relative; }
.av-stat-card:hover {border-color:#8ecbff79;background:linear-gradient(135deg,#203650ed,#14273fe8);}
.av-card-top {display:flex;align-items:center;gap:13px;}
.av-card-top > span:nth-child(2) {min-width:0;flex:1;}
.av-card-top strong {font-size:18px;line-height:1.3;font-weight:690;display:block;}
.av-card-top small {font-size:11px;line-height:1.6;display:block;color:#bdcde0;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.av-circle {display:grid;place-items:center;flex:0 0 54px;height:54px;border-radius:18px;background:#2e507787;color:#dff1ff;}
.av-circle .a-icon {width:28px;height:28px;}
.av-circle.ai {background:#5733b794;color:#e6d5ff;}.av-circle.weather {background:#dba53b36;color:#ffeab2;}
.av-chevron {width:18px!important;opacity:.8;}
.av-lines {margin-top:18px;display:grid;gap:10px;}
.av-lines p {margin:0;display:flex;align-items:center;gap:10px;min-width:0;color:#e1edfa;font-size:13px;line-height:1.2;}
.av-lines p .a-icon {width:17px;height:17px;color:#7bc9ff;}
.av-lines p span:last-child {overflow:hidden;white-space:nowrap;text-overflow:ellipsis;}
.av-mini-dot {display:inline-block;width:9px;height:9px;border-radius:50%;flex-shrink:0;background:#8696a8;box-shadow:0 0 0 3px #ffffff08;}
.av-mini-dot.is-ok {background:#23e98a;box-shadow:0 0 8px #23e98a57;}
.av-mini-dot.is-error {background:#ff8d7e;}
.av-weather {background:linear-gradient(135deg,#203a54ea,#172c47d7);}
.av-weather-body {display:flex;align-items:center;gap:13px;margin:14px 0 0 2px;}
.av-weather-icon {font-size:52px;filter:drop-shadow(0 8px 9px #10182766);}
.av-weather-body div {display:flex;flex-direction:column;min-width:0;}
.av-weather-body strong {font-size:38px;font-weight:600;letter-spacing:-.05em;white-space:nowrap;}
.av-weather-body span {font-size:14px;color:#e2ecfa;}
.av-weather-body small {margin-top:4px;color:#bed5eb;font-size:12px;}
.alfred-main-v1 .av-voice-hero {grid-column:2;grid-row:3;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;min-height:170px;}
.av-voice-controls {display:flex;align-items:center;justify-content:center;gap:clamp(18px,6vw,120px);width:100%;z-index:1;}
.av-voice-center {display:flex;align-items:center;flex-direction:column;min-width:260px;text-align:center;}
.av-mic-orb {height:clamp(116px,12.5vw,178px);width:clamp(116px,12.5vw,178px);border:3px solid #23acff;
border-radius:50%;background:radial-gradient(circle at 50% 45%,#0e529b,#001a44 70%);box-shadow:0 0 0 8px #0a76ff1a,0 0 32px #0084ffb9,inset 0 0 34px #0672f06e;
display:grid;place-items:center;margin-bottom:12px;transition:filter .2s,box-shadow .2s;}
.av-mic-orb .a-icon {width:58px;height:58px;stroke-width:1.65;filter:drop-shadow(0 2px 5px #dff3ff44);}
.av-voice-center > strong {font-size:clamp(22px,2vw,32px);font-weight:680;text-shadow:0 2px 14px #07111bcc;}
.av-voice-center > p {margin:3px 0 7px;color:#e4ecf7;font-size:clamp(12px,1.1vw,16px);text-shadow:0 1px 8px #000b;}
.av-status-line {display:flex;align-items:center;gap:9px;font-size:11px;color:#d1e7f8;background:#0513268f;border:1px solid #ffffff20;border-radius:30px;padding:4px 12px;}
.av-status-dot {height:7px;width:7px;border-radius:50%;background:#42e893;}
.av-sound-control {width:130px;min-height:124px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;
background:#101c2ada;border:1px solid #9fc6f664;border-radius:55px;color:#eff6ff;box-shadow:0 12px 23px #0003;backdrop-filter:blur(12px); }
.av-sound-control .a-icon {width:30px;height:30px;margin-bottom:1px;color:#1d9eff;}
.av-sound-control strong {font-size:14px;}.av-sound-control small {font-size:12px;color:#2af0a1;}
.av-sound-control[aria-pressed="true"] {border-color:#fe778a;color:#ffe0e3;background:#45232de8;}
.av-sound-control[aria-pressed="true"] .a-icon,.av-sound-control[aria-pressed="true"] small {color:#ff808b;}
.av-wave-host {position:absolute;left:50%;top:43%;width:min(620px,65%);height:120px;transform:translate(-50%,-50%);pointer-events:none;opacity:.75;}
.alfred-glass-theme .av-wave-host #wave {position:relative;display:block;width:100%;height:120px;max-width:none;margin:0;}
.alfred-glass-theme .av-wave-host #wave canvas {width:100%;height:100%;filter:drop-shadow(0 0 3px #1688ff);}
.alfred-main-v1 .av-shortcuts {grid-column:2;grid-row:4;display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:11px;min-height:0;}
.av-shortcuts button {display:flex;align-items:center;justify-content:center;flex-direction:column;gap:8px;color:#f2f7fc;
border:1px solid #b0ccea55;border-radius:16px;background:#111d2ce8;backdrop-filter:blur(14px);box-shadow:0 6px 25px #0003;padding:8px;min-width:0;}
.av-shortcuts .a-icon {width:27px;height:27px;color:#d7eafb;}.av-shortcuts span {font-size:12px;line-height:1.15;font-weight:590;text-align:center;}
.alfred-main-v1 .av-history {grid-column:2;grid-row:5;display:flex;align-items:center;gap:12px;background:#0b1828e8;border:1px solid #b0ccea4a;border-radius:16px;min-width:0;padding:10px 17px;overflow:hidden;backdrop-filter:blur(14px);}
.av-history-heading {display:flex;align-items:center;gap:10px;white-space:nowrap;font-size:14px;padding-right:16px;border-right:1px solid #abc7e84a;}
.av-history-heading .a-icon {width:24px;height:24px;}
.av-history #avHistoryList {display:flex;align-items:center;gap:0;min-width:0;flex:1;overflow-x:auto;scrollbar-width:thin;}
.av-history-item {display:inline-flex;gap:8px;align-items:center;padding:0 14px;border-right:1px solid #a1bed735;font-size:12px;white-space:nowrap;min-width:0;}
.av-history-item time {color:#9ec7ec;font-variant-numeric:tabular-nums;}.av-history-item span {max-width:240px;overflow:hidden;text-overflow:ellipsis;}
.av-history-empty {font-size:12px;color:#a4b9ca;padding-left:10px;}
.alfred-main-v1 .av-back {position:fixed;top:112px;right:32px;z-index:15;display:flex;align-items:center;gap:8px;color:white;
padding:9px 14px;border:1px solid #d5e8ff88;border-radius:10px;background:#183452ee;font-size:13px;}
.av-back[hidden],.av-toast[hidden] {display:none!important;}
.av-toast {position:fixed;z-index:80;bottom:115px;right:22px;padding:12px 18px;background:#142b43ed;color:#f0faff;border-radius:12px;border:1px solid #9ccfff8c;max-width:min(460px,90vw);box-shadow:0 12px 35px #0009;}
.av-image-dialog {width:min(520px,94vw);background:#162538;color:#f5f9fd;border:1px solid #7fa9c85a;border-radius:18px;padding:26px;box-shadow:0 20px 100px #000c;}
.av-image-dialog::backdrop {background:#040b14d2;backdrop-filter:blur(6px);}
.av-image-dialog h2 {font-size:21px;margin:0 0 12px;}.av-image-dialog p {font-size:13px;color:#cad7e8;line-height:1.6;}
.av-image-dialog label {display:block;font-size:13px;font-weight:700;margin:14px 0 7px;}
.av-image-dialog textarea {width:100%;background:#07182a;color:#fff;border:1px solid #9db7ce7e;border-radius:10px;padding:12px;resize:vertical;font:inherit;}
.av-dialog-close {float:right;background:#284259;border:1px solid #ffffff30;border-radius:8px;color:white;padding:6px;}
.av-dialog-actions {display:flex;justify-content:flex-end;margin-top:14px;}.av-dialog-actions button {padding:10px 16px;background:#0677ec;border:0;border-radius:10px;color:#fff;}
.av-more-grid {display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:20px;}.av-more-grid a,.av-more-grid button {display:flex;align-items:center;gap:8px;padding:12px;background:#243950;color:#fff;border:1px solid #ffffff26;border-radius:10px;text-align:left;font-size:13px;}
.alfred-glass-theme #stage {position:fixed;left:calc(98px + 26px);right:25px;top:114px;bottom:103px;z-index:8;display:flex;align-items:center;justify-content:center;
opacity:0;visibility:hidden;pointer-events:none;padding:28px;background:#0a1625e8;border:1px solid #d4e8ff42;border-radius:23px;backdrop-filter:blur(21px);overflow:auto; }
.alfred-glass-theme #content {max-width:100%;width:100%;max-height:100%;overflow:auto;}
.alfred-glass-theme .alfred-main-v1.is-focused ~ #stage {opacity:1;visibility:visible;pointer-events:auto;}
.alfred-main-v1.is-focused .av-summary,.alfred-main-v1.is-focused .av-voice-hero,.alfred-main-v1.is-focused .av-shortcuts {opacity:0;pointer-events:none;}
.alfred-glass-theme .transcript-panel {z-index:18;right:24px;bottom:111px;width:min(390px,33vw);border:1px solid #91b4d14f;background:#101e2ddb;min-height:75px;padding:10px 14px;box-shadow:0 12px 30px #0006;}
.alfred-glass-theme .transcript-panel .transcript-text {font-size:12px;max-height:62px;margin-top:6px;}
.alfred-glass-theme .transcript-panel:not([data-active="true"]) {opacity:.62;transition:opacity .2s;}
.alfred-glass-theme .transcript-panel:not([data-active="true"]):hover {opacity:1;}
.alfred-glass-theme #alfredTimersDock {z-index:25;left:118px;bottom:112px;width:300px;max-height:35vh;background:#101e2def;border-color:#75b7e47e;}
.alfred-glass-theme #alfredTimerToast {z-index:40;bottom:220px;left:119px;}
.alfred-glass-theme .camera-panel:not(.camera-popup-body) {display:none;}
.alfred-glass-theme.av-legacy-camera-open .camera-panel:not(.camera-popup-body) {display:block;position:fixed;top:100px;right:35px;left:auto;bottom:auto;width:480px;max-width:90vw;z-index:120;background:#172637f2;}
#avLegacyCameraClose {border:0;background:#2a557a;color:white;padding:8px 15px;width:100%;}
.alfred-main-v1[data-mode="listening"] .av-mic-orb {box-shadow:0 0 0 10px #16bfff22,0 0 42px #0084ff,inset 0 0 35px #0572f3;animation:av-breathe 1.4s ease-in-out infinite;}
.alfred-main-v1[data-mode="thinking"] .av-mic-orb {border-color:#f2c16a;box-shadow:0 0 28px #d89c2866;}
.alfred-main-v1[data-mode="speaking"] .av-mic-orb {border-color:#51f2ae;box-shadow:0 0 32px #51f2ae55;}
.alfred-main-v1.av-mic-is-muted .av-mic-orb {border-color:#e68b95;filter:grayscale(.7);box-shadow:0 0 25px #b24e4966;}
.alfred-main-v1.av-mic-is-muted .av-status-dot {background:#f28b91;}
@keyframes av-breathe {50%{transform:scale(1.045)}}
@media (max-width:1360px) {
.alfred-glass-theme .alfred-main-v1 {grid-template-columns:88px minmax(0,1fr);grid-template-rows:80px minmax(180px,205px) minmax(150px,1fr) 98px 70px;gap:10px;padding-right:10px;padding-bottom:10px;}
.av-stat-card {padding:14px;}.av-card-top strong{font-size:15px}.av-circle{flex-basis:43px;height:43px}.av-lines{margin-top:13px;gap:8px}.av-lines p{font-size:12px}
.av-weather-body strong {font-size:31px;}.av-weather-icon{font-size:39px}.av-rail-link{width:75px;min-height:62px}.av-voice-controls{gap:clamp(12px,3vw,60px)}.av-sound-control{width:105px;min-height:105px;}
.alfred-glass-theme #stage {left:112px;top:100px;bottom:92px;}.alfred-glass-theme #alfredTimersDock{left:100px;bottom:93px;}
}
@media(max-width:900px) {
body.alfred-glass-theme {overflow:auto;}
.alfred-glass-theme .alfred-main-v1 {position:relative;min-height:100dvh;overflow:visible;grid-template-columns:1fr;grid-template-rows:auto auto auto auto auto auto;gap:10px;padding:0 12px 18px;}
.alfred-main-v1 .av-head {grid-column:1;grid-row:1;padding:15px 0;}.av-head-actions{gap:4px}.av-round{width:36px;height:36px}.av-brand strong{font-size:22px}.av-brand small{font-size:11px}.av-logo{font-size:38px}.av-header-right{gap:12px}.av-time strong{font-size:22px}.av-time span{font-size:10px;}
.alfred-main-v1 .av-rail {grid-column:1;grid-row:2;display:grid;grid-template-columns:repeat(6,1fr);border-radius:14px;gap:3px;padding:6px;}.av-rail-link{width:auto;min-height:58px;}
.alfred-main-v1 .av-summary {grid-column:1;grid-row:3;grid-template-columns:repeat(2,minmax(0,1fr));}.av-stat-card {min-height:160px;}
.alfred-main-v1 .av-voice-hero{grid-column:1;grid-row:4;min-height:260px;}.av-wave-host{width:90%;}.av-voice-controls{gap:12px;}.av-voice-center{min-width:190px}.av-sound-control{width:86px;min-height:95px;border-radius:34px;}.av-sound-control strong{font-size:11px;}.av-mic-orb{width:120px;height:120px;}.av-mic-orb .a-icon{width:45px;height:45px;}
.alfred-main-v1 .av-shortcuts{grid-column:1;grid-row:5;grid-template-columns:repeat(4,1fr);}.av-shortcuts button{min-height:78px;}
.alfred-main-v1 .av-history{grid-column:1;grid-row:6;min-height:70px;}
.alfred-glass-theme #stage{left:12px;right:12px;top:145px;bottom:20px;max-height:calc(100dvh - 165px);}
.alfred-glass-theme #alfredTimersDock{left:15px;bottom:95px;width:min(290px,70vw);}.alfred-glass-theme .transcript-panel{right:13px;bottom:16px;width:min(350px,55vw);}
}
@media(max-width:550px) {
.av-head{flex-wrap:wrap;gap:8px}.av-header-right{flex-wrap:wrap;margin-left:auto}.av-head-actions{gap:2px}.av-time span{display:none}.av-brand small{display:none}.av-round{width:34px;height:34px}
.av-card-top small{font-size:10px;}.av-stat-card{padding:13px;min-height:152px;}.av-lines p{font-size:11px;gap:5px}.av-weather-icon{font-size:30px;}.av-weather-body strong{font-size:25px;}
.av-voice-controls{gap:8px;}.av-voice-center{min-width:130px;}.av-voice-center>strong{font-size:23px;}.av-voice-center>p{font-size:11px}.av-mic-orb{width:96px;height:96px;}.av-sound-control{width:65px;min-height:90px;padding:3px;}.av-sound-control .a-icon{width:22px;height:22px;}.av-sound-control strong{font-size:10px;}.av-sound-control small{font-size:10px;}
.alfred-main-v1 .av-shortcuts{grid-template-columns:repeat(3,minmax(0,1fr));}.av-history{flex-wrap:wrap;}.alfred-glass-theme .transcript-panel{position:relative;left:auto;right:auto;bottom:auto;margin:8px 12px;width:auto;}.alfred-glass-theme #alfredTimersDock{bottom:12px;left:12px;}
}
@media(prefers-reduced-motion:reduce){.alfred-main-v1 *{animation:none!important;transition:none!important}}
+72
View File
@@ -0,0 +1,72 @@
/* ALFRED_GLASS_V2 — supplement au theme salon V1. Ne supprime aucun controle audio. */
.alfred-glass-theme .alfred-main-v1 {
grid-template-columns:minmax(0,1fr)!important;
grid-template-rows: minmax(76px,95px) minmax(185px,225px) minmax(165px,1fr) minmax(98px,120px)!important;
gap:clamp(9px,1.15vw,16px)!important;
padding:0 clamp(12px,1.65vw,28px) clamp(12px,1.55vw,23px)!important;
}
.alfred-glass-theme .alfred-main-v1 .av-head {grid-column:1!important;grid-row:1!important;}
/* Le menu lateral est masque mais reste dans le DOM pour les modules deja installes. */
.alfred-glass-theme .alfred-main-v1 .av-rail,
.alfred-glass-theme .alfred-main-v1 .av-history {display:none!important;}
.alfred-glass-theme .alfred-main-v1 .av-summary {grid-column:1!important;grid-row:2!important;gap:clamp(9px,1.05vw,17px);}
.alfred-glass-theme .alfred-main-v1 .av-voice-hero {grid-column:1!important;grid-row:3!important;min-height:160px;}
.alfred-glass-theme .alfred-main-v1 .av-shortcuts {grid-column:1!important;grid-row:4!important;gap:clamp(7px,.9vw,14px);}
/* Plus de microphone central, ni de commandes en double : les boutons du bandeau superieur restent fonctionnels. */
.alfred-glass-theme .alfred-main-v1 .av-mic-orb,
.alfred-glass-theme .alfred-main-v1 .av-sound-control {display:none!important;}
.alfred-glass-theme .alfred-main-v1 .av-voice-controls {position:relative;z-index:2;gap:0;align-self:stretch;min-height:100%;padding-top:clamp(70px,12vh,130px);pointer-events:none;}
.alfred-glass-theme .alfred-main-v1 .av-voice-center {pointer-events:auto;min-width:0;width:100%;}
.alfred-glass-theme .alfred-main-v1 .av-voice-center > strong {font-size:clamp(23px,2.25vw,38px);}
.alfred-glass-theme .alfred-main-v1 .av-voice-center > p {font-size:clamp(12px,1vw,16px);}
.alfred-glass-theme .alfred-main-v1 .av-wave-host {top:24%;width:min(800px,88%);height:110px;opacity:1;z-index:1;}
.alfred-glass-theme .alfred-main-v1 .av-wave-host #wave {height:110px;}
.alfred-glass-theme .alfred-main-v1.av-mic-is-muted .av-wave-host {opacity:.18;filter:grayscale(1);}
.alfred-glass-theme .alfred-main-v1[data-mode="thinking"] .av-wave-host {filter:hue-rotate(25deg);}
.alfred-glass-theme .alfred-main-v1[data-mode="speaking"] .av-wave-host {filter:hue-rotate(-24deg);}
/* Cartes moins opaques pour laisser respirer l'ambiance salon. */
.alfred-glass-theme .alfred-main-v1 .av-stat-card {
background:linear-gradient(135deg,rgba(12,25,41,.67),rgba(9,20,34,.54))!important;
border-color:rgba(206,228,252,.23)!important;
backdrop-filter:blur(15px) saturate(120%)!important;
-webkit-backdrop-filter:blur(15px) saturate(120%)!important;
}
.alfred-glass-theme .alfred-main-v1 .av-stat-card:hover {
background:linear-gradient(135deg,rgba(20,42,65,.80),rgba(13,28,47,.68))!important;
border-color:rgba(142,203,255,.55)!important;
}
.alfred-glass-theme .alfred-main-v1 .av-shortcuts button {
background:rgba(9,20,34,.65)!important;
border-color:rgba(200,224,247,.32)!important;
backdrop-filter:blur(14px)!important;
}
.alfred-glass-theme .alfred-main-v1 .av-shortcuts button:hover {background:rgba(26,56,85,.8)!important;}
.alfred-glass-theme .av-round {background:rgba(11,24,38,.68)!important;}
.alfred-glass-theme .av-round[aria-pressed="true"] {background:rgba(92,26,39,.83)!important;}
.alfred-glass-theme #stage {left:clamp(12px,1.65vw,28px)!important;right:clamp(12px,1.65vw,28px)!important;bottom:clamp(12px,1.55vw,23px)!important;background:rgba(7,20,37,.83)!important;}
.alfred-glass-theme .transcript-panel {bottom:20px!important;background:rgba(9,22,38,.79)!important;}
.alfred-glass-theme #alfredTimersDock {left:24px!important;bottom:21px!important;background:rgba(9,22,38,.82)!important;}
.alfred-glass-theme #alfredTimerToast {left:24px!important;bottom:125px!important;}
/* Le bouton de menu est un vrai bouton, sans lien direct vers /config. */
.alfred-glass-theme #avTopMenu {position:relative;}
@media(max-width:1100px){
body.alfred-glass-theme{overflow:auto!important;}
.alfred-glass-theme .alfred-main-v1 {position:relative!important;min-height:100dvh;height:auto;overflow:visible!important;grid-template-rows:auto auto minmax(180px,240px) auto!important;}
.alfred-glass-theme .alfred-main-v1 .av-summary {grid-template-columns:repeat(2,minmax(0,1fr));}
.alfred-glass-theme .alfred-main-v1 .av-stat-card {min-height:175px;}
.alfred-glass-theme .alfred-main-v1 .av-voice-controls {padding-top:90px;}
.alfred-glass-theme .alfred-main-v1 .av-wave-host {top:21%;}
.alfred-glass-theme .alfred-main-v1 .av-shortcuts {grid-template-columns:repeat(4,minmax(0,1fr));}
.alfred-glass-theme #stage {top:105px!important;bottom:22px!important;}
}
@media(max-width:600px){
.alfred-glass-theme .alfred-main-v1 {padding:0 11px 15px!important;grid-template-rows:auto auto minmax(170px,210px) auto!important;}
.alfred-glass-theme .alfred-main-v1 .av-head {padding:14px 2px!important;}
.alfred-glass-theme .av-header-right{gap:6px;}
.alfred-glass-theme .alfred-main-v1 .av-stat-card {min-height:155px;padding:12px;}
.alfred-glass-theme .alfred-main-v1 .av-shortcuts{grid-template-columns:repeat(3,minmax(0,1fr));}
.alfred-glass-theme .alfred-main-v1 .av-voice-controls {padding-top:80px;}
.alfred-glass-theme .alfred-main-v1 .av-wave-host{width:100%;}
.alfred-glass-theme #alfredTimersDock{left:11px!important;bottom:12px!important;}
}
@media(prefers-reduced-motion:reduce){.alfred-main-v1 .av-wave-host canvas{animation:none!important;}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+137
View File
@@ -0,0 +1,137 @@
/* Admin Alfred Glass — style harmonisé et configuration du cadre Maison. */
(() => {
'use strict';
const page = document.body.dataset.page;
if (!['config','surveillance'].includes(page)) return;
document.body.classList.add('alfred-admin-glass');
const rail = document.createElement('aside');
rail.className = 'ag-rail';
rail.setAttribute('aria-label','Navigation Alfred');
rail.innerHTML = `<a href="/" title="Accueil"><span>⌂</span>Accueil</a>
<a href="/config" title="Configuration" class="${page==='config'?'is-active':''}"><span>⚙</span>Réglages</a>
<a href="/surveillance" title="Surveillance" class="${page==='surveillance'?'is-active':''}"><span>▤</span>Suivi</a>`;
document.querySelector('.admin-layout')?.prepend(rail);
const brand = document.querySelector('.admin-header .brand small');
if (brand) brand.textContent = 'Assistant vocal · Centre de contrôle';
const main = document.getElementById('mainArea');
if (!main || page !== 'config') return;
const editor = document.createElement('section');
editor.id='agMaisonEditor';
editor.className='panel ag-maison-editor';
editor.innerHTML = `
<div class="panel-title"><span class="panel-symbol">⌂</span>
<div><h2>Cadre « Maison » de l’accueil</h2><p>Personnalise jusqu’à trois informations affichées en haut de l’écran principal</p></div></div>
<p class="ag-help">Choisis un compteur global ou une entité précise de Home Assistant, et personnalise le libellé. Les valeurs sont actualisées automatiquement sans redémarrer Alfred.</p>
<div id="agMaisonRows" class="ag-maison-rows"></div>
<div class="ag-maison-actions">
<button class="button ghost" type="button" id="agAddMaison">+ Ajouter une ligne</button>
<button class="button ghost" type="button" id="agDefaultMaison">Valeurs par défaut</button>
<button class="button primary" type="button" id="agSaveMaison">Enregistrer le cadre Maison</button>
</div><p id="agMaisonMessage" role="status" aria-live="polite" class="message"></p>`;
main.querySelector('.restart-panel')?.before(editor);
const types = [
['lights_on','Nombre de lumières allumées'],
['covers_closed','Nombre de volets fermés'],
['indoor_temperature','Température intérieure'],
['entity','Entité Home Assistant précise']
];
const defaults = [
{kind:'lights_on',label:'Lumières allumées'},
{kind:'covers_closed',label:'Volets fermés'},
{kind:'indoor_temperature',label:'Température intérieure'}
];
const list = editor.querySelector('#agMaisonRows');
const message = editor.querySelector('#agMaisonMessage');
const add = editor.querySelector('#agAddMaison');
let entities = [];
let rows = defaults.map(x=>({...x}));
let busy=false;
const msg=(text,error=false)=>{message.textContent=text;message.classList.toggle('ag-error',error);};
const el=(name,cls,text)=>{const node=document.createElement(name);if(cls)node.className=cls;if(text!==undefined)node.textContent=text;return node;};
function render() {
list.replaceChildren();
rows.forEach((row,i)=>{
const box=el('div','ag-row');
const header=el('div','ag-row-header');
header.append(el('strong','',`Ligne ${i+1}`));
const nav=el('div','ag-row-buttons');
const action=(name,symbol,title,fn)=>{
const b=el('button','ag-small-button',symbol);b.type='button';b.title=title;b.setAttribute('aria-label',title);
b.disabled=busy;b.addEventListener('click',fn);nav.append(b);
};
action('up','↑','Monter',()=>{if(i){[rows[i-1],rows[i]]=[rows[i],rows[i-1]];render();}});
action('down','↓','Descendre',()=>{if(i<rows.length-1){[rows[i],rows[i+1]]=[rows[i+1],rows[i]];render();}});
action('remove','×','Supprimer',()=>{if(rows.length>1){rows.splice(i,1);render();}});
header.append(nav);box.append(header);
const settings=el('div','ag-row-fields');
const kindWrap=el('label','ag-field');kindWrap.append(el('span','', 'Information affichée'));
const kind=el('select');
types.forEach(([value,txt])=>{const o=el('option','',txt);o.value=value;o.selected=row.kind===value;kind.append(o);});
kind.addEventListener('change',()=>{row.kind=kind.value;if(row.kind==='entity'&&!row.entity_id)row.entity_id=entities[0]?.id||'';render();});
kindWrap.append(kind);settings.append(kindWrap);
const labelWrap=el('label','ag-field');labelWrap.append(el('span','', 'Intitulé personnalisé'));
const text=el('input');text.type='text';text.maxLength=38;text.required=true;text.value=row.label||'';
text.addEventListener('input',()=>{row.label=text.value;});labelWrap.append(text);settings.append(labelWrap);
if(row.kind==='entity'){
const entityWrap=el('label','ag-field ag-wide');entityWrap.append(el('span','', 'Entité Home Assistant'));
const field=el('input');field.type='text';field.maxLength=120;
field.value=row.entity_id||'';field.placeholder='sensor.salon_temperature';
field.setAttribute('list',`agEntityList${i}`);
field.addEventListener('input',()=>{row.entity_id=field.value.trim();});
const choices=el('datalist');choices.id=`agEntityList${i}`;
entities.forEach(entry=>{const o=el('option');o.value=entry.id;o.label=entry.name;choices.append(o);});
entityWrap.append(field,choices);
const helper=el('small','',entities.length
? 'Choisis une entité proposée ou saisis son identifiant.'
: 'Saisis un identifiant comme sensor.salon_temperature si Home Assistant est inaccessible.');
entityWrap.append(helper);settings.append(entityWrap);
}
box.append(settings);list.append(box);
});
add.disabled=rows.length>=3 || busy;
}
function tryAuthFetch(url,options={}){
return fetch('/api/control/auth',{cache:'no-store',credentials:'same-origin'}).then(async r=>{
const a=await r.json();if(!a.authenticated)throw new Error('Connexion administrateur nécessaire.');
const headers=options.method==='POST'?{'Content-Type':'application/json','X-Alfred-CSRF':a.csrf}:{};
const response=await fetch(url,{...options,headers,credentials:'same-origin',cache:'no-store'});
const body=await response.json().catch(()=>({}));
if(!response.ok)throw new Error(body.error||`Erreur HTTP ${response.status}`);
return body;
});
}
async function refresh(){
if(busy)return;
busy=true;
try{
const config=await tryAuthFetch('/api/control/home-card');
rows=Array.isArray(config.rows)?config.rows:defaults.map(x=>({...x}));
const response=await tryAuthFetch('/api/control/home-entities').catch(err=>{msg('Liste des entités indisponible : '+err.message,true);return null;});
entities=response?.entities||[];
if(response)msg(`${entities.length} entités Home Assistant disponibles.`);
}catch(e){msg(e.message,true);}
finally{busy=false;render();}
}
editor.querySelector('#agDefaultMaison').addEventListener('click',()=>{rows=defaults.map(x=>({...x}));render();msg('Valeurs par défaut prêtes à enregistrer.');});
add.addEventListener('click',()=>{if(rows.length>=3)return;rows.push({kind:'entity',label:'Information',entity_id:entities[0]?.id||''});render();});
editor.querySelector('#agSaveMaison').addEventListener('click',async()=>{
if(busy)return;
busy=true;
try{
const result=await tryAuthFetch('/api/control/home-card',{method:'POST',body:JSON.stringify({rows})});
rows=result.rows;
msg('Cadre Maison enregistré. Les changements apparaîtront sur l’accueil en quelques secondes.');
}catch(e){msg('Impossible d’enregistrer : '+e.message,true);}
finally{busy=false;render();}
});
let loaded=false;
async function checkLogin(){
if(!main.hidden&&!loaded){loaded=true;await refresh();}
if(main.hidden)loaded=false;
}
new MutationObserver(checkLogin).observe(main,{attributes:true,attributeFilter:['hidden']});
checkLogin();
})();
+3 -2
View File
@@ -54,6 +54,7 @@
configDirty(); configDirty();
$('pendingNotice').hidden = false; $('pendingNotice').hidden = false;
setMessage(result.ok ? 'Configuration sauvegardée.' : 'Enregistrement inconnu.'); setMessage(result.ok ? 'Configuration sauvegardée.' : 'Enregistrement inconnu.');
if (result.identity_sync_warning) setMessage('Nom enregistré, mais identity.md non synchronisé : '+result.identity_sync_warning, true);
} catch (error) { setMessage(error.message, true); } } catch (error) { setMessage(error.message, true); }
} }
function serviceStatus(el, data) { function serviceStatus(el, data) {
@@ -165,12 +166,12 @@
fields.forEach(el=>{el.addEventListener('change',markInput);el.addEventListener('input',markInput);}); fields.forEach(el=>{el.addEventListener('change',markInput);el.addEventListener('input',markInput);});
$('saveConfig')?.addEventListener('click',saveConfig); $('saveConfig')?.addEventListener('click',saveConfig);
$('restartButton')?.addEventListener('click',async ()=>{ $('restartButton')?.addEventListener('click',async ()=>{
if(!confirm('Redémarrer Alfred maintenant ? Les opérations en cours seront interrompues.'))return; if(!confirm('Redémarrer '+(document.querySelector('.brand strong')?.textContent || 'l’assistant')+' maintenant ? Les opérations en cours seront interrompues.'))return; // ALFRED_IDENTITY_GLASS_V1
try{const data=await fetchJSON('/api/control/restart',{method:'POST'});setMessage(data.message||'Demande transmise.');$('restartButton').disabled=true;setTimeout(()=>$('restartButton').disabled=false,5000);} try{const data=await fetchJSON('/api/control/restart',{method:'POST'});setMessage(data.message||'Demande transmise.');$('restartButton').disabled=true;setTimeout(()=>$('restartButton').disabled=false,5000);}
catch(err){setMessage(err.message,true);} catch(err){setMessage(err.message,true);}
}); });
$('testVoice')?.addEventListener('click',async()=>{ $('testVoice')?.addEventListener('click',async()=>{
try{await fetchJSON('/api/tts/say',{method:'POST',body:JSON.stringify({text:'Bonjour, je suis Alfred. Voici un test de ma voix actuelle.'})});setMessage('Test vocal demandé.');} try{await fetchJSON('/api/tts/say',{method:'POST',body:JSON.stringify({text:'Bonjour, je suis '+(document.querySelector('.brand strong')?.textContent || 'votre assistant')+'. Voici un test de ma voix actuelle.'})});setMessage('Test vocal demandé.');}
catch(err){setMessage(err.message,true);} catch(err){setMessage(err.message,true);}
}); });
let volDelay, gainDelay; let volDelay, gainDelay;
@@ -0,0 +1,60 @@
/* Identity editing: admin-protected API, optimistic revision checks, no HTML injection. */
(() => {
'use strict';
const $=id=>document.getElementById(id);
let csrf='',revision='',dirty=false,busy=false;
const msg=(s,error=false,id='idMessage')=>{const e=$(id);e.textContent=s;e.classList.toggle('error',error);};
const setName=name=>{
const n=String(name||'Assistant').trim();
const initial=Array.from(n)[0]?.toLocaleUpperCase('fr-FR')||'A';
$('idName').textContent=n;$('idInitial').textContent=initial;
$('idActiveName').textContent=n;$('idAvatar').textContent=initial;
document.title='Identité · '+n;
};
function updateCount(){$('idCount').textContent=$('idText').value.length.toLocaleString('fr-FR')+' caractères';}
async function jsonFetch(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 a=await jsonFetch('/api/control/auth');
csrf=a.csrf||'';
$('idLoginPanel').hidden=!!a.authenticated;
$('idEditor').hidden=!a.authenticated;
$('idBadge').textContent=a.authenticated?'Administrateur connecté':'Connexion requise';
$('idBadge').classList.toggle('ok',!!a.authenticated);
if(a.authenticated)await load();
}
async function load(){
try{
const d=await jsonFetch('/api/control/identity');
$('idText').value=d.text;revision=d.revision;dirty=false;
setName(d.name);updateCount();msg('Document chargé.');
}catch(e){msg(e.message,true);}
}
$('idLoginForm').addEventListener('submit',async e=>{
e.preventDefault();
try{
await jsonFetch('/api/control/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:$('idPassword').value})});
$('idPassword').value='';await auth();
}catch(err){msg(err.message,true,'idLoginMessage');}
});
$('idText').addEventListener('input',()=>{dirty=true;updateCount();msg('Modifications non enregistrées.');});
$('idSave').addEventListener('click',async()=>{
if(busy||!revision)return;
busy=true;$('idSave').disabled=true;
try{
const d=await jsonFetch('/api/control/identity',{
method:'POST',headers:{'Content-Type':'application/json','X-Alfred-CSRF':csrf},
body:JSON.stringify({text:$('idText').value,revision})
});
revision=d.revision;dirty=false;msg(d.updated?'Identité enregistrée. Nouvelle version sauvegardée.':'Aucun changement à enregistrer.');
}catch(e){msg(e.message,true);}
finally{busy=false;$('idSave').disabled=false;}
});
$('idReload').addEventListener('click',()=>{if(dirty&&!confirm('Abandonner les modifications non enregistrées ?'))return;load();});
window.addEventListener('beforeunload',event=>{if(dirty){event.preventDefault();event.returnValue='';}});
auth().catch(e=>{msg(e.message,true,'idLoginMessage');$('idLoginPanel').hidden=false;});
})();
+125
View File
@@ -0,0 +1,125 @@
/* ALFRED_LISTES_GLASS_V1 — course/tâches, fenêtre Glass, pas de dépendance JS. */
(() => {
'use strict';
if (document.getElementById('alfredListsGlassDialog')) return;
const $ = id => document.getElementById(id);
const icon = (d) => `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${d}</svg>`;
const shopping = icon('<path d="M3 7h18l-2 12H5L3 7ZM8 8V6a4 4 0 0 1 8 0v2"/>');
const tasks = icon('<rect x="3" y="3" width="18" height="18" rx="3"/><path d="m7 12 3 3 6-6"/>');
const shell = document.getElementById('alfredMainV1');
if (!shell) return; // Le tableau de bord doit être installé avant ce module.
const modal = document.createElement('dialog');
modal.id = 'alfredListsGlassDialog';
modal.className = 'al-list-dialog';
modal.innerHTML = `<div class="al-list-head">
<div><p class="al-eyebrow">ALFRED · ORGANISATION</p><h2>Courses & tâches</h2><p class="al-subtitle">Tes listes sont conservées sur Alfred et mises à jour en temps réel.</p></div>
<button type="button" class="al-close" data-al-close title="Fermer" aria-label="Fermer">×</button>
</div><div class="al-list-grid">
<section class="al-list-section" data-kind="courses"><header class="al-list-sec-head"><div class="al-list-sec-icon">${shopping}</div><div><h3>Liste de courses</h3><p><span id="alCountCourses">—</span> article(s) à acheter</p></div></header>
<form class="al-add-form" data-kind="courses"><input name="label" maxlength="160" placeholder="Pain, lait, tomates…" aria-label="Nouvelle course" required /><button type="submit">+ Ajouter</button></form>
<ul class="al-items" id="alItemsCourses" aria-label="Courses"> </ul></section>
<section class="al-list-section" data-kind="taches"><header class="al-list-sec-head"><div class="al-list-sec-icon al-task-icon">${tasks}</div><div><h3>Liste de tâches</h3><p><span id="alCountTaches">—</span> tâche(s) à faire</p></div></header>
<form class="al-add-form" data-kind="taches"><input name="label" maxlength="160" placeholder="Peindre le mur…" aria-label="Nouvelle tâche" required /><button type="submit">+ Ajouter</button></form>
<ul class="al-items" id="alItemsTaches" aria-label="Tâches"> </ul></section>
</div><div class="al-footer"><span id="alListStatus" role="status" aria-live="polite">Chargement…</span><button type="button" data-al-refresh>Actualiser</button></div>`;
document.body.append(modal);
// Navigation: garder toutes les entrées existantes, y compris « Plus ».
const rail = shell.querySelector('.av-rail');
if (rail && !rail.querySelector('[data-al-open]')) {
const btn = document.createElement('button');
btn.type = 'button'; btn.className = 'av-rail-link al-rail-link';
btn.setAttribute('data-al-open', '');
btn.innerHTML = `${tasks}<span>Listes</span>`;
rail.querySelector('[data-av-action="more"]')?.before(btn);
if (!btn.isConnected) rail.append(btn);
}
const shortcuts = shell.querySelector('.av-shortcuts');
if (shortcuts && !shortcuts.querySelector('[data-al-open]')) {
const btn = document.createElement('button'); btn.type = 'button';
btn.setAttribute('data-al-open', '');
btn.innerHTML = `${shopping}<span id="alShortcutText">Courses & tâches</span>`;
shortcuts.querySelector('[data-av-action="more"]')?.before(btn);
if (!btn.isConnected) shortcuts.append(btn);
}
const more = $('avMoreDialog')?.querySelector('.av-more-grid');
if (more && !more.querySelector('[data-al-open]')) {
const btn = document.createElement('button');btn.type='button';btn.setAttribute('data-al-open','');
btn.innerHTML = `${shopping} Courses & tâches`; more.append(btn);
}
function status(msg, error = false) {
const el = $('alListStatus'); if (!el) return;
el.textContent = msg;el.classList.toggle('al-error',error);
}
async function fetchLists() {
const res = await fetch('/api/alfred/lists', {cache:'no-store'});
const data = await res.json(); if (!res.ok || !data.ok) throw Error(data.error || `HTTP ${res.status}`);
return data;
}
async function send(action, payload) {
const res = await fetch('/api/alfred/lists/action', {
method: 'POST', headers: {'Content-Type':'application/json','X-Alfred-Request':'1'},
body: JSON.stringify({action,...payload})
});
const data = await res.json();if (!res.ok || !data.ok)throw Error(data.error || `HTTP ${res.status}`);
return data;
}
function element(tag, text, cls) {
const e=document.createElement(tag); if (cls)e.className=cls; if (text!==undefined)e.textContent=text;return e;
}
function render(items, kind) {
const target=$(kind==='courses'?'alItemsCourses':'alItemsTaches');if(!target)return;
target.replaceChildren();
if(!items.length) {
const li=element('li', 'Ta liste est vide. Ajoute un premier élément !', 'al-empty');target.append(li);return;
}
for(const item of items) {
const li=element('li',undefined,'al-item'+(item.done?' is-done':''));
const check=element('button',item.done?'✓':'','al-check');check.type='button';
check.setAttribute('aria-label',item.done?'Réouvrir '+item.label:'Marquer comme terminé : '+item.label);
check.title=item.done?'Réouvrir':'Cocher';
check.addEventListener('click',()=>modify(item.done?'reopen_item':'complete_item',kind,item.id));
const label=element('span',item.label,'al-item-label');
const remove=element('button','×','al-remove');remove.type='button';remove.title='Supprimer';
remove.setAttribute('aria-label','Supprimer '+item.label);
remove.addEventListener('click',()=>modify('delete_item',kind,item.id));
li.append(check,label,remove);target.append(li);
}
}
async function refresh() {
try {
const data = await fetchLists();
for(const kind of ['courses','taches']) {
render(data.lists[kind] || [],kind);
const dest=$(kind==='courses'?'alCountCourses':'alCountTaches');
if(dest)dest.textContent=data.counts[kind]?.active ?? 0;
}
const shortcut=$('alShortcutText');
if(shortcut)shortcut.textContent=`Courses ${data.counts.courses.active} · Tâches ${data.counts.taches.active}`;
status('Listes à jour');
} catch(e) {status(`Erreur : ${e.message}`,true);}
}
async function modify(action,kind,id) {
try {await send(action,{kind,id});await refresh();}catch(e){status(e.message,true);}
}
document.addEventListener('click',(ev)=>{
if(!ev.target.closest('[data-al-open]'))return;
if($('avMoreDialog')?.open)$('avMoreDialog').close();
modal.showModal();refresh();
});
modal.querySelector('[data-al-close]').addEventListener('click',()=>modal.close());
modal.querySelector('[data-al-refresh]').addEventListener('click',refresh);
modal.querySelectorAll('.al-add-form').forEach(form=>form.addEventListener('submit',async ev=>{
ev.preventDefault();const input=form.querySelector('input');const label=input.value.trim();if(!label)return;
try {
const result=await send('add_item',{kind:form.dataset.kind,label});
input.value='';await refresh();status(result.already_present?'Cet élément est déjà présent.':'Élément ajouté.');input.focus();
}catch(e){status(e.message,true);}
}));
window.alfredSocket?.on('alfred_lists_changed',()=>refresh());
window.addEventListener('focus',refresh);
setInterval(refresh,30000);
refresh();
window.AlfredLists={open:()=>{modal.showModal();refresh();},refresh};
})();
+45
View File
@@ -0,0 +1,45 @@
/* ALFRED_GLASS_LOGS_V2 — aucun HTML de journal injecte, seulement textContent. */
(() => {
'use strict';
const $=id=>document.getElementById(id);
let all=[];let authenticated=false;let pending=false;
const when=new Intl.DateTimeFormat('fr-FR',{dateStyle:'short',timeStyle:'medium',timeZone:'Europe/Paris'});
const el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=String(text);return e;};
async function identity(){try{const r=await fetch('/api/state',{cache:'no-store'});if(!r.ok)return;const d=await r.json();const n=String(d.assistant_name||d.title||'').trim();if(!n)return;$('lgName').textContent=n;$('lgLetter').textContent=Array.from(n)[0].toLocaleUpperCase('fr-FR');document.title='Journaux · '+n;}catch(_){}}
identity();
function render(){
const needle=$('lgSearch').value.trim().toLocaleLowerCase('fr-FR');
const component=$('lgComponent').value,level=$('lgLevel').value;
const filtered=all.filter(x=>(!component||x.component===component)&&(!level||x.level===level)&&(!needle||(x.message||'').toLocaleLowerCase('fr-FR').includes(needle)));
const root=$('lgEvents');root.replaceChildren();
$('lgCount').textContent=`${filtered.length} / ${all.length} événement(s)`;
if(!filtered.length){root.append(el('div','lg-empty',authenticated?'Aucun événement correspondant aux filtres.':'Connecte-toi via la page Configuration pour consulter les journaux.'));return;}
for(const x of filtered.slice().reverse()){
const line=el('div','lg-event');line.dataset.level=x.level;
let date='—';try{date=when.format(new Date(x.at));}catch(_){}
const time=el('time','',date);time.dateTime=x.at;
line.append(time,el('span','lg-component',x.component),el('span','lg-level',x.level),el('span','lg-message',x.message));
root.append(line);
}
}
async function refresh(){
if(pending)return;pending=true;$('lgStatus').textContent='Actualisation…';
try{const r=await fetch('/api/alfred/logs?limit='+encodeURIComponent($('lgLimit').value),{cache:'no-store'});const d=await r.json();
if(r.status===401){authenticated=false;all=[];$('lgStatus').textContent='Accès administrateur requis · Connexion depuis /config';render();return;}
if(!r.ok||!d.ok)throw Error(d.error||`HTTP ${r.status}`);
authenticated=true;all=Array.isArray(d.items)?d.items:[];
$('lgStatus').textContent='Dernière lecture : '+new Date().toLocaleTimeString('fr-FR',{timeZone:'Europe/Paris'});
render();
}catch(e){$('lgStatus').textContent='Journal indisponible : '+e.message;}finally{pending=false;}
}
for(const id of ['lgSearch','lgComponent','lgLevel'])$(id).addEventListener('input',render);
$('lgLimit').addEventListener('change',refresh);
$('lgRefresh').addEventListener('click',refresh);
$('lgExport').addEventListener('click',()=>{
if(!authenticated||!all.length)return;
const file=new Blob([JSON.stringify({source:'alfred.service',exported_at:new Date().toISOString(),events:all},null,2)],{type:'application/json'});
const url=URL.createObjectURL(file);const a=document.createElement('a');a.href=url;a.download='alfred-journaux-'+new Date().toISOString().slice(0,10)+'.json';a.click();URL.revokeObjectURL(url);
});
setInterval(()=>{if($('lgAuto').checked&&!document.hidden)refresh();},10000);
window.addEventListener('focus',refresh);refresh();
})();
+319
View File
@@ -0,0 +1,319 @@
/* ALFRED_MAIN_VISUAL_V1 — accueil vivant, conservation des contrôleurs existants. */
(() => {
'use strict';
if (document.getElementById('alfredMainV1')) return;
const shell = document.querySelector('.alfred-shell');
if (!shell || !document.getElementById('stage') || !document.getElementById('content')) return;
const I = {
home:'<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z"/>',
bulb:'<path d="M9 18h6m-5 3h4m-6-7c-1-1.2-2-2.5-2-5a6 6 0 0 1 12 0c0 2.5-1 3.8-2 5-.8 1-1 1.9-1 2H9c0-.1-.2-1-1-2z"/>',
server:'<rect x="3" y="3" width="18" height="8" rx="2"/><rect x="3" y="13" width="18" height="8" rx="2"/><path d="M7 7h.01M7 17h.01M12 7h6M12 17h6"/>',
brain:'<path d="M12 18V5a3 3 0 0 0-5-2 3 3 0 0 0-3 4 4 4 0 0 0 0 7 3.5 3.5 0 0 0 3.3 5A3.5 3.5 0 0 0 12 18m0-13a3 3 0 0 1 5-2 3 3 0 0 1 3 4 4 4 0 0 1 0 7 3.5 3.5 0 0 1-3.3 5A3.5 3.5 0 0 1 12 18"/><path d="m8 10 4 2 4-2"/>',
media:'<rect x="3" y="4" width="18" height="16" rx="3"/><circle cx="8.5" cy="9" r="1.3"/><path d="m4 17 5-5 4 4 3-3 4 4"/>',
more:'<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
mic:'<rect x="9" y="2.5" width="6" height="13" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4m-4 0h8"/>',
micOff:'<path d="M9 9v3a3 3 0 0 0 5.5 1.7M15 9V5.5a3 3 0 0 0-5.5-1.7M5 11a7 7 0 0 0 11.5 5.4M19 11a7 7 0 0 1-.5 2.6M12 18v4m-4 0h8M3 3l18 18"/>',
speaker:'<path d="M11 5 6 9H3v6h3l5 4V5ZM15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>',
speakerOff:'<path d="M11 5 6 9H3v6h3l5 4V5ZM16 9l5 6m0-6-5 6"/>',
camera:'<rect x="2" y="5" width="14" height="14" rx="3"/><path d="m16 10 6-3v10l-6-3"/>',
settings:'<path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"/><circle cx="12" cy="12" r="4"/>',
calendar:'<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 10h18"/>',
clock:'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
cloud:'<path d="M7 18h11a4 4 0 0 0 0-8h-1a6 6 0 0 0-11-1 4.5 4.5 0 0 0 1 9Z"/>',
shield:'<path d="m12 2 8 3v6c0 6-3.7 9-8 11-4.3-2-8-5-8-11V5z"/><path d="m9 12 2 2 4-4"/>',
arrow:'<path d="m9 5 7 7-7 7"/>',
chat:'<path d="M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-5 3v-3a2 2 0 0 1-1-2V7a2 2 0 0 1 2-2z"/>',
close:'<path d="M5 5l14 14M19 5 5 19"/>',
sun:'<circle cx="12" cy="12" r="4"/><path d="M12 1v3m0 16v3M1 12h3m16 0h3M4 4l2 2m12 12 2 2m0-16-2 2M6 18l-2 2"/>',
spark:'<path d="m12 2 2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z"/>',
};
const icon = (name, extra = '') => `<svg class="a-icon ${extra}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${I[name] || I.more}</svg>`;
const initialName = (document.title || 'Alfred').trim();
const dashboard = document.createElement('div');
dashboard.id = 'alfredMainV1';
dashboard.className = 'alfred-main-v1';
dashboard.innerHTML = `
<header class="av-head">
<div class="av-brand"><div class="av-logo">A</div><div><strong id="avName"></strong><small>Assistant vocal</small></div></div>
<div class="av-header-right"><div class="av-time"><strong id="avTime">--:--</strong><span id="avDate">—</span></div>
<div class="av-head-actions">
<button type="button" class="av-round" id="avTopCamera" aria-label="Ouvrir la caméra" title="Caméra">${icon('camera')}</button>
<button type="button" class="av-round" id="avTopMic" aria-label="Couper le microphone" aria-pressed="false" title="Couper le micro">${icon('mic')}</button>
<button type="button" class="av-round" id="avTopSpeaker" aria-label="Couper la sortie audio" aria-pressed="false" title="Couper le son">${icon('speaker')}</button>
<button type="button" class="av-round" id="avTopSettings" aria-label="Réglages audio" title="Réglages audio">${icon('settings')}</button>
<a class="av-round" href="/config" aria-label="Configuration d'Alfred" title="Configuration">${icon('more')}</a>
</div>
</div>
</header>
<nav class="av-rail" aria-label="Navigation principale">
<button type="button" class="av-rail-link selected" data-av-action="home">${icon('home')}<span>Accueil</span></button>
<button type="button" class="av-rail-link" data-av-action="house">${icon('bulb')}<span>Maison</span></button>
<a class="av-rail-link" href="/surveillance">${icon('server')}<span>Système</span></a>
<a class="av-rail-link" href="/surveillance">${icon('brain')}<span>IA</span></a>
<button type="button" class="av-rail-link" data-av-action="media">${icon('media')}<span>Médias</span></button>
<button type="button" class="av-rail-link" data-av-action="more">${icon('more')}<span>Plus</span></button>
</nav>
<section class="av-summary" aria-label="État de la maison et des services">
<button class="av-stat-card" type="button" data-av-action="house"><div class="av-card-top"><span class="av-circle home">${icon('home')}</span><span><strong>Maison</strong><small id="avHomeSummary">Chargement…</small></span>${icon('arrow','av-chevron')}</div><div class="av-lines"><p>${icon('bulb')}<span id="avLights">Lumières : —</span></p><p>${icon('home')}<span id="avCovers">Volets : —</span></p><p>${icon('sun')}<span id="avIndoor">Température : —</span></p></div></button>
<a class="av-stat-card" href="/surveillance"><div class="av-card-top"><span class="av-circle system">${icon('server')}</span><span><strong>Système</strong><small>Mini-PC Alfred</small></span>${icon('arrow','av-chevron')}</div><div class="av-lines"><p><span class="av-mini-dot"></span><span id="avLoad">Charge : —</span></p><p><span class="av-mini-dot"></span><span id="avMemory">Mémoire : —</span></p><p><span class="av-mini-dot"></span><span id="avDisk">Stockage : —</span></p></div></a>
<a class="av-stat-card" href="/surveillance"><div class="av-card-top"><span class="av-circle ai">${icon('brain')}</span><span><strong>Services IA</strong><small id="avAiSummary">Statuts en cours…</small></span>${icon('arrow','av-chevron')}</div><div class="av-lines"><p><span class="av-mini-dot" id="avOllamaDot"></span><span id="avOllama">Ollama : —</span></p><p><span class="av-mini-dot" id="avHermesDot"></span><span id="avHermes">Hermes : —</span></p><p><span class="av-mini-dot" id="avComfyDot"></span><span id="avComfy">ComfyUI : —</span></p></div></a>
<button class="av-stat-card av-weather" type="button" data-av-action="weather"><div class="av-card-top"><span class="av-circle weather">${icon('cloud')}</span><span><strong>Météo</strong><small>Home Assistant</small></span>${icon('arrow','av-chevron')}</div><div class="av-weather-body"><span class="av-weather-icon" id="avWeatherIcon">☁️</span><div><strong id="avWeatherTemp">—</strong><span id="avWeatherCondition">Chargement…</span><small id="avWeatherRange"></small></div></div></button>
</section>
<section class="av-voice-hero" aria-label="Commande vocale Alfred">
<div class="av-voice-controls">
<button type="button" id="avMicButton" class="av-sound-control" aria-pressed="false">${icon('mic')}<strong>Micro</strong><small id="avMicLabel">Actif</small></button>
<div class="av-voice-center"><div class="av-mic-orb" id="avOrb">${icon('mic')}</div><strong id="avVoiceTitle">En veille</strong><p id="avVoiceSubtitle">Dites « Alfred » puis votre demande</p><span class="av-status-line"><span class="av-status-dot" id="avStatusDot"></span><span id="avStatusText">Prêt à vous écouter</span></span></div>
<button type="button" id="avSpeakerButton" class="av-sound-control" aria-pressed="false">${icon('speaker')}<strong>Sortie audio</strong><small id="avSpeakerLabel">Activée</small></button>
</div>
<div class="av-wave-host" id="avWaveHost"></div>
</section>
<div class="av-shortcuts" role="group" aria-label="Raccourcis d'Alfred">
<button type="button" data-av-action="time">${icon('clock')}<span>Quelle heure est-il ?</span></button>
<button type="button" data-av-action="weather">${icon('cloud')}<span>Météo</span></button>
<button type="button" data-av-action="house">${icon('home')}<span>État de la maison</span></button>
<button type="button" data-av-action="proxmox">${icon('server')}<span>Vérifier Proxmox</span></button>
<button type="button" data-av-action="wazuh">${icon('shield')}<span>Alertes Wazuh</span></button>
<button type="button" data-av-action="comfyui">${icon('media')}<span>Créer une image</span></button>
<button type="button" data-av-action="more">${icon('more')}<span>Plus d'outils</span></button>
</div>
<footer class="av-history"><span class="av-history-heading">${icon('chat')} <strong>Interactions récentes</strong></span><div id="avHistoryList" aria-live="polite"><span class="av-history-empty">Aucune interaction depuis l’ouverture de cette page</span></div></footer>
<button type="button" class="av-back" id="avBack" hidden>${icon('close')} Retour à l’accueil</button>
<div class="av-toast" id="avToast" role="status" aria-live="polite" hidden></div>
<dialog class="av-image-dialog" id="avComfyDialog"><form method="dialog"><button type="submit" class="av-dialog-close" aria-label="Fermer">${icon('close')}</button></form><h2>Créer une image avec ComfyUI</h2><p>Décris l’image à générer. Alfred transmettra ta demande à Hermes.</p><label for="avComfyPrompt">Description</label><textarea id="avComfyPrompt" rows="4" maxlength="450" placeholder="Une maison futuriste au coucher du soleil…"></textarea><div class="av-dialog-actions"><button id="avComfySubmit" type="button">Envoyer à Alfred</button></div></dialog>
<dialog class="av-image-dialog" id="avMoreDialog"><form method="dialog"><button type="submit" class="av-dialog-close" aria-label="Fermer">${icon('close')}</button></form><h2>Outils Alfred</h2><div class="av-more-grid"><a href="/config">${icon('settings')} Configuration</a><a href="/surveillance">${icon('server')} Surveillance</a><button type="button" data-av-action="calendar">${icon('calendar')} Agenda</button><button type="button" data-av-action="camera">${icon('camera')} Caméra</button><button type="button" data-av-action="audio">${icon('speaker')} Réglages audio</button></div></dialog>
`;
shell.insertBefore(dashboard, shell.firstChild);
document.body.classList.add('alfred-glass-theme');
document.getElementById('avName').textContent = initialName;
const wave = document.getElementById('wave');
document.getElementById('avWaveHost').appendChild(wave);
window.dispatchEvent(new Event('resize'));
const $ = id => document.getElementById(id);
const timeZone = 'Europe/Paris';
let lastStatus = 'idle';
let mute = {mic_muted:false,speaker_muted:false};
let refreshingAudio = false;
let toastTimer;
function toast(message) {
const el = $('avToast'); el.textContent = String(message); el.hidden = false;
clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, 5500);
}
function clock() {
const now = new Date();
$('avTime').textContent = new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit'}).format(now);
const date = new Intl.DateTimeFormat('fr-FR',{timeZone,weekday:'long',day:'numeric',month:'long',year:'numeric'}).format(now);
$('avDate').textContent = date.charAt(0).toUpperCase() + date.slice(1);
}
clock(); setInterval(clock, 15000);
function updateAudio(a) {
mute = {...mute,...a};
const mic = Boolean(mute.mic_muted), speaker = Boolean(mute.speaker_muted);
for (const id of ['avTopMic','avMicButton']) {
const button = $(id); button.setAttribute('aria-pressed',String(mic));
button.setAttribute('aria-label', mic ? 'Réactiver le microphone' : 'Couper le microphone');
button.title = button.getAttribute('aria-label');
button.querySelector('svg').outerHTML = icon(mic?'micOff':'mic');
}
for (const id of ['avTopSpeaker','avSpeakerButton']) {
const button = $(id); button.setAttribute('aria-pressed',String(speaker));
button.setAttribute('aria-label', speaker?'Réactiver la sortie audio':'Couper la sortie audio');
button.title = button.getAttribute('aria-label');
button.querySelector('svg').outerHTML = icon(speaker?'speakerOff':'speaker');
}
$('avMicLabel').textContent = mic?'Coupé':'Actif';
$('avSpeakerLabel').textContent = speaker?'Coupée':'Activée';
dashboard.classList.toggle('av-mic-is-muted',mic);
dashboard.classList.toggle('av-speaker-is-muted',speaker);
renderMode(lastStatus);
}
async function refreshAudio() {
if (refreshingAudio) return;
refreshingAudio = true;
try { const r=await fetch('/api/audio/settings',{cache:'no-store'});
if (!r.ok) throw Error('HTTP '+r.status);
updateAudio(await r.json());
} catch (e) { console.warn('[ALFRED UI] Réglages audio :',e); }
finally {refreshingAudio=false;}
}
async function toggleAudio(kind) {
const prop = kind==='mic'?'mic_muted':'speaker_muted';
try {
const r=await fetch('/api/audio/settings',{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({[prop]:!mute[prop]})});
const data=await r.json();
if (!r.ok) throw Error(data.error || 'HTTP '+r.status);
updateAudio(data);
toast(kind==='mic' ? (data.mic_muted?'Microphone coupé':'Microphone réactivé') : (data.speaker_muted?'Sortie audio coupée':'Sortie audio réactivée'));
} catch (e) { toast('Impossible de modifier le son : '+e.message); }
}
for (const id of ['avTopMic','avMicButton']) $(id).addEventListener('click',()=>toggleAudio('mic'));
for (const id of ['avTopSpeaker','avSpeakerButton']) $(id).addEventListener('click',()=>toggleAudio('speaker'));
$('avTopSettings').addEventListener('click',()=>{const btn=$('audioSettingsButton'); if(btn)btn.click();else location.assign('/config');});
refreshAudio(); setInterval(()=>{if(!document.hidden)refreshAudio();},30000);
function serviceText(state) {return state==='ok'?'Connecté':state==='offline'?'Hors ligne':state==='installed'?'Installé (non testé)':state==='unconfigured'?'Non configuré':'Non vérifié';}
function setService(id,dotid,label,state) {
$(id).textContent = `${label} : ${serviceText(state)}`;
$(dotid).className = `av-mini-dot ${state==='ok'?'is-ok':state==='offline'?'is-error':''}`;
}
async function refreshDashboard() {
try {
const r=await fetch('/api/alfred/dashboard',{cache:'no-store'});
if(!r.ok) throw Error('HTTP '+r.status);
const d=await r.json();
const h=d.home||{}, s=d.system||{}, ai=d.services||{}, w=d.weather||{};
$('avHomeSummary').textContent=h.available?'Home Assistant connecté':(h.detail||'Données non disponibles');
$('avLights').textContent = h.available?`${h.lights_on} lumière(s) allumée(s)`:'Lumières : indisponible';
$('avCovers').textContent = h.available?`${h.covers_closed} volet(s) fermé(s)`:'Volets : indisponible';
$('avIndoor').textContent = h.indoor_temperature!=null?`${h.indoor_temperature} °C · intérieur`:'Température : non disponible';
if (window.alfredHomeCardCustomUpdate) window.alfredHomeCardCustomUpdate();
$('avLoad').textContent = s.load_1m!=null?`Charge 1 min : ${s.load_1m}`:'Charge : non disponible';
$('avMemory').textContent = s.memory_percent!=null?`Mémoire : ${s.memory_percent} %`:'Mémoire : non disponible';
$('avDisk').textContent = s.disk_percent!=null?`Stockage : ${s.disk_percent} %`:'Stockage : non disponible';
setService('avOllama','avOllamaDot','Ollama',ai.ollama);
setService('avHermes','avHermesDot','Hermes',ai.hermes);
setService('avComfy','avComfyDot','ComfyUI',ai.comfyui);
$('avAiSummary').textContent='États vérifiés / installation locale';
$('avWeatherTemp').textContent=w.available&&w.temperature!=null?`${w.temperature} ${w.unit||'°C'}`:'—';
$('avWeatherCondition').textContent=w.available?w.condition:'Non disponible';
const sunny=['sunny','clear-night','partlycloudy'].includes(w.condition_id);
$('avWeatherIcon').textContent=sunny?'🌤️':w.condition_id==='rainy'?'🌧️':w.condition_id==='snowy'?'❄️':'☁️';
} catch (e) {
$('avHomeSummary').textContent='Données indisponibles';
$('avAiSummary').textContent='Surveillance indisponible';
$('avWeatherCondition').textContent='Indisponible';
console.warn('[ALFRED UI] Tableau de bord :',e);
}
}
refreshDashboard(); setInterval(()=>{if(!document.hidden)refreshDashboard();},30000);
async function refreshForecast() {
try {
const r=await fetch('/api/ha/weather',{cache:'no-store'});
if(!r.ok)return;
const d=await r.json();
const day=(d.daily||[])[0];
const vals=[];
if(day?.temperature!=null) vals.push('↑ '+day.temperature+'°');
if(day?.templow!=null) vals.push('↓ '+day.templow+'°');
$('avWeatherRange').textContent=vals.join(' ');
} catch (_) { /* Not all installations expose the weather forecast route. */ }
}
refreshForecast();setInterval(()=>{if(!document.hidden)refreshForecast();},180000);
function renderMode(mode) {
lastStatus = mode||'idle';
const states = {idle:['En veille','Dites « '+initialName+' » puis votre demande','Prêt'],
waiting:['En veille','Dites « '+initialName+' » puis votre demande','Prêt'],
listening:['Je vous écoute…','Quelle est votre demande ?','Écoute en cours'],
thinking:['Je réfléchis…','Traitement de la demande','Hermes travaille'],
speaking:['Je vous réponds…','Réponse vocale en cours','Piper parle'],
error:['Un problème est survenu','Consultez la transcription ou la surveillance','Erreur']};
const item=states[mode]||states.idle;
$('avVoiceTitle').textContent=mute.mic_muted?'Microphone coupé':item[0];
$('avVoiceSubtitle').textContent=mute.mic_muted?'Réactivez le micro pour utiliser le mot d’activation':item[1];
$('avStatusText').textContent=mute.mic_muted?'Écoute désactivée':item[2];
dashboard.dataset.mode=mode;
$('avOrb').setAttribute('aria-label',item[0]);
}
renderMode('idle');
const recent=[];
let lastTranscript='';
function pushRecent(text) {
const value=String(text||'').trim();
if(!value || value===lastTranscript)return;
lastTranscript=value;
recent.unshift({text:value,at:new Date()});
if(recent.length>5)recent.pop();
const root=$('avHistoryList');root.replaceChildren();
for(const e of recent) {
const node=document.createElement('span');node.className='av-history-item';
const t=document.createElement('time');t.textContent=new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit'}).format(e.at);
const content=document.createElement('span');content.textContent=e.text;
node.append(t,content);root.append(node);
}
}
function focusScene(show) {
dashboard.classList.toggle('is-focused', Boolean(show));
$('avBack').hidden = !show;
}
function onRender(d) { if(!d || typeof d!=='object')return;
if(d.type==='home')focusScene(false);
else if(d.type && d.type!=='text' || (d.text||d.title||d.url))focusScene(true);
}
const socket=window.alfredSocket;
socket?.on('alfred_state',state=>{
renderMode(String(state.mode||'idle').toLowerCase());
if(state.mode==='thinking' && state.transcript)pushRecent(state.transcript);
});
socket?.on('alfred_render',onRender);
socket?.on('audio_settings',updateAudio);
fetch('/api/state',{cache:'no-store'}).then(r=>r.ok?r.json():null).then(s=>{if(s)renderMode(String(s.mode||'idle').toLowerCase());}).catch(()=>{});
async function goHome() {
focusScene(false);
if(window.AlfredViews?.goHome){try{await window.AlfredViews.goHome();}catch(_){}}
else if($('viewHome'))$('viewHome').click();
}
$('avBack').addEventListener('click',goHome);
function openCamera() {
if($('cameraPopupButton')){ $('cameraPopupButton').click();return; }
const camera=document.querySelector('.camera-panel');
if(!camera){ toast('Caméra non installée');return; }
const isOpen=document.body.classList.toggle('av-legacy-camera-open');
const toggle=$('cameraToggle');
if(toggle && (toggle.getAttribute('aria-pressed')==='true')!==isOpen)toggle.click();
if(isOpen && !$('avLegacyCameraClose')){
const close=document.createElement('button');close.id='avLegacyCameraClose';close.type='button';close.textContent='Fermer la caméra';
close.addEventListener('click',openCamera);camera.prepend(close);
}
}
$('avTopCamera').addEventListener('click',openCamera);
function openView(name) {
const f=window.AlfredViews?.[name];
if(typeof f==='function'){ focusScene(true);f();return; }
const fallback={openWeather:'viewWeather',openTemperatures:'viewTemperatures',openCalendar:'viewCalendar'}[name];
if(fallback && $(fallback)){focusScene(true);$(fallback).click();return;}
toast('Cette fonction n’est pas disponible sur cette installation.');
}
async function quickCommand(action,description) {
try {
const r=await fetch('/api/alfred/dashboard/command',{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({action,description})});
const data=await r.json(); if(!r.ok)throw Error(data.error||'HTTP '+r.status);
pushRecent(({proxmox:'Vérifier Proxmox',wazuh:'Consulter les alertes Wazuh',comfyui:'Générer une image'})[action]||action);
toast('Demande transmise à Hermes');
} catch(e) {toast('Commande indisponible : '+e.message);}
}
function showTime() {
const current=new Intl.DateTimeFormat('fr-FR',{timeZone,hour:'2-digit',minute:'2-digit',second:'2-digit'}).format(new Date());
const content=$('content');content.replaceChildren();
const title=document.createElement('h1');title.textContent='Il est '+current;
const subtitle=document.createElement('p');subtitle.textContent='Heure de Paris · horloge locale du navigateur';
content.append(title,subtitle); focusScene(true);pushRecent('Quelle heure est-il ?');
}
async function act(name) {
if(name==='home'){await goHome();return;}
if(name==='house'){openView('openTemperatures');return;}
if(name==='weather'){openView('openWeather');return;}
if(name==='calendar'){ $('avMoreDialog').close();openView('openCalendar');return; }
if(name==='camera'){ $('avMoreDialog').close();openCamera();return; }
if(name==='media'){ $('avComfyDialog').showModal();$('avComfyPrompt').focus();return;}
if(name==='time'){showTime();return;}
if(name==='proxmox'||name==='wazuh'){await quickCommand(name);return;}
if(name==='comfyui'){ $('avComfyDialog').showModal();$('avComfyPrompt').focus();return;}
if(name==='more'){ $('avMoreDialog').showModal();return;}
if(name==='audio'){ $('avMoreDialog').close();$('avTopSettings').click();return; }
}
dashboard.addEventListener('click',event=>{
const button=event.target.closest('[data-av-action]');
if(button)act(button.dataset.avAction);
});
$('avComfySubmit').addEventListener('click',()=>{
const text=$('avComfyPrompt').value.trim();
if(text.length<4){toast('Décris l’image à générer.');return;}
$('avComfyDialog').close();quickCommand('comfyui',text);
});
window.addEventListener('resize',()=>{ /* legacy alfred.js recalculates the waveform canvas */ });
})();
+71
View File
@@ -0,0 +1,71 @@
/* ALFRED_GLASS_V2 — mise en page uniquement ; logique V1 et extensions conservees. */
(() => {
'use strict';
const main = document.getElementById('alfredMainV1');
if (!main || main.dataset.glassV2 === 'ready') return;
main.dataset.glassV2 = 'ready';
const logo = main.querySelector('.av-logo');
const name = document.getElementById('avName');
const subtitle = document.getElementById('avVoiceSubtitle');
if (!logo || !name) return;
let assistantName = String(name.textContent || '').trim() || 'Alfred';
const cleanName = value => {
if(typeof value !== 'string')return '';
const s = value.trim();
return s.length >= 2 && s.length <= 30 ? s : '';
};
function setName(value) {
const s = cleanName(value);
if(!s) return;
assistantName=s;
name.textContent=s;
const first=Array.from(s)[0] || 'A';
logo.textContent=first.toLocaleUpperCase('fr-FR');
logo.setAttribute('aria-label',`Initiale de ${s}`);
// La V1 conserve son nom initial dans la phrase de veille : mise a jour apres chaque evenement.
const title=document.getElementById('avVoiceTitle');
if(title && subtitle && (title.textContent === 'En veille' || title.textContent === 'En attente')) {
subtitle.textContent=`Dites « ${s} » puis votre demande`;
}
}
setName(assistantName);
async function fetchName(){
try{
const r=await fetch('/api/state',{cache:'no-store'});
if(!r.ok)return;
const d=await r.json();
setName(d.assistant_name || d.title || '');
} catch(_){ /* La page reste utilisable si /api/state est indisponible. */ }
}
fetchName();
setInterval(()=>{if(!document.hidden)fetchName();},20000);
window.addEventListener('focus',fetchName);
window.alfredSocket?.on('alfred_state',state=>{
setName(state?.assistant_name || state?.title || assistantName);
});
// Le menu ··· ouvre les options de la V1 au lieu de naviguer directement vers /config.
const oldMenu=main.querySelector('.av-head-actions > a[href="/config"]');
if(oldMenu){
const btn=document.createElement('button');
btn.type='button';btn.id='avTopMenu';btn.className=oldMenu.className;
btn.setAttribute('data-av-action','more');
btn.setAttribute('aria-label','Ouvrir le menu principal');
btn.title='Menu : Configuration, Surveillance, Journaux';
btn.innerHTML=oldMenu.innerHTML;
oldMenu.replaceWith(btn);
}
const grid=document.querySelector('#avMoreDialog .av-more-grid');
if(grid&&!grid.querySelector('a[href="/logs"]')){
const link=document.createElement('a');
link.href='/logs';link.textContent='Journaux et historiques';
link.setAttribute('aria-label','Ouvrir les journaux');
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('class','a-icon');svg.setAttribute('fill','none');
svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.8');
const d=document.createElementNS('http://www.w3.org/2000/svg','path');
d.setAttribute('d','M6 3h9l4 4v14H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM14 3v5h5M8 12h8M8 16h8');
svg.appendChild(d);link.prepend(svg);grid.append(link);
}
// Rail et historique restes en DOM : les scripts des Listes et de la V1 les utilisent.
// Ne pas les supprimer au risque de casser les evenements ou le rafraichissement.
})();
@@ -0,0 +1,31 @@
/* Alfred Glass — aperçu Maison alimenté par les préférences de /config. */
(() => {
'use strict';
let rows=null;
function render(){
if(!rows)return;
const card=document.getElementById('avHomeSummary');
if(!card)return;
const ids=['avLights','avCovers','avIndoor'];
rows.slice(0,3).forEach((r,i)=>{
const node=document.getElementById(ids[i]);
if(!node)return;
node.textContent=`${r.label} : ${r.value}`;
node.closest('p').hidden=false;
});
for(let i=rows.length;i<ids.length;i++){
const node=document.getElementById(ids[i]);
if(node)node.closest('p').hidden=true;
}
}
window.alfredHomeCardCustomUpdate=render;
async function update(){
try {
const r=await fetch('/api/alfred/home-card',{cache:'no-store'});
if(!r.ok)throw new Error('HTTP '+r.status);
const data=await r.json();rows=Array.isArray(data.rows)?data.rows:null;
render();
}catch(e){console.warn('[ALFRED MAISON] Informations personnalisées indisponibles',e);}
}
update();setInterval(()=>{if(!document.hidden)update();},20000);
})();
@@ -0,0 +1,93 @@
/* ALFRED_IDENTITY_GLASS_V1 — dynamic user-facing assistant branding.
Technical keys, service names, tool IDs and logs remain unchanged. */
(() => {
'use strict';
let active='',activeWake='';
const replace=(el,oldName,newName)=>{
if(!el || !el.firstChild || el.firstChild.nodeType!==3)return;
const value=el.firstChild.nodeValue||'';
const next=value.replace(/\bAlfred\b/g,newName);
if(next!==value)el.firstChild.nodeValue=next;
};
function apply(n,wake){
if(typeof n!=='string' || n.trim().length<2 || n.length>30)return;
active=n.trim();
if(typeof wake==='string'&&wake.trim())activeWake=wake.trim();
const letter=Array.from(active)[0]?.toLocaleUpperCase('fr-FR')||'A';
const main=document.getElementById('alfredMainV1');
if(main){
const logo=main.querySelector('.av-logo');
if(logo)logo.textContent=letter;
const title=document.getElementById('avName');
if(title)title.textContent=active;
const info=document.getElementById('avVoiceSubtitle');
if(info&&/Dites\s+[«"]/.test(info.textContent||''))info.textContent=`Dites « ${activeWake||active} » puis votre demande`;
const system=main.querySelector('.av-circle.system')?.parentElement?.querySelector('small');
if(system)system.textContent='Mini-PC '+active;
replace(document.querySelector('#avMoreDialog h2'),'',active);
const moreTitle=document.querySelector('#avMoreDialog h2');if(moreTitle)moreTitle.textContent='Outils '+active;
const comfyDesc=document.querySelector('#avComfyDialog p');if(comfyDesc)comfyDesc.textContent='Décris l’image à générer. '+active+' transmettra ta demande à Hermes.';
const submit=document.getElementById('avComfySubmit');if(submit)submit.textContent='Envoyer à '+active;
const hero=main.querySelector('.av-voice-hero');if(hero)hero.setAttribute('aria-label','Commande vocale '+active);
const oldConfig=main.querySelector('a[href="/config"][aria-label^="Configuration d"]');if(oldConfig)oldConfig.setAttribute('aria-label','Configuration de '+active);
const nav=document.querySelector('#avMoreDialog .av-more-grid');
if(nav && !nav.querySelector('a[href="/identity"]')){
const link=document.createElement('a');link.href='/identity';link.textContent='Identité de '+active;nav.append(link);
}else if(nav){const link=nav.querySelector('a[href="/identity"]');if(link)link.textContent='Identité de '+active;}
const legacyStage=document.getElementById('stage');if(legacyStage)legacyStage.setAttribute('aria-label','Réponse de '+active);
const legacyCamera=document.getElementById('cameraFeed');if(legacyCamera)legacyCamera.alt='Vidéo en direct de la caméra de '+active;
document.title=active+' · Accueil';
}
const brand=document.querySelector('.admin-header .brand');
if(brand){
const b=brand.querySelector('strong'),o=brand.querySelector('.brand-orb');
if(b)b.textContent=active;if(o)o.textContent=letter;
const loginOrb=document.querySelector('.auth-box .brand-orb');if(loginOrb)loginOrb.textContent=letter;
const authIntro=document.querySelector('.auth-box p');if(authIntro)authIntro.textContent=document.body.dataset.page==='surveillance'?('Connecte-toi pour consulter l’état de '+active+'.'):('Connecte-toi pour modifier la configuration de '+active+'.');
const help=document.querySelector('.ag-help');if(help)help.textContent=help.textContent.replace(/redémarrer Alfred/g,'redémarrer '+active);
const restartInfo=document.querySelector('.restart-panel p');if(restartInfo)restartInfo.textContent='Redémarrer '+active+' pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.';
const configLabel=document.getElementById('ALFRED_NAME');if(configLabel)configLabel.placeholder=active;
const hint=document.getElementById('pendingNotice');
if(hint){const strong=hint.querySelector('strong');if(strong)strong.textContent='Redémarre '+active;}
const restartButton=document.getElementById('restartButton');if(restartButton)restartButton.textContent='Redémarrer '+active;
document.title=active+' · '+(document.body.dataset.page==='surveillance'?'Surveillance':'Configuration');
const nav=brand.closest('.admin-header')?.querySelector('nav');
if(nav && !nav.querySelector('a[href="/identity"]')){
const a=document.createElement('a');a.href='/identity';a.textContent='Identité';
const logout=nav.querySelector('button');if(logout)nav.insertBefore(a,logout);else nav.append(a);
}
const rail=document.querySelector('.ag-rail');
if(rail && !rail.querySelector('a[href="/identity"]')){
const a=document.createElement('a');a.href='/identity';a.innerHTML='<span>✎</span>Identité';rail.append(a);
}
}
const legacyAudio=document.querySelector('body > main .subtitle');
if(legacyAudio){legacyAudio.textContent=active+' · Paramètres audio';document.title=active+' — Configuration audio';
const hints=document.querySelectorAll('body > main p.hint');if(hints[0])hints[0].textContent='Désactive les réponses vocales de '+active+'.';if(hints[1])hints[1].textContent='Suspend l’écoute et la détection du mot « '+(activeWake||active)+' ».';}
const logsTitle=document.getElementById('lgName');
if(logsTitle){
logsTitle.textContent=active;
const letterEl=document.getElementById('lgLetter');if(letterEl)letterEl.textContent=letter;
const eyebrow=document.querySelector('.lg-eyebrow');if(eyebrow)eyebrow.textContent='OBSERVABILITÉ · '+active.toLocaleUpperCase('fr-FR');
const nav=document.querySelector('.lg-nav');
if(nav&&!nav.querySelector('a[href="/identity"]')){const a=document.createElement('a');a.href='/identity';a.textContent='Identité';nav.append(a);}
document.title='Journaux · '+active;
}
}
async function refresh(){
try{const r=await fetch('/api/alfred/identity/name',{cache:'no-store'});if(!r.ok)return;const d=await r.json();apply(d.name,d.wake_word);}catch(_){}
}
const subtitle=document.getElementById('avVoiceSubtitle');
if(subtitle){
const observer=new MutationObserver(()=>{
if(active&&/Dites\s+[«"]/.test(subtitle.textContent||'')){
const desired=`Dites « ${activeWake||active} » puis votre demande`;
if(subtitle.textContent!==desired)subtitle.textContent=desired;
}
});
observer.observe(subtitle,{childList:true,characterData:true,subtree:true});
}
refresh();window.addEventListener('focus',refresh);
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();});
setInterval(()=>{if(!document.hidden)refresh();},10000);
})();
@@ -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>
+33
View File
@@ -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>
+5
View File
@@ -8,6 +8,8 @@
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2"> <link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script> <script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 --> <!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
</head> </head>
<body data-page="config"> <body data-page="config">
<div class="admin-layout"> <div class="admin-layout">
@@ -71,5 +73,8 @@
</main> </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 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> </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> </body>
</html> </html>
+2
View File
@@ -126,5 +126,7 @@
} }
})(); })();
</script> </script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
</body> </body>
</html> </html>
+13
View File
@@ -12,6 +12,12 @@
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css"> <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-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css"> <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> </head>
<body> <body>
<main class="alfred-shell"> <main class="alfred-shell">
@@ -183,5 +189,12 @@
<script src="/static/js/alfred-timers-context.js"></script> <script src="/static/js/alfred-timers-context.js"></script>
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 --> <!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script> <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> </body>
</html> </html>
+6 -1
View File
@@ -1,6 +1,8 @@
<!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"> <!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> <script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 --> <!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
</head> </head>
<body data-page="surveillance"> <body data-page="surveillance">
<div class="admin-layout"> <div class="admin-layout">
@@ -41,4 +43,7 @@
<p role="status" aria-live="polite" class="message" id="message"></p> <p role="status" aria-live="polite" class="message" id="message"></p>
</main> </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 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></body></html> </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>
+1 -1
View File
@@ -7,7 +7,7 @@ from dotenv import load_dotenv
from proxmoxer import ProxmoxAPI from proxmoxer import ProxmoxAPI
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
load_dotenv("/opt/hermes/proxmox-mcp/.env") load_dotenv("/opt/hermes/MCP_proxmox/.env")
mcp = FastMCP("proxmox-mcp") mcp = FastMCP("proxmox-mcp")