This commit is contained in:
pporcheret
2026-10-30 22:10:03 +01:00
parent 7f26f9b015
commit 90ffa635a0
161 changed files with 19042 additions and 111 deletions
@@ -0,0 +1,646 @@
import os, threading, hmac
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
# Charger la configuration AVANT les imports des services
load_dotenv("/opt/alfred/.env")
load_dotenv(
"/opt/alfred/app/config/control_center.env",
override=True
)
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
from services.piper_tts import stop_playback as duplex_stop_piper
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
from services import alfred_features # ALFRED_FEATURES_V45
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
from services import camera_settings, ha_weather, weather_outfits
load_dotenv()
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
# Service temps autonome (survit aux conversations Hermes)
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
on_interrupt=duplex_stop_piper,
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
camera_settings.restore_saved_settings()
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
if not conversation_lock.acquire(blocking=False):
return
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
from services.audio import STTResult
r = STTResult(text=injected_text)
else:
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=done.set)
done.wait(timeout=120)
emit_state("idle", partial="")
return
# ALFRED_LISTES_GLASS_V1_VOICE_START
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
try:
_lists_reply = _alfred_handle_list_voice(
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
except Exception as _lists_error:
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
if _lists_reply is not None:
_lists_display = {'type':'text', 'text':_lists_reply}
with STATE_LOCK:
STATE['content'] = _lists_display
socketio.emit('alfred_render', _lists_display)
if _lists_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_lists_done = threading.Event()
speak_async(_lists_reply, on_end=_lists_done.set)
_lists_done.wait(timeout=120)
emit_state('idle', partial='')
return
# ALFRED_LISTES_GLASS_V1_VOICE_END
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
answer = ask_hermes(r.text)
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
alfred_context_images.schedule_context_image(
r.text, speech, socketio,
lambda: STATE.get("transcript") == r.text,
) # ALFRED_TIMERS_CONTEXT_V1
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
alfred_features.publish_voice_display(display)
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
try:
started = speak_async(speech, on_end=done.set)
if started:
done.wait(timeout=120)
finally:
if not duplex_monitor.interrupted():
duplex_monitor.stop()
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
if duplex_monitor.interrupted():
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
duplex_monitor.stop()
else:
# A few wake-free seconds after the assistant finishes speaking.
pending_duplex_text = duplex_monitor.listen_conversation()
if pending_duplex_text:
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
# Only the voice is cancelled; the original Hermes job is untouched.
wake.pause()
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
daemon=True, name='dona-duplex-followup').start()
elif not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@app.get('/api/duplex/status')
def duplex_status():
return jsonify(duplex_monitor.status())
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
def _alfred_dashboard_quick_command(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _run():
try:
wake.pause()
with STATE_LOCK:
_image_revision_before = IMAGE_REVISION
emit_state('thinking', transcript=prompt, partial='',
content={'type':'text', 'text':prompt})
_answer = ask_hermes(prompt)
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
with STATE_LOCK:
if IMAGE_REVISION == _image_revision_before:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_done = threading.Event()
speak_async(_speech, on_end=_done.set)
_done.wait(timeout=120)
emit_state('idle', partial='')
except Exception as _exc:
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
emit_state('error', content={'type':'text', 'text':str(_exc)})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
return True
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
# ALFRED_ADMIN_GLASS_MAISON_V1_START
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
_alfred_register_home_glass(app)
# ALFRED_ADMIN_GLASS_MAISON_V1_END
# ALFRED_LISTES_GLASS_V1_BACKEND_START
from services.alfred_lists import register_lists as _alfred_register_lists
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
_alfred_register_lists(app, socketio)
# ALFRED_LISTES_GLASS_V1_BACKEND_END
# ALFRED_GLASS_V2_BACKEND_START
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
_alfred_register_logs_v2(app)
# ALFRED_GLASS_V2_BACKEND_END
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
_alfred_identity_register(app)
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
_alfred_developer_register(app)
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
_register_hermes_new_session(app)
# DONA_FAMILY_UI_V1_START
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
def _dona_submit_typed(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _dona_run_typed():
try:
wake.pause()
with STATE_LOCK:
_before_image = IMAGE_REVISION
emit_state('thinking', partial='', transcript=prompt,
content={'type': 'text', 'text': prompt})
_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}
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
# Deliberately no speak_async(): typed interaction is silent.
emit_state('idle', partial='')
except Exception as exc:
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, flush=True)
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
return True
from services.dona_family_ui import register_dona_family as _dona_register_family
_dona_register_family(app, adult_command=_dona_submit_typed)
# DONA_FAMILY_UI_V1_END
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,17 @@
{
"app": "/opt/alfred/app",
"existing": [
"app.py",
"services/hermes_client.py",
"services/alfred_identity_page.py",
"static/js/dona-accueil-ecrit-v1.js",
"static/js/dona-ui-fix.js",
"templates/index.html",
"templates/alfred_identity_glass.html"
],
"created": [
"services/dona_spotify.py",
"static/js/dona-spotify.js",
"static/css/dona-v6.css"
]
}
@@ -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)
@@ -0,0 +1,478 @@
"""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 = ""
# 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()
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,108 @@
/* DONA_FAMILY_UI_V1: non-destructive augmentation of the installed Glass V1 dashboard. */
(() => {
'use strict';
let attempts=0;
function init(){
const root=document.getElementById('alfredMainV1');
if(!root){if(++attempts<40)setTimeout(init,150);return;}
if(root.dataset.donaFamily==='ready')return;
const $=id=>document.getElementById(id);
const head=root.querySelector('.av-header-right');
const weather=root.querySelector('.av-weather');
const hero=root.querySelector('.av-voice-hero');
const shortcuts=root.querySelector('.av-shortcuts');
if(!head||!weather||!hero||!shortcuts)return;
root.dataset.donaFamily='ready';document.body.classList.add('dona-family-v1');
// Move the EXISTING weather widget: old JS keeps updating the SAME IDs.
weather.classList.add('dona-inline-weather');
weather.setAttribute('title','Ouvrir les détails météo');
head.prepend(weather);
// Child link is supplementary; all old admin/navigation functions remain.
const kid=document.createElement('a');kid.href='/enfant';kid.className='dona-child-link';kid.textContent='⭐ Espace enfant';
kid.setAttribute('aria-label','Ouvrir le chat enfant, écrit uniquement');
head.insertBefore(kid,head.querySelector('.av-head-actions'));
const toolsGrid=root.querySelector('#avMoreDialog .av-more-grid');
if(toolsGrid&&!toolsGrid.querySelector('a[href="/enfant"]')){
const a=document.createElement('a');a.href='/enfant';a.textContent='⭐ Espace enfant';toolsGrid.append(a);
}
// New expressive centre; old controls stay in DOM so audio event listeners work.
const display=document.createElement('div');display.className='dona-art-center';
display.innerHTML='<div class="dona-organic-wrap"><canvas id="donaOrganicCanvas" aria-label="Orbe animé de Dona" role="img"></canvas><span class="dona-orb-reflection" aria-hidden="true"></span></div><strong class="dona-art-name" id="donaArtName"></strong><span class="dona-art-subtitle" id="donaArtSubtitle">Prêt à vous aider</span>';
hero.prepend(display);
const oldVoice=root.querySelector('.av-voice-center');
if(oldVoice){display.append(oldVoice);}
// Move shortcuts into a collapsed tray, instead of removing their controls.
const tools=document.createElement('details');tools.className='dona-tools-tray';
const summary=document.createElement('summary');summary.textContent='☷ Raccourcis';
shortcuts.parentNode.insertBefore(tools,shortcuts);tools.append(summary,shortcuts);
// Collapsible multi-line composition field. No TTS on typed requests.
const compose=document.createElement('div');compose.className='dona-compose';
compose.innerHTML=`<button class="dona-compose-open" type="button" id="donaComposeToggle" aria-expanded="false" aria-controls="donaComposeBody"><span>✎</span> Écrire une demande à Dona… <span aria-hidden="true">⌃</span></button>
<form class="dona-compose-body" id="donaComposeBody" hidden>
<label for="donaComposeInput">Ta demande écrite à Hermes</label>
<div class="dona-compose-row"><textarea id="donaComposeInput" maxlength="1600" rows="2" placeholder="Ex. Résume mes cinq derniers mails, sans en envoyer." required></textarea><button id="donaComposeSend" type="submit">Envoyer ➤</button></div>
<div class="dona-compose-foot"><small>Entrée pour envoyer · Maj + Entrée pour une nouvelle ligne · réponse sans synthèse vocale</small><span id="donaComposeStatus" aria-live="polite"></span></div>
</form>`;
root.append(compose);
const toggle=$('donaComposeToggle'),body=$('donaComposeBody'),input=$('donaComposeInput'),send=$('donaComposeSend'),status=$('donaComposeStatus');
function openCompose(value){body.hidden=!value;toggle.setAttribute('aria-expanded',String(value));compose.classList.toggle('is-open',value);if(value)input.focus();}
toggle.addEventListener('click',()=>openCompose(body.hidden));
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&!body.hidden&&!root.classList.contains('is-focused'))openCompose(false);});
input.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();body.requestSubmit();}});
body.addEventListener('submit',async e=>{
e.preventDefault();let message=input.value.trim();if(!message)return;
send.disabled=true;status.textContent='Envoi à Hermes…';
try{
const r=await fetch('/api/dona/texte',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify({message})});
let d={};try{d=await r.json();}catch(_){}
if(!r.ok)throw Error(d.error||'Erreur '+r.status);
input.value='';status.textContent='Hermes prépare sa réponse…';
}catch(err){status.textContent=err.message;}
finally{send.disabled=false;}
});
const modeText={idle:'Prêt à vous aider',waiting:'Prêt à vous aider',listening:'Je vous écoute…',thinking:'Je réfléchis…',speaking:'Je vous réponds…',error:'Un problème est survenu'};
function setMode(mode){root.dataset.familyMode=mode;const t=$('donaArtSubtitle');if(t)t.textContent=modeText[mode]||'Prêt à vous aider';if(mode==='idle'&&status.textContent==='Hermes prépare sa réponse…')status.textContent='Réponse reçue';}
function setName(value){const name=String(value||$('avName')?.textContent||'Dona').trim();$('donaArtName').textContent=name;}
setName();setMode(root.dataset.mode||'idle');
const socket=window.alfredSocket;
socket?.on('alfred_state',s=>{setMode(String(s.mode||'idle').toLowerCase());setName(s.assistant_name||s.title||$('avName')?.textContent);});
socket?.on('alfred_render',()=>{if(status.textContent==='Hermes prépare sa réponse…')status.textContent='Réponse reçue';});
fetch('/api/state',{cache:'no-store'}).then(r=>r.ok?r.json():null).then(s=>{if(s){setMode(s.mode||'idle');setName(s.assistant_name||s.title);}}).catch(()=>{});
// Animated multi-layer liquid orb using canvas, independent of server state.
const canvas=$('donaOrganicCanvas'),ctx=canvas.getContext('2d');
if(!ctx)return;
let w=320,h=320,phase=0,raf=0,last=0;
const reduce=matchMedia('(prefers-reduced-motion: reduce)').matches;
function resize(){const rect=canvas.getBoundingClientRect();const dpr=Math.min(devicePixelRatio||1,2);w=Math.max(150,rect.width);h=Math.max(150,rect.height);canvas.width=Math.floor(w*dpr);canvas.height=Math.floor(h*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);}
window.addEventListener('resize',resize,{passive:true});resize();
function path(radius,layer){const n=130;ctx.beginPath();for(let k=0;k<=n;k++){
const a=k/n*Math.PI*2;const modulation=1+0.067*Math.sin(a*3+phase*.72+layer*.64)+0.044*Math.cos(a*5-phase*.39+layer)+0.021*Math.sin(a*8+phase*.55);
const r=radius*modulation;const x=Math.cos(a)*r,y=Math.sin(a)*r;
if(k===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
}ctx.closePath();}
function frame(now){raf=requestAnimationFrame(frame);if(document.hidden||now-last<30)return;last=now;
const mode=root.dataset.familyMode||'idle';const speed=mode==='listening'?1.7:mode==='thinking'?1.38:mode==='speaking'?1.5:.75;
if(!reduce)phase+=.024*speed;
ctx.clearRect(0,0,w,h);ctx.save();ctx.translate(w/2,h/2);const r=Math.min(w,h)*.33;
const bloom=ctx.createRadialGradient(-r*.2,-r*.15,r*.1,0,0,r*1.45);
bloom.addColorStop(0,'rgba(238,117,160,.11)');bloom.addColorStop(.7,'rgba(139,112,246,.14)');bloom.addColorStop(1,'rgba(37,180,255,0)');ctx.fillStyle=bloom;ctx.beginPath();ctx.arc(0,0,r*1.48,0,Math.PI*2);ctx.fill();
for(let layer=3;layer>=0;layer--){const waveR=r*(1+layer*.025);path(waveR,layer);
const g=ctx.createLinearGradient(-r*.9,r*.8,r*.9,-r*.85);
if(layer%2===0){g.addColorStop(0,'rgba(47,196,244,.76)');g.addColorStop(.36,'rgba(68,94,198,.88)');g.addColorStop(.62,'rgba(244,138,173,.74)');g.addColorStop(1,'rgba(255,176,71,.9)');}
else {g.addColorStop(0,'rgba(59,164,255,.65)');g.addColorStop(.4,'rgba(196,119,245,.48)');g.addColorStop(.7,'rgba(241,125,146,.52)');g.addColorStop(1,'rgba(255,185,95,.76)');}
ctx.globalAlpha=layer===0?.76:.25;ctx.fillStyle=g;ctx.fill();
ctx.globalAlpha=layer===0?.92:.31;ctx.strokeStyle=g;ctx.lineWidth=layer===0?2.5:1.8;ctx.shadowBlur=layer===0?23:13;ctx.shadowColor=layer%2?'#bd80ec':'#ffc685';ctx.stroke();ctx.shadowBlur=0;
}
const ribbon=ctx.createLinearGradient(-r,r*.4,r,-r*.3);ribbon.addColorStop(0,'rgba(67,219,255,.0)');ribbon.addColorStop(.48,'rgba(255,231,197,.56)');ribbon.addColorStop(1,'rgba(255,181,103,.01)');
ctx.beginPath();for(let i=0;i<=100;i++){const x=-r+i/100*r*2;const y=Math.sin(i/100*Math.PI*2+phase*.35)*r*.25+Math.cos(i/100*Math.PI*3-phase*.45)*r*.11;if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);}ctx.lineWidth=r*.26;ctx.strokeStyle=ribbon;ctx.globalAlpha=.38;ctx.stroke();ctx.restore();
}
raf=requestAnimationFrame(frame);window.addEventListener('pagehide',()=>cancelAnimationFrame(raf),{once:true});
}
init();
})();
@@ -0,0 +1,134 @@
/* Correctif visuel Dona : titre d'accueil et dégagement de la reconnaissance vocale.
Ne touche ni à Hermes, ni à Vosk, ni aux réglages du mot d'appel. */
(() => {
'use strict';
const LABEL = 'RECONNAISSANCE VOCALE';
const COMPOSER = 'Écrire une demande à';
let pending = false;
let lastStatus = null;
let lastDock = null;
let resizeObserver = null;
const offsets = new WeakMap();
function visible(el) {
if (!el || el.nodeType !== 1) return false;
const s = getComputedStyle(el);
const r = el.getBoundingClientRect();
return s.display !== 'none' && s.visibility !== 'hidden' &&
r.width > 0 && r.height > 0;
}
function textNodes() {
const root = document.body;
if (!root) return [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
const p = n.parentElement;
if (!p || /^(SCRIPT|STYLE|NOSCRIPT|TEXTAREA)$/i.test(p.tagName))
return NodeFilter.FILTER_REJECT;
return n.textContent.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
}
});
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
return nodes;
}
function correctTitle(nodes) {
for (const n of nodes) {
if (n.textContent.trim() !== 'Alfred' || !visible(n.parentElement)) continue;
const el = n.parentElement;
const size = parseFloat(getComputedStyle(el).fontSize) || 0;
const heading = el.closest('h1,h2,[role="heading"]');
// Uniquement le grand titre d'accueil ; ne pas renommer les autres mentions.
if (size >= 32 || (heading && parseFloat(getComputedStyle(heading).fontSize) >= 32)) {
n.textContent = n.textContent.replace(/Alfred/, 'Dona');
}
}
}
function findDock(nodes) {
const found = nodes.find(n => n.textContent.includes(COMPOSER) && visible(n.parentElement));
if (!found) return null;
let best = null;
let bestScore = -Infinity;
let el = found.parentElement;
for (let i = 0; i < 8 && el && el !== document.body; i++, el = el.parentElement) {
const r = el.getBoundingClientRect();
if (!visible(el) || r.height < 45 || r.height > 265 || r.width < 250 ||
r.top < window.innerHeight * 0.45) continue;
const css = getComputedStyle(el);
const fixed = /^(fixed|sticky)$/.test(css.position);
const radius = parseFloat(css.borderTopLeftRadius) || 0;
const score = (fixed ? 30 : 0) + (radius > 12 ? 14 : 0) +
(r.width > window.innerWidth * 0.55 ? 10 : 0) -
Math.abs(r.bottom - (window.innerHeight - 16)) / 12 -
Math.abs(r.height - 85) / 10;
if (score > bestScore) { bestScore = score; best = el; }
}
return best;
}
function findVoiceRegion(nodes, dock) {
const found = nodes.find(n => n.textContent.includes(LABEL) && visible(n.parentElement));
if (!found) return null;
let el = found.parentElement;
let candidate = el;
for (let i = 0; i < 9 && el && el !== document.body; i++, el = el.parentElement) {
const r = el.getBoundingClientRect();
if (dock && el.contains(dock)) break;
const text = (el.innerText || '').trim();
if (r.height > 300 || text.includes('Prêt à vous aider') || text.length > 1100) break;
// Prendre le groupe entier (statut Vosk + transcription), pas la seule étiquette.
if (r.height >= 25 && visible(el)) candidate = el;
}
return candidate;
}
function update() {
pending = false;
const nodes = textNodes();
correctTitle(nodes);
const dock = findDock(nodes);
const status = findVoiceRegion(nodes, dock);
if (resizeObserver) {
if (lastStatus && lastStatus !== status) resizeObserver.unobserve(lastStatus);
if (lastDock && lastDock !== dock) resizeObserver.unobserve(lastDock);
if (status && lastStatus !== status) resizeObserver.observe(status);
if (dock && lastDock !== dock) resizeObserver.observe(dock);
}
lastStatus = status;
lastDock = dock;
if (!status) return;
const prev = offsets.get(status) || 0;
const rect = status.getBoundingClientRect();
const originalBottom = rect.bottom + prev;
const topLimit = dock ? dock.getBoundingClientRect().top - 24 : window.innerHeight - 22;
const offset = dock ? Math.max(0, Math.ceil(originalBottom - topLimit)) : 0;
const safeOffset = Math.min(offset, Math.max(0, rect.top + prev - 70));
if (Math.abs(safeOffset - prev) >= 1) {
status.style.translate = safeOffset ? `0 -${safeOffset}px` : '';
offsets.set(status, safeOffset);
}
}
function schedule() {
if (pending) return;
pending = true;
requestAnimationFrame(update);
}
function init() {
if ('ResizeObserver' in window) resizeObserver = new ResizeObserver(schedule);
const observer = new MutationObserver(schedule);
observer.observe(document.body, { subtree: true, childList: true, characterData: true });
window.addEventListener('resize', schedule, {passive: true});
window.addEventListener('orientationchange', schedule, {passive: true});
if (window.visualViewport) window.visualViewport.addEventListener('resize', schedule, {passive:true});
schedule();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, {once:true});
else init();
})();
@@ -0,0 +1,46 @@
<!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>
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
</head>
<body>
<header class="id-head">
<a class="id-brand" href="/"><span class="id-letter" id="idInitial">A</span><span><strong id="idName">Assistant</strong><small>Identité et personnalité</small></span></a>
<nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity" class="active" aria-current="page">Identité</a></nav>
</header>
<main class="id-main">
<div class="id-title"><div><p class="id-eyebrow">PERSONNALISATION · HERMES</p><h1>Identité de l'assistant</h1><p>Personnalise la personnalité, le ton et les instructions permanentes transmises à Hermes.</p></div><span class="id-pill" id="idBadge">Vérification…</span></div>
<section class="id-panel" id="idLoginPanel" hidden>
<h2>Accès administrateur</h2><p>Utilise le même mot de passe que la page Configuration.</p>
<form id="idLoginForm"><label for="idPassword">Mot de passe</label><input type="password" id="idPassword" autocomplete="current-password" required><button type="submit" class="id-button primary">Se connecter</button></form>
<p id="idLoginMessage" role="alert"></p>
</section>
<section id="idEditor" hidden>
<div class="id-layout">
<div class="id-panel id-editor-card">
<div class="id-editor-title"><div><h2>Document d'identité</h2><p>Édition directe de <code>config/identity.md</code> · format Markdown</p></div><span id="idCount">0 caractère</span></div>
<label class="id-sr-only" for="idText">Instructions Hermes</label>
<textarea id="idText" spellcheck="false" autocomplete="off" aria-describedby="idEditorHelp"></textarea>
<p class="id-tip" id="idEditorHelp">Les changements du texte s'appliquent aux prochaines demandes vocales. Les sessions Hermes anciennes peuvent conserver leur propre historique.</p>
<div class="id-buttons"><button type="button" class="id-button" id="idReload">Recharger</button><button type="button" class="id-button primary" id="idSave">Enregistrer l'identité</button></div>
<p id="idMessage" role="status" aria-live="polite"></p>
</div>
<aside class="id-side">
<section class="id-panel"><p class="id-kicker">IDENTITÉ ACTIVE</p><div class="id-current"><span id="idAvatar">A</span><div><strong id="idActiveName">—</strong><small>Nom configuré dans /config</small></div></div><p>Modifier le nom dans <a href="/config">Configuration</a> synchronise automatiquement les mentions de l'assistant dans ce document.</p></section>
<section class="id-panel"><h2>Conseils de rédaction</h2><p>Décris les règles de conversation et les préférences durables. Pour les outils spécialisés, utilise les Skills Hermes.</p><div class="id-example">## Style<br>Tu réponds en français, avec des phrases naturelles.<br><br>## Outils<br>Tu confirmes une action seulement après le succès de son outil.</div></section>
<section class="id-panel"><h2>Sauvegardes</h2><p>Une copie est créée avant chaque modification dans <code>config/identity_history/</code>. Les fichiers existants et les noms techniques ne sont pas renommés.</p></section>
</aside>
</div>
</section>
</main>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
</body>
</html>
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
<!-- ALFRED_MAIN_VISUAL_V1 -->
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
<!-- ALFRED_LISTES_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
<!-- ALFRED_GLASS_V2 -->
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
<!-- ALFRED_REPONSE_CLOSE_V1 -->
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
<!-- DONA_FAMILY_UI_V1 -->
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
<path d="m16.5 10 5-3v10l-5-3"/>
</svg>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
<div id="alfredTimersList"></div>
</aside>
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
aria-modal="true" hidden>
<article class="alfred-context-card">
<button id="alfredContextClose" class="alfred-context-close" type="button"
aria-label="Fermer l'illustration" title="Fermer">×</button>
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
<div class="alfred-context-details">
<h2 id="alfredContextTitle"></h2>
<p id="alfredContextText"></p>
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
</div>
</article>
</div>
<script src="/static/js/alfred-timers-context.js"></script>
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
<script src="/static/js/alfred-main-v1.js?v=1"></script>
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<script src="/static/js/alfred-maison-card.js?v=1"></script>
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
<script src="/static/js/alfred-main-v2.js?v=2"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
<script defer src="/static/js/alfred-reply-auto-close.js?v=1"></script>
<script src="/static/js/dona-accueil-ecrit-v1.js?v=1"></script>
<!-- dona-ui-fix start -->
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=1"></script>
<!-- dona-ui-fix end -->
</body>
</html>