This commit is contained in:
pporcheret
2026-11-01 15:01:22 +01:00
parent 2c5537aff1
commit e2340807aa
36 changed files with 4952 additions and 8 deletions
+49 -2
View File
@@ -26,6 +26,7 @@ from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
from services.piper_tts import stop_playback as duplex_stop_piper
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import dona_clock_local as _dona_clock_local # DONA_CLOCK_SCULPTURE_V11
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
from services import alfred_features # ALFRED_FEATURES_V45
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
@@ -44,6 +45,11 @@ get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur.
from services import piper_tts as _dona_waves_piper
_dona_waves_piper.dona_set_level_callback(
lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)})
)
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
@@ -128,6 +134,24 @@ def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V
content={"type": "text", "text": r.text}
)
# DONA_CLOCK_SCULPTURE_V11 : heure/date locales AVANT Hermes.
_dona_local_clock_reply = _dona_clock_local.answer(r.text)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,_dona_local_clock_reply))
if not audio_settings.speaker_muted():
emit_state('speaking')
_dona_clock_done=threading.Event()
_dona_clock_started=speak_async(_dona_local_clock_reply,
on_end=lambda: (_dona_clock_done.set(),
socketio.emit('alfred_tts_finished', {'source':'voice'})))
if _dona_clock_started:_dona_clock_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
if _dona_vision_v95.is_camera_question(r.text):
try:
@@ -200,7 +224,7 @@ def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V
return
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
if _dona_dynamic.is_dynamic_request(r.text):
if _dona_dynamic.is_dynamic_request(r.text) or _dona_json_autorender.is_monitor_request(r.text):
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic', 'dashboard':_board}
@@ -288,6 +312,7 @@ def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
display, speech = _dona_json_autorender.convert(answer, r.text, display, speech)
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
socketio.emit('dona_learning_turn',
@@ -642,6 +667,13 @@ def _alfred_dashboard_quick_command(prompt):
_image_revision_before = IMAGE_REVISION
emit_state('thinking', transcript=prompt, partial='',
content={'type':'text', 'text':prompt})
# DONA_JSON_AUTORENDER_V1: operational shortcuts use the isolated
# dashboard session; ordinary shortcuts remain regular Hermes calls.
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
_display = {'type':'dona_dynamic','dashboard':_board}
_speech = 'Voici le tableau de bord : ' + _board['title']
else:
_answer = ask_hermes(prompt)
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
@@ -649,6 +681,7 @@ def _alfred_dashboard_quick_command(prompt):
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
with STATE_LOCK:
if IMAGE_REVISION == _image_revision_before:
@@ -722,6 +755,17 @@ def _dona_submit_typed(prompt):
_before_image = IMAGE_REVISION
emit_state('thinking', partial='', transcript=prompt,
content={'type': 'text', 'text': prompt})
# DONA_CLOCK_SCULPTURE_V11 : même traitement depuis Écrire à Dona / raccourcis.
_dona_local_clock_reply = _dona_clock_local.answer(prompt)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_dona_local_clock_reply))
emit_state('idle',partial='')
return
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
if _dona_vision_v95.is_camera_question(prompt):
try:
@@ -767,7 +811,7 @@ def _dona_submit_typed(prompt):
return
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
if _dona_dynamic.is_dynamic_request(prompt):
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic','dashboard':_board}
@@ -783,6 +827,7 @@ def _dona_submit_typed(prompt):
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
@@ -810,6 +855,8 @@ _dona_register_family(app, adult_command=_dona_submit_typed)
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
from services import dona_dynamic as _dona_dynamic
# DONA_JSON_AUTORENDER_V1 : rendu automatique JSON Hermes, demandes et raccourcis
from services import dona_json_autorender_v1 as _dona_json_autorender
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
from services import dona_dashboard_agent as _dona_dashboard_agent
+116
View File
@@ -1032,5 +1032,121 @@
},
"cached_at": 1793428787.6485865,
"focus_label": "aujourd’hui"
},
"9f4f50aa330ea2e933fdfa8071b6453d0577465b85dd35ce63b2b03c2f4db4b2": {
"file": "9f4f50aa330ea2e933fdfa8071b6453d0577465b85dd35ce63b2b03c2f4db4b2.jpg",
"prompt_ids": [
"679937f6-bf42-4cc2-9c69-3d21fbd000d3",
"d49063bf-3ec9-41ce-bd9a-f27e94084647"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
}
},
"cached_at": 1793443488.2070868,
"focus_label": "aujourd’hui"
},
"9d4b22e9c9a7b6cc7263ea7aab5278d0ef2328ae28cb5db15469120c7e86229c": {
"file": "9d4b22e9c9a7b6cc7263ea7aab5278d0ef2328ae28cb5db15469120c7e86229c.jpg",
"prompt_ids": [
"15bd0716-ea47-4709-aba2-0da675d97017",
"dc1646e7-cd5f-4892-b2a0-e69d931688c0"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
}
},
"cached_at": 1793475062.3883624,
"focus_label": "aujourd’hui"
},
"3aa04ccb2c10320ee520ef45d52b6c149e68e64c31f80b745eafde4bfeb9220c": {
"file": "3aa04ccb2c10320ee520ef45d52b6c149e68e64c31f80b745eafde4bfeb9220c.jpg",
"prompt_ids": [
"401c7ee7-48ba-4614-ab21-bcbcec060e53",
"898173ea-bba0-4c35-a025-fa45c4b72140"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
}
},
"cached_at": 1793526148.471928,
"focus_label": "aujourd’hui"
},
"f0a4965a7e0789e79bb24c865a11ba84bdac5f67ff83d3719c08b42ad0bb95e2": {
"file": "f0a4965a7e0789e79bb24c865a11ba84bdac5f67ff83d3719c08b42ad0bb95e2.jpg",
"prompt_ids": [
"e1ccd704-a7b5-4375-8d4c-815b7366bdf5",
"9d61529b-b5d0-4853-8974-29113770531f"
],
"reviews": {
"child": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
},
"adult": {
"score": 0.0,
"ok": false,
"issues": [
"review_unavailable"
],
"reviewed": false,
"reference_applied": false
}
},
"cached_at": 1793535766.1160083,
"focus_label": "aujourd’hui"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.
Binary file not shown.
+66
View File
@@ -0,0 +1,66 @@
"""Dona: heure/date du système, sans appel LLM. Fuseau Europe/Paris par défaut."""
from __future__ import annotations
from datetime import datetime
import os
import re
import unicodedata
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
DAYS = ('lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche')
MONTHS = ('janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet',
'août', 'septembre', 'octobre', 'novembre', 'décembre')
def _normalized(text: str) -> str:
text = unicodedata.normalize('NFKD', str(text or '').lower())
text = ''.join(c for c in text if not unicodedata.combining(c))
text = re.sub(r'[^a-z0-9]+', ' ', text).strip()
return re.sub(r'^(?:dona|alfred)\s+', '', text)
def intent(text: str) -> str | None:
"""Renvoie 'time', 'date', 'both' ou None; pas de faux positif trajet/durée."""
s = _normalized(text)
if not s or len(s) > 140:
return None
# Ne pas répondre avec l'heure de Paris à une question sur une autre ville.
if re.search(r'\b(?:heure|est il|est elle)\s+(?:a|en|au|aux|dans)\s+[a-z]', s):
return None
# Ne pas renvoyer la date actuelle à une question concernant une date future/passée.
if re.search(r'\bdate\s+(?:du|de|pour|a)\s+(?:\d|demain|hier)', s):
return None
t = (
bool(re.search(r'\b(?:quelle?|quel)\s+heure\s+(?:est\s+il|il\s+est|est\s+elle)\b', s))
or bool(re.search(r'\bil\s+est\s+(?:quelle?|quel)\s+heure\b', s))
or bool(re.search(r'\b(?:donne|dis|dites|affiche|montre)\s+(?:moi\s+)?(?:l\s+)?heure\b', s))
or bool(re.search(r'\b(?:heure\s+actuelle|heure\s+maintenant|il\s+est\s+quelle\s+heure)\b', s))
)
d = (
bool(re.search(r'\b(?:quelle?|quel)\s+(?:est\s+)?(?:la\s+)?date\s+(?:d\s+aujourd\s+hui|aujourd\s+hui|du\s+jour)?\b', s))
or bool(re.search(r'\b(?:on\s+est|nous\s+sommes|sommes\s+nous)\s+(?:quel|quelle|le\s+combien)\s+(?:jour|date)?\b', s))
or bool(re.search(r'\b(?:quel\s+jour\s+(?:est\s+on|sommes\s+nous|on\s+est)|quelle\s+date\s+sommes\s+nous)\b', s))
or bool(re.search(r'\b(?:donne|dis|affiche|montre)\s+(?:moi\s+)?(?:la\s+)?date\s+(?:du\s+jour)?\b', s))
)
if t and d:
return 'both'
if t:
return 'time'
if d:
return 'date'
return None
def answer(text: str, *, now: datetime | None = None) -> str | None:
which = intent(text)
if which is None:
return None
if now is None:
try:
tz = ZoneInfo(os.getenv('DONA_TIMEZONE') or os.getenv('ALFRED_TIMEZONE') or 'Europe/Paris')
except (ZoneInfoNotFoundError, ValueError, KeyError):
tz = ZoneInfo('Europe/Paris')
now = datetime.now(tz)
hour = f'Il est {now.hour} heures {now.minute:02d}.'
day = f"Aujourd'hui, nous sommes le {DAYS[now.weekday()]} {now.day} {MONTHS[now.month-1]} {now.year}."
return (hour + ' ' + day) if which == 'both' else hour if which == 'time' else day
@@ -0,0 +1,81 @@
"""Dona: reuse the existing, data-only dashboard renderer for Hermes replies.
This module never executes model-supplied HTML, JavaScript or instructions. Only
`dona_dynamic.normalize_dashboard` can build cards; invalid content remains text.
"""
from __future__ import annotations
import re
from typing import Any
from services import dona_dynamic
MAX_JSON = dona_dynamic.MAX_RESPONSE
def is_monitor_request(prompt: str) -> bool:
"""Route clearly operational Proxmox/Wazuh checks to existing dashboard API."""
q = str(prompt or '').casefold()
if not re.search(r'\b(proxmox|wazuh|cluster|clusters)\b', q):
return False
return bool(re.search(
r'\b(v[eé]rifi(?:e|es|er|ez)|contr[oô]le(?:r|s|z)?|surveille(?:r|s|z)?|'
r'montre|affiche|tableau|[eé]tat|statut|status|ressources?|'
r'performances?|noeuds?|nœuds?|serveurs?|alertes?|supervision|'
r'fonctionn(?:e|ement)|disponibilit[eé])\b', q))
def _candidate_text(answer: Any, display: Any) -> str | None:
if isinstance(display, dict):
if isinstance(display.get('cards'), list):
# Raw dashboard object should not be converted via repr().
import json
return json.dumps(display, ensure_ascii=False)
if display.get('type') in (None, 'text'):
raw = display.get('text')
if isinstance(raw, str) and '"cards"' in raw:
return raw
if isinstance(answer, str) and '"cards"' in answer:
return answer
if isinstance(answer, dict):
if isinstance(answer.get('cards'), list):
import json
return json.dumps(answer, ensure_ascii=False)
for key in ('text', 'speech'):
raw = answer.get(key)
if isinstance(raw, str) and '"cards"' in raw:
return raw
return None
def _clean_line_continuations(text: str) -> str:
"""Repair two common harmless Hermes JSON formatting artefacts only.
Trailing backslash + real newline (copied JSON), and invalid ``\\~``
escaping in approximate values. Other malformed content is left untouched.
"""
text = re.sub(r'\\[ \t]*\r?\n', '\n', text)
return text.replace('\\~', '~')
def as_board(answer: Any, display: Any, prompt: str) -> dict | None:
"""Return a server-validated dashboard, or None for an ordinary answer."""
raw = _candidate_text(answer, display)
if not raw or len(raw) > MAX_JSON:
return None
try:
extracted = dona_dynamic.extract_dashboard(_clean_line_continuations(raw))
return dona_dynamic.normalize_dashboard(extracted, prompt=prompt, origin='hermes')
except (ValueError, TypeError, KeyError):
return None
def convert(answer: Any, prompt: str, display: Any, speech: str):
"""Convert ONLY actual card JSON. Preserve ordinary answers and images."""
if isinstance(display, dict) and display.get('type') not in (None, 'text'):
return display, speech
board = as_board(answer, display, prompt)
if board is None:
return display, speech
return {'type': 'dona_dynamic', 'dashboard': board}, 'Voici le tableau de bord : ' + board['title']
+55
View File
@@ -91,6 +91,58 @@ def stop_playback():
pass
# DONA_WAVES_V1 — volume réel du WAV Wyoming Piper (50 ms, hors micro).
from array import array as _dona_array
import sys as _dona_sys
_dona_level_callback = None
def dona_set_level_callback(callback):
global _dona_level_callback
_dona_level_callback = callback
def _dona_emit_level(value):
if _dona_level_callback is not None:
try:
_dona_level_callback(max(0.0, min(1.0, float(value))))
except Exception:
pass # Une erreur côté interface ne doit jamais interrompre Piper.
def _dona_waves_stream_envelope(wav_path, proc):
"""Échantillonne le PCM pendant aplay, sans enregistrer ni exposer le son."""
try:
with wave.open(wav_path, 'rb') as stream:
if stream.getsampwidth() != 2:
return # Autre format : état « speaking » en secours côté navigateur.
rate=stream.getframerate()
if rate <= 0:
return
frames=max(1,int(rate * .05))
started=time.monotonic()
index=0
while proc.poll() is None:
raw=stream.readframes(frames)
if not raw:
break
samples=_dona_array('h')
samples.frombytes(raw)
if _dona_sys.byteorder=='big':
samples.byteswap()
stride=max(1,len(samples)//420)
window=samples[::stride]
rms=(sum(s*s for s in window)/max(1,len(window)))**.5
desired=started+index*frames/rate
remaining=desired-time.monotonic()
if remaining>0:
time.sleep(min(remaining,.07))
if proc.poll() is not None:
break
_dona_emit_level(min(1.0,rms/6500))
index+=1
except Exception as exc:
print('[DONA WAVES] analyse PCM impossible :',type(exc).__name__,flush=True)
finally:
_dona_emit_level(0)
def speak(text):
if not text or not text.strip():
return
@@ -123,6 +175,9 @@ def speak(text):
_playback_proc = proc
if _playback_cancel.is_set() and proc.poll() is None:
proc.terminate()
# DONA_WAVES_V1 : animation par l'amplitude du vrai son joué.
threading.Thread(target=_dona_waves_stream_envelope,
args=(wav_path,proc),daemon=True).start()
try:
result = proc.wait(timeout=120)
except subprocess.TimeoutExpired:
@@ -0,0 +1,119 @@
/* DONA_REPLY_FLOATING_V1 — Les réponses textuelles ne recouvrent plus les animations. */
/* La carte occupe une bande latérale, sans voile de fond ni blocage de la scène. */
/* La classe de l’écran principal est synchronisée en JS. */
body.dona-family-v1 .alfred-main-v1.dona-reply-background-visible.is-focused .av-voice-hero,
body.dona-family-v1 .alfred-main-v1.dona-reply-background-visible.is-focused .dona-art-center {
opacity:1!important; visibility:visible!important; pointer-events:none!important;
}
body.dona-family-v1 .alfred-main-v1.dona-reply-background-visible.is-focused .dona-art-center > * {
visibility:visible!important;
}
body.dona-family-v1.dona-holo-v1.alfred-glass-theme #stage.dona-reply-compact:not(.dona-reply-expanded) {
position:fixed!important;
left:auto!important;
top:clamp(140px,24vh,285px)!important;
right:clamp(16px,2.5vw,44px)!important;
bottom:auto!important;
transform:none!important;
width:min(410px,calc(100vw - 32px))!important;
max-width:calc(100vw - 32px)!important;
max-height:min(65dvh,580px)!important;
min-height:0!important;
padding:48px 15px 15px!important;
overflow:auto!important;
display:block!important;
border:1px solid rgba(149,177,232,.36)!important;
border-radius:22px!important;
background:linear-gradient(145deg,rgba(12,20,43,.88),rgba(19,20,39,.84))!important;
box-shadow:0 18px 50px rgba(0,0,0,.38),inset 0 1px rgba(255,255,255,.05)!important;
-webkit-backdrop-filter:blur(10px)!important;
backdrop-filter:blur(10px)!important;
z-index:28!important;
align-items:stretch!important;
justify-content:flex-start!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) #content {
width:100%!important;
max-width:100%!important;
min-width:0!important;
max-height:none!important;
overflow:visible!important;
margin:0!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .av-reponse-actions {
top:10px!important;right:10px!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .av-reponse-close {
width:33px!important;min-width:33px!important;min-height:33px!important;
padding:6px!important;border-radius:11px!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-response {
display:flex!important;flex-direction:column!important;gap:0!important;
margin:0!important;max-width:100%!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-primary {
padding:4px 2px 2px!important;
min-height:0!important;
border:0!important;border-radius:0!important;
box-shadow:none!important;background:transparent!important;
gap:10px!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-primary::before,
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-response::after {
display:none!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-kicker {
font-size:10px!important;letter-spacing:.09em!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-headline {
font-size:clamp(17px,1.45vw,21px)!important;line-height:1.25!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-excerpt {
font-size:13px!important;line-height:1.52!important;
display:-webkit-box!important;-webkit-box-orient:vertical!important;-webkit-line-clamp:4!important;
line-clamp:4!important;overflow:hidden!important;
white-space:normal!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-facts {
display:none!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-more {
min-height:38px!important;padding:9px 12px!important;font-size:12px!important;
margin-top:5px!important;
}
/* Mode développé : le contenu et les cartes d'origine réapparaissent.
L'utilisateur choisit volontairement d'occuper l'écran. */
body.dona-family-v1.dona-holo-v1.alfred-glass-theme #stage.dona-reply-compact.dona-reply-expanded {
position:fixed!important;
left:clamp(8px,2vw,30px)!important;right:clamp(8px,2vw,30px)!important;
top:clamp(85px,10vh,125px)!important;bottom:clamp(95px,9vh,120px)!important;
width:auto!important;max-width:none!important;max-height:none!important;
padding:clamp(62px,8vh,92px) 20px 24px!important;
transform:none!important;overflow:auto!important;
background:linear-gradient(145deg,rgba(3,8,20,.97),rgba(10,16,35,.97))!important;
border:1px solid rgba(146,172,231,.38)!important;z-index:30!important;
}
/* L'état visuel compact ne s'applique pas aux cartes riches (météo, vidéo, tableaux...). */
@media (max-width:1050px) {
body.dona-family-v1.dona-holo-v1.alfred-glass-theme #stage.dona-reply-compact:not(.dona-reply-expanded) {
left:clamp(12px,3vw,28px)!important;
right:clamp(12px,3vw,28px)!important;
top:auto!important;
bottom:clamp(110px,12vh,160px)!important;
width:auto!important;
max-width:none!important;
max-height:min(31dvh,275px)!important;
padding:43px 14px 12px!important;
}
}
@media (max-width:600px) {
body.dona-family-v1.dona-holo-v1.alfred-glass-theme #stage.dona-reply-compact:not(.dona-reply-expanded) {
left:9px!important;right:9px!important;
bottom:clamp(95px,13vh,125px)!important;
border-radius:15px!important;max-height:29dvh!important;
}
body.dona-family-v1.dona-holo-v1 #stage.dona-reply-compact:not(.dona-reply-expanded) .dh-excerpt {
-webkit-line-clamp:2!important;line-clamp:2!important;
}
}
+34
View File
@@ -0,0 +1,34 @@
/* Dona — vagues vocales, sélection réversible sans désinstaller. */
body.dona-family-v1.dona-waves-selected .dona-organic-wrap {
width:min(93vw,1400px)!important;
height:clamp(155px,28vh,330px)!important;
min-width:0!important;min-height:155px!important;
max-width:100%!important;filter:none!important;
overflow:visible!important;
}
body.dona-family-v1.dona-waves-selected #donaOrganicCanvas,
body.dona-family-v1.dona-waves-selected .dona-orb-reflection {display:none!important}
body.dona-family-v1 .dona-organic-wrap #donaWavesCanvas {
display:none;position:absolute;inset:0;width:100%;height:100%;pointer-events:none;
}
body.dona-family-v1.dona-waves-selected .dona-organic-wrap #donaWavesCanvas {display:block}
body.dona-family-v1.dona-waves-selected .dona-art-name {margin-top:-5px!important}
.dona-waves-menu-title{color:#c7b8cc;font-size:11px;letter-spacing:.07em;padding:10px 10px 5px;text-transform:uppercase}
.dv92-menu-entry.dona-waves-option[aria-checked="true"] {background:rgba(94,148,208,.19);color:#ffe1b8}
.dona-waves-option .dona-waves-choice-icon{width:22px;min-width:22px;text-align:center;font-size:18px}
@media(max-width:900px){
body.dona-family-v1.dona-waves-selected .dona-organic-wrap{height:clamp(145px,23vh,240px)!important;min-height:145px!important;width:min(96vw,740px)!important}
body.dona-family-v1.dona-waves-selected .dona-art-name{font-size:clamp(32px,8vw,50px)}
}
/* DONA_SCULPTURE_V11 — Visuel organique plus concentré, mêmes events Piper. */
body.dona-family-v1.dona-sculpture-selected .dona-organic-wrap {
width:min(94vw,990px)!important;
height:clamp(210px,39vh,440px)!important;
min-height:210px!important;
}
@media(max-width:900px){
body.dona-family-v1.dona-sculpture-selected .dona-organic-wrap{
width:min(97vw,820px)!important;height:clamp(180px,28vh,295px)!important;min-height:180px!important;
}
}
@@ -83,6 +83,8 @@
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;
// DONA_WAVES_V1 : ne pas dessiner une orbe cachée; bascule possible à chaud.
if(window.DonaWaves && window.DonaWaves.isActive())return;
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;
@@ -0,0 +1,49 @@
/* DONA_REPLY_FLOATING_V1: carte texte latérale sans recouvrir le moteur d'animations. */
(() => {
'use strict';
if (window.__donaReplyFloatingV1) return;
window.__donaReplyFloatingV1 = true;
function setup() {
const main = document.getElementById('alfredMainV1');
const stage = document.getElementById('stage');
const content = document.getElementById('content');
if (!main || !stage || !content) return;
let queued = false;
function isRich() {
// Les vues calendrier/météo, cartes, vidéo, images et modules spécialisés
// conservent la grande scène et tous leurs contrôles.
if (content.classList.contains('alfred-view')) return true;
if (content.querySelector('.alfred-view,.view-pane,.cards,.hero,iframe,video,canvas,img,table,.dona-dynamic-grid,.dona-dynamic-dashboard,[data-dona-rich]')) return true;
if (content.querySelector('.dh-response')) return false;
const nodes = [...content.children];
return !nodes.length || nodes.some(n => !['H1','H2','H3','P','PRE','BLOCKQUOTE'].includes(n.tagName));
}
function update() {
queued = false;
const focused = main.classList.contains('is-focused');
const compact = focused && !isRich();
const expanded = compact && content.querySelector('.dh-more[aria-expanded="true"]') !== null;
stage.classList.toggle('dona-reply-compact', compact);
stage.classList.toggle('dona-reply-expanded', expanded);
main.classList.toggle('dona-reply-background-visible', compact && !expanded);
stage.setAttribute('data-dona-reply-mode', compact ? (expanded ? 'expanded':'compact') : 'full');
}
function queue() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
new MutationObserver(queue).observe(content, {childList:true,subtree:true,attributes:true,attributeFilter:['aria-expanded','class']});
new MutationObserver(queue).observe(main, {attributes:true,attributeFilter:['class']});
// Le renderer transforme parfois la réponse brute en carte au frame suivant.
window.alfredSocket?.on('alfred_render', queue);
stage.addEventListener('click', e => {
if (e.target.closest('.dh-more')) setTimeout(queue, 0);
});
queue();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup, {once:true});
else setup();
})();
+258
View File
@@ -0,0 +1,258 @@
/* DONA_SCULPTURE_V11 */
/* Dona Vagues Vocales V1 — Canvas 2D, amplitude PCM Piper envoyée par Socket.IO.
Le mode orb conserve l'ancien canvas et son animation.
Aucune capture micro et aucun audio client ne sont nécessaires. */
(() => {
'use strict';
if (window.DonaWaves) return;
const KEY='dona_visual_voice_v1';
let choice='waves';
try {if (['waves','orb','sculpture'].includes(localStorage.getItem(KEY)))choice=localStorage.getItem(KEY);}catch (_) {}
const API={isActive:()=>choice!=='orb',select:selectMode,getChoice:()=>choice};
window.DonaWaves=API;
document.body.classList.toggle('dona-waves-selected', choice!=='orb');
document.body.classList.toggle('dona-sculpture-selected',choice==='sculpture');
function selectMode(next){
if(!['waves','orb','sculpture'].includes(next))return;
choice=next;
document.body.classList.toggle('dona-waves-selected',choice!=='orb');
document.body.classList.toggle('dona-sculpture-selected',choice==='sculpture');
try{localStorage.setItem(KEY,choice);}catch(_){}
syncMenu();
}
function syncMenu(){
const menu=document.getElementById('dv92ToolsMenu');if(!menu)return;
for(const el of menu.querySelectorAll('[data-dona-visual]')){
const active=el.dataset.donaVisual===choice;
el.setAttribute('aria-checked',String(active));
el.querySelector('.dona-waves-choice-icon').textContent=active?'◉':'○';
}
}
function addMenuControls(){
const menu=document.getElementById('dv92ToolsMenu');
if(!menu||menu.querySelector('#donaWavesMenuTitle'))return;
const title=document.createElement('div');title.id='donaWavesMenuTitle';
title.className='dona-waves-menu-title';title.textContent='Animation centrale';
menu.append(title);
for(const [mode,label] of [['waves','Vagues horizontales (Piper)'],['sculpture','Sculpture de vagues (Piper)'],['orb','Orbe d’origine']]){
const b=document.createElement('button');b.type='button';
b.className='dv92-menu-entry dona-waves-option';b.dataset.donaVisual=mode;
b.setAttribute('role','menuitemradio');
const mark=document.createElement('span');mark.className='dona-waves-choice-icon';
b.append(mark,document.createTextNode(label));
b.addEventListener('click',()=>{
selectMode(mode);
menu.hidden=true;
document.getElementById('dv92Hamburger')?.setAttribute('aria-expanded','false');
});
menu.append(b);
}
syncMenu();
}
let started=false;
function init(){
if(started)return true;
const root=document.getElementById('alfredMainV1');
const wrap=root?.querySelector('.dona-organic-wrap');
if(!wrap)return false;
started=true;
const canvas=document.createElement('canvas');canvas.id='donaWavesCanvas';
canvas.setAttribute('role','img');canvas.setAttribute('aria-label','Vagues animées de Dona réagissant à la voix Piper');
wrap.append(canvas);
const ctx=canvas.getContext('2d',{alpha:true});if(!ctx)return true;
const reduce=window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches||false;
let w=0,h=0,dpr=1,mode=String(root.dataset.familyMode||'idle');
let phase=0,smoothed=.02,pcm=0,lastPCM=0,voiceFinished=false,then=0,raf=0;
const socket=window.alfredSocket;
socket?.on('alfred_state',data=>{
const next=String(data?.mode||'idle').toLowerCase();
if(next==='speaking'&&mode!=='speaking')voiceFinished=false;
mode=next;
});
socket?.on('dona_piper_level',data=>{
const value=Number(data?.level);
if(!Number.isFinite(value))return;
pcm=Math.max(0,Math.min(1,value));
lastPCM=performance.now();
});
socket?.on('alfred_tts_finished',()=>{pcm=0;voiceFinished=true;});
const ro=typeof ResizeObserver!=='undefined'?new ResizeObserver(resize):null;
ro?.observe(wrap);window.addEventListener('resize',resize,{passive:true});
function resize(){
const box=wrap.getBoundingClientRect();
if(!box.width||!box.height)return;
const ratio=Math.min(window.devicePixelRatio||1,1.7);
const nw=Math.round(box.width*ratio),nh=Math.round(box.height*ratio);
if(nw===canvas.width&&nh===canvas.height)return;
w=box.width;h=box.height;dpr=ratio;
canvas.width=nw;canvas.height=nh;ctx.setTransform(ratio,0,0,ratio,0,0);
}
// DONA_SCULPTURE_V11 : membranes de lumière, 2D pur, animées par PCM Piper.
function drawSculpture(now){
const cx=w*.5, cy=h*.51;
const R=Math.min(w*.23,h*.30);
const strength=Math.min(1,smoothed);
const phi=phase*.74;
ctx.save();
// Lueur ambiante derrière les rubans, sans rectangle de fond.
let halo=ctx.createRadialGradient(cx,cy,R*.12,cx,cy,R*2.25);
halo.addColorStop(0,'rgba(255,188,186,.13)');
halo.addColorStop(.39,'rgba(120,82,249,.12)');
halo.addColorStop(1,'rgba(12,43,138,0)');
ctx.fillStyle=halo;ctx.fillRect(0,0,w,h);
const shell=(layer,behind)=>{
const n=104, z=layer/8;
const width=Math.min(w*.40,h*1.16)*(1-z*.34);
const rise=h*(.225+.07*Math.sin(layer*.87+phi*.25))*(1-z*.24);
const yShift=h*(.015*Math.sin(layer*1.8+phi*.18));
const steps=[];
for(let i=0;i<=n;i++){
const t=i/n, x=cx+(t-.5)*width*2;
const env=Math.pow(Math.max(0,Math.sin(Math.PI*t)),.65);
const y=cy+yShift+Math.sin(t*Math.PI*(2.7+layer*.16)+phi*.5+layer*.63)*rise*env
+Math.cos(t*Math.PI*4.8-phi*.38+layer)*rise*.29*env;
steps.push({x,y,t,env});
}
const thickness=h*(.21+.045*Math.sin(layer*1.24+phi*.1));
const g=ctx.createLinearGradient(cx-width,cy,cx+width,cy);
g.addColorStop(0,'rgba(26,119,250,.60)');
g.addColorStop(.33,'rgba(104,98,253,.58)');
g.addColorStop(.59,'rgba(249,167,209,.70)');
g.addColorStop(1,'rgba(252,192,123,.62)');
ctx.beginPath();ctx.moveTo(steps[0].x,steps[0].y);
for(let i=1;i<steps.length;i++)ctx.lineTo(steps[i].x,steps[i].y);
for(let i=steps.length-1;i>=0;i--){
const p=steps[i];
ctx.lineTo(p.x,p.y+thickness*p.env*(.8+strength*.44));
}
ctx.closePath();ctx.globalAlpha=(behind?.19:.30)+strength*.09;
ctx.fillStyle=g;ctx.fill();ctx.globalAlpha=.55+strength*.25;
ctx.beginPath();ctx.moveTo(steps[0].x,steps[0].y);
for(let i=1;i<steps.length;i++)ctx.lineTo(steps[i].x,steps[i].y);
ctx.lineWidth=1.1+(layer%3)*.35+strength;
ctx.strokeStyle=g;ctx.shadowBlur=behind?11:21;
ctx.shadowColor=layer%2?'#f2a1bc':'#5faaff';ctx.stroke();ctx.shadowBlur=0;
// Fines nervures / maillage, effet étoffe lumineuse.
if(layer%2===0){
ctx.globalAlpha=.13+strength*.08;
for(let i=6;i<n;i+=7){
const p=steps[i],x=p.x,y=p.y;
ctx.beginPath();ctx.moveTo(x,y);
ctx.lineTo(x+Math.sin(layer+phi)*5,y+thickness*p.env);
ctx.lineWidth=.7;ctx.strokeStyle=layer%4?'#b9adff':'#ffcaad';ctx.stroke();
}
}
ctx.globalAlpha=1;
};
for(let layer=7;layer>=4;layer--)shell(layer,true);
// Noyau irisé : diaphragme vivant, se dilate sur les syllabes.
const pulse=1+strength*.20+.035*Math.sin(phi*1.05);
const r=R*pulse;
const core=ctx.createRadialGradient(cx-r*.19,cy-r*.26,r*.1,cx,cy,r*1.15);
core.addColorStop(0,'rgba(255,214,214,.90)');
core.addColorStop(.26,'rgba(244,147,194,.78)');
core.addColorStop(.53,'rgba(117,113,244,.82)');
core.addColorStop(.82,'rgba(41,125,253,.72)');
core.addColorStop(1,'rgba(37,135,245,.025)');
ctx.fillStyle=core;ctx.beginPath();
for(let i=0;i<=180;i++){
const a=i/180*Math.PI*2;
const rad=r*(1+.12*Math.sin(4*a+phi*.5)+.085*Math.cos(3*a-phi*.3));
const x=cx+Math.cos(a)*rad,y=cy+Math.sin(a)*rad*.82;
if(!i)ctx.moveTo(x,y);else ctx.lineTo(x,y);
}
ctx.closePath();ctx.shadowColor='#b696ff';ctx.shadowBlur=32+strength*25;ctx.fill();ctx.shadowBlur=0;
for(let layer=3;layer>=0;layer--)shell(layer,false);
// Poussière irisée légère dans le volume.
for(let i=0;i<70;i++){
const t=(i*.61803398875)%1;
const x=cx+(t-.5)*w*.70;
const env=Math.sin(Math.PI*t);
const y=cy+Math.cos(i*1.71+phi*.15)*h*.27*env;
ctx.globalAlpha=(.08+strength*.21)*env;
ctx.fillStyle=i%3?'#accaff':'#ffccbd';
ctx.beginPath();ctx.arc(x,y,.65+(i%4)*.22,0,Math.PI*2);ctx.fill();
}
ctx.restore();
}
function draw(now){
raf=requestAnimationFrame(draw);
if(choice==='orb'||document.hidden||now-then<30)return;
then=now;
resize();if(!w||!h)return;
const speaking=mode==='speaking'&&!voiceFinished;
const current=(now-lastPCM<240&&speaking)?pcm:0;
const base=mode==='listening'?.16:mode==='thinking'?.115:speaking?.07:.025;
const target=Math.max(base,current*.88);
smoothed+=(target-smoothed)*(target>smoothed?.35:.19);
if(!reduce)phase+=mode==='listening'?.054:mode==='thinking'?.034:speaking?.047:.018;
ctx.clearRect(0,0,w,h);
if(choice==='sculpture'){drawSculpture(now);return;}
const mid=h*.51, amplitude=h*(.245+smoothed*.35);
const palette=[
['#1362ff','#11c8fc','#6b56e9'],
['#1e92ef','#9274ff','#e47dbd'],
['#6c4bd9','#ef86b5','#ffd29b'],
['#4a5ad8','#ffb3c2','#ffc282'],
['#16b8ed','#8c66f5','#f596b5'],
['#7854e5','#d891ed','#fdad8b']
];
// Contours en membranes translucides, superposés pour former une onde lumineuse.
for(let layer=0;layer<palette.length;layer++){
const [left,center,right]=palette[layer];
const m=layer*1.07;
const points=[];
for(let x=0;x<=w+5;x+=5){
const nx=x/w;
const envelope=Math.pow(Math.max(0,Math.sin(Math.PI*nx)),.75);
const s1=Math.sin(nx*Math.PI*(2.55+m*.24)+phase*(.70+m*.04)+m*.79);
const s2=Math.sin(nx*Math.PI*(5.0-m*.17)-phase*(.42+m*.02)+m*1.18);
const y=mid+(s1*.77+s2*.23)*amplitude*envelope
+Math.cos(nx*Math.PI*2.6+phase*.28+m)*h*.014;
points.push([x,y]);
}
const gradient=ctx.createLinearGradient(0,0,w,0);
gradient.addColorStop(0,left);gradient.addColorStop(.5,center);gradient.addColorStop(1,right);
ctx.beginPath();ctx.moveTo(0,mid);
for(const [x,y] of points)ctx.lineTo(x,y);
const thickness=h*(.11+.018*(layer%3)+.09*smoothed);
for(let i=points.length-1;i>=0;i--){const [x,y]=points[i];ctx.lineTo(x,y+thickness);}
ctx.closePath();ctx.fillStyle=gradient;ctx.globalAlpha=.095+(layer%3)*.058+smoothed*.1;ctx.fill();
ctx.beginPath();points.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y));
ctx.lineWidth=(layer%2?1.5:1.9)+smoothed*1.35;
ctx.strokeStyle=gradient;ctx.globalAlpha=.60+smoothed*.30;
ctx.shadowBlur=15+smoothed*25;ctx.shadowColor=center;ctx.stroke();
ctx.shadowBlur=0;ctx.globalAlpha=1;
}
// Lueur centrale très diffuse, avec énergie liée à l'amplitude Piper.
const glow=ctx.createRadialGradient(w*.5,mid,0,w*.5,mid,w*.34);
glow.addColorStop(0,`rgba(255,194,205,${.06+smoothed*.18})`);
glow.addColorStop(.45,`rgba(107,99,255,${.025+smoothed*.045})`);
glow.addColorStop(1,'rgba(56,138,244,0)');
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
// Petits points lumineux déterministes, très discrets au repos.
for(let i=0;i<22;i++){
const nx=(i*0.61803398875)%1,ny=.5+.33*Math.sin(i*2.18+phase*.14);
const x=nx*w,y=ny*h;
ctx.globalAlpha=(.11+smoothed*.37)*Math.pow(Math.sin(nx*Math.PI),2);
ctx.fillStyle=i%3===0?'#ffb5cc':'#83ceff';ctx.beginPath();ctx.arc(x,y,.8+(i%3)*.35,0,Math.PI*2);ctx.fill();
}
ctx.globalAlpha=1;
}
resize();raf=requestAnimationFrame(draw);
window.addEventListener('pagehide',()=>{cancelAnimationFrame(raf);ro?.disconnect();},{once:true});
return true;
}
let attempts=0;
function ready(){
if(!init()&&attempts++<50)setTimeout(ready,120);
addMenuControls();
const menu=document.getElementById('dv92ToolsMenu');
if(menu&&!menu.dataset.donaWavesObserved){
menu.dataset.donaWavesObserved='1';
new MutationObserver(()=>addMenuControls()).observe(menu,{childList:true});
}
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',ready,{once:true});else ready();
})();
+8
View File
@@ -51,6 +51,10 @@
<link rel="stylesheet" href="/static/css/dona-learning-v9-3.css?v=93">
<!-- DONA_VISION_V95 -->
<link rel="stylesheet" href="/static/css/dona-vision-v9-5.css?v=95">
<!-- DONA_WAVES_V1 -->
<link rel="stylesheet" href="/static/css/dona-waves-v1.css?v=1">
<!-- DONA_REPLY_FLOATING_V1 -->
<link rel="stylesheet" href="/static/css/dona-reply-floating-v1.css?v=1">
</head>
<body>
<main class="alfred-shell">
@@ -254,5 +258,9 @@
<script defer src="/static/js/dona-ui-v9-2-1-close.js?v=921"></script>
<script defer src="/static/js/dona-learning-v9-3.js?v=93"></script>
<script defer src="/static/js/dona-vision-v9-5.js?v=95"></script>
<!-- DONA_WAVES_V1 -->
<script defer src="/static/js/dona-waves-v1.js?v=1"></script>
<!-- DONA_REPLY_FLOATING_V1 -->
<script defer src="/static/js/dona-reply-floating-v1.js?v=1"></script>
</body>
</html>
@@ -0,0 +1,20 @@
{
"kind": "dona-avatar-v1",
"app": "/opt/alfred/app",
"original_files": [
"templates/index.html",
"static/js/dona-accueil-ecrit-v1.js"
],
"created_files": [
"static/css/dona-avatar-v1.css",
"static/js/dona-avatar-v1.js",
"static/img/dona-avatar-portrait.png"
],
"installed_sha256": {
"templates/index.html": "eb039ad650db5f3a735baf702749a555c539c577020c77d9d3de89168dc3ade2",
"static/js/dona-accueil-ecrit-v1.js": "5f85b649d20270a1be55ca4dbbab30918e1d0ea792052d880613a5a76db7f834",
"static/css/dona-avatar-v1.css": "e79daa023147bcca69a3bba2f06927b79e3d1426fc24c57a9daff7314dc95d9c",
"static/js/dona-avatar-v1.js": "2399c2679693abddbc8a483bb5e6b14bc5119e0ef38222ea7d684b8fcbc31d57",
"static/img/dona-avatar-portrait.png": "ffaba09cd4528f48d93ab1aadf5e662d44872563ccf657961d08f681afee80b8"
}
}
@@ -0,0 +1,104 @@
/* 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);
// Minimal permanent one-row text field: no expandable tray, send at right.
const compose=document.createElement('div');compose.className='dona-compose dona-compose-compact';
compose.innerHTML=`<form class="dona-compose-body" id="donaComposeBody">
<label class="dona-sr-only" for="donaComposeInput">Écrire à Dona</label>
<div class="dona-compose-row"><textarea id="donaComposeInput" maxlength="1600" rows="1" placeholder="Écrire à Dona…" aria-label="Écrire à Dona" required></textarea><button id="donaComposeSend" type="submit" aria-label="Envoyer la demande" title="Envoyer">➤</button></div>
<span id="donaComposeStatus" role="status" aria-live="polite"></span>
</form>`;
root.append(compose);
const body=$('donaComposeBody'),input=$('donaComposeInput'),send=$('donaComposeSend'),status=$('donaComposeStatus');
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,258 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
<!-- ALFRED_MAIN_VISUAL_V1 -->
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
<!-- ALFRED_LISTES_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
<!-- ALFRED_GLASS_V2 -->
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
<!-- ALFRED_REPONSE_CLOSE_V1 -->
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
<!-- DONA_FAMILY_UI_V1 -->
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
<!-- DONA_V7_RELEASE -->
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
<!-- DONA_V8_DYNAMIC -->
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
<!-- DONA_V8_1_ISOLATION -->
<link rel="stylesheet" href="/static/css/dona-header-v8-1.css?v=81">
<!-- DONA_YOUTUBE_V82 -->
<link rel="stylesheet" href="/static/css/dona-youtube-v8-2.css?v=82">
<!-- DONA_NAV_V83 -->
<link rel="stylesheet" href="/static/css/dona-navigation-v8-3.css?v=83">
<!-- DONA_SUITE_V9 -->
<link rel="stylesheet" href="/static/css/dona-suite-v9.css?v=90">
<!-- DONA_FILES_V91 -->
<link rel="stylesheet" href="/static/css/dona-files-v9-1.css?v=91">
<!-- DONA_UI_V92 -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2.css?v=92">
<!-- DONA_UI_V921_CLOSE -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2-1-close.css?v=921">
<!-- DONA_LEARNING_V93 -->
<link rel="stylesheet" href="/static/css/dona-learning-v9-3.css?v=93">
<!-- DONA_VISION_V95 -->
<link rel="stylesheet" href="/static/css/dona-vision-v9-5.css?v=95">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
<path d="m16.5 10 5-3v10l-5-3"/>
</svg>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
<div id="alfredTimersList"></div>
</aside>
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
aria-modal="true" hidden>
<article class="alfred-context-card">
<button id="alfredContextClose" class="alfred-context-close" type="button"
aria-label="Fermer l'illustration" title="Fermer">×</button>
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
<div class="alfred-context-details">
<h2 id="alfredContextTitle"></h2>
<p id="alfredContextText"></p>
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
</div>
</article>
</div>
<script src="/static/js/alfred-timers-context.js"></script>
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
<script src="/static/js/alfred-main-v1.js?v=1"></script>
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<script src="/static/js/alfred-maison-card.js?v=1"></script>
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
<script src="/static/js/alfred-main-v2.js?v=2"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
<script defer src="/static/js/dona-spotify.js?v=6"></script>
<!-- dona-ui-fix start -->
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
<!-- dona-ui-fix end -->
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
<script defer src="/static/js/dona-header-v8-1.js?v=81"></script>
<script defer src="/static/js/dona-youtube-v8-2.js?v=82"></script>
<script defer src="/static/js/dona-navigation-v8-3.js?v=83"></script>
<script defer src="/static/js/dona-suite-v9.js?v=90"></script>
<script defer src="/static/js/dona-files-v9-1.js?v=91"></script>
<script defer src="/static/js/dona-ui-v9-2.js?v=92"></script>
<script defer src="/static/js/dona-ui-v9-2-1-close.js?v=921"></script>
<script defer src="/static/js/dona-learning-v9-3.js?v=93"></script>
<script defer src="/static/js/dona-vision-v9-5.js?v=95"></script>
</body>
</html>
@@ -0,0 +1,23 @@
{
"kind": "dona-clock-sculpture-v1.1",
"app": "/opt/alfred/app",
"original": [
"app.py",
"static/js/dona-waves-v1.js",
"static/css/dona-waves-v1.css"
],
"created": [
"services/dona_clock_local.py"
],
"installed_sha256": {
"app.py": "246913f10e751bbd35f71dbcfa553f8284cdd1c050d3eadf1d2f276ce33ef184",
"static/js/dona-waves-v1.js": "3a87d35280436920024f550feb9040450ff057e9c3e6b75e5d7f75f4cf194ffc",
"static/css/dona-waves-v1.css": "47df09de7549762feda8d95cfc3bd9763bd7f01c3bfb10a07844f000867b2a53",
"services/dona_clock_local.py": "705a67d28387a57bff92ab44bf0ac9195422bb47c1d311cecf13bad6050a0ee8"
},
"original_sha256": {
"app.py": "472959331cfece1d75100f32d77c57b4b1838fafff45f0d49e13fd17837d942a",
"static/js/dona-waves-v1.js": "8c33a71da919da54533ecdffc1a4368c9ea16daff893a082a14f05170ac9b3c5",
"static/css/dona-waves-v1.css": "9c9586493b6b6ec506928f6ebac79f28053b9df36b680ce1c0835aa75bf3f1cb"
}
}
@@ -0,0 +1,887 @@
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="*")
# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur.
from services import piper_tts as _dona_waves_piper
_dona_waves_piper.dona_set_level_callback(
lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)})
)
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
# Service temps autonome (survit aux conversations Hermes)
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
on_interrupt=duplex_stop_piper,
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
camera_settings.restore_saved_settings()
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
if not conversation_lock.acquire(blocking=False):
return
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
from services.audio import STTResult
r = STTResult(text=injected_text)
else:
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
# DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée.
_dona_v93_original_vosk = r.text
_dona_v93_ack = _dona_learning_v93.voice_feedback(r.text)
if _dona_v93_ack is not None:
socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack})
if not audio_settings.speaker_muted():
_dona_v93_done = threading.Event()
if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set):
_dona_v93_done.wait(timeout=120)
emit_state('idle',partial='')
return
r.text = _dona_learning_v93.correct_transcript(r.text)
if r.text != _dona_v93_original_vosk:
socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text})
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
if _dona_vision_v95.is_camera_question(r.text):
try:
_vision_answer = _dona_vision_monitor.analyze(r.text)
except RuntimeError as _vision_exc:
_vision_answer = str(_vision_exc)
except Exception as _vision_exc:
print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True)
_vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer))
if _vision_answer and not audio_settings.speaker_muted():
emit_state('speaking')
_vision_done=threading.Event()
if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_FILES_V91 : questions explicites sur la pièce jointe active.
if _dona_files_v91.is_file_question(r.text):
_file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech))
if _file_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_file_done = threading.Event()
if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120)
emit_state('idle', partial='')
return
# DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes.
if _dona_suite_v9.is_request(r.text):
_v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech))
if _v9_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_v9_done=threading.Event()
_v9_ok=speak_async(_v9_speech,on_end=_v9_done.set)
if _v9_ok:_v9_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM.
if _dona_nav.is_request(r.text):
_nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply))
if _nav_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_nav_done=threading.Event()
_nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'})))
if _nav_ok:_nav_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix.
if _dona_youtube.is_search(r.text):
_speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech))
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_evt=threading.Event()
_ok=speak_async(_speech,on_end=_evt.set)
if _ok: _evt.wait(timeout=120)
emit_state('idle',partial='')
return
if _dona_youtube.is_control(r.text,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(r.text,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
if _dona_dynamic.is_dynamic_request(r.text):
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic', 'dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render', _payload)
if not audio_settings.speaker_muted():
emit_state('speaking')
_finished = threading.Event()
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
if _started: _finished.wait(timeout=120)
emit_state('idle', partial='')
return
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
done.wait(timeout=120)
emit_state("idle", partial="")
return
# ALFRED_LISTES_GLASS_V1_VOICE_START
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
try:
_lists_reply = _alfred_handle_list_voice(
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
except Exception as _lists_error:
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
if _lists_reply is not None:
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply))
_lists_display = {'type':'text', 'text':_lists_reply}
with STATE_LOCK:
STATE['content'] = _lists_display
socketio.emit('alfred_render', _lists_display)
if _lists_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_lists_done = threading.Event()
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_lists_done.wait(timeout=120)
emit_state('idle', partial='')
return
# ALFRED_LISTES_GLASS_V1_VOICE_END
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(r.text))
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
alfred_context_images.schedule_context_image(
r.text, speech, socketio,
lambda: STATE.get("transcript") == r.text,
) # ALFRED_TIMERS_CONTEXT_V1
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
alfred_features.publish_voice_display(display)
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
try:
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
if started:
done.wait(timeout=120)
finally:
if not duplex_monitor.interrupted():
duplex_monitor.stop()
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
if duplex_monitor.interrupted():
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
duplex_monitor.stop()
else:
# A few wake-free seconds after the assistant finishes speaking.
pending_duplex_text = duplex_monitor.listen_conversation()
if pending_duplex_text:
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
# Only the voice is cancelled; the original Hermes job is untouched.
wake.pause()
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
daemon=True, name='dona-duplex-followup').start()
elif not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@app.get('/api/duplex/status')
def duplex_status():
return jsonify(duplex_monitor.status())
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
def _alfred_dashboard_quick_command(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _run():
try:
wake.pause()
with STATE_LOCK:
_image_revision_before = IMAGE_REVISION
emit_state('thinking', transcript=prompt, partial='',
content={'type':'text', 'text':prompt})
_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=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_done.wait(timeout=120)
emit_state('idle', partial='')
except Exception as _exc:
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
emit_state('error', content={'type':'text', 'text':str(_exc)})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
return True
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
# ALFRED_ADMIN_GLASS_MAISON_V1_START
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
_alfred_register_home_glass(app)
# ALFRED_ADMIN_GLASS_MAISON_V1_END
# ALFRED_LISTES_GLASS_V1_BACKEND_START
from services.alfred_lists import register_lists as _alfred_register_lists
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
_alfred_register_lists(app, socketio)
# ALFRED_LISTES_GLASS_V1_BACKEND_END
# ALFRED_GLASS_V2_BACKEND_START
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
_alfred_register_logs_v2(app)
# ALFRED_GLASS_V2_BACKEND_END
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
_alfred_identity_register(app)
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
_alfred_developer_register(app)
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
_register_hermes_new_session(app)
# DONA_FAMILY_UI_V1_START
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
def _dona_submit_typed(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _dona_run_typed():
try:
wake.pause()
with STATE_LOCK:
_before_image = IMAGE_REVISION
emit_state('thinking', partial='', transcript=prompt,
content={'type': 'text', 'text': prompt})
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
if _dona_vision_v95.is_camera_question(prompt):
try:
_vision_answer=_dona_vision_monitor.analyze(prompt)
except Exception as _vision_exc:
_vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer))
emit_state('idle',partial='')
return
# DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe.
if _dona_files_v91.is_file_question(prompt):
_dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK)
emit_state('idle',partial='')
return
# DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes.
if _dona_suite_v9.is_request(prompt):
_v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes.
if _dona_nav.is_request(prompt):
_v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
if _dona_youtube.is_search(prompt):
_v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
if _dona_youtube.is_control(prompt,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(prompt,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
if _dona_dynamic.is_dynamic_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render',_payload)
emit_state('idle', partial='')
return
_answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt))
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
# DONA_LEARNING_V93 : évaluation de l’échange écrit.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_speech))
# Deliberately no speak_async(): typed interaction is silent.
emit_state('idle', partial='')
except Exception as exc:
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
import traceback
traceback.print_exc()
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
return True
from services.dona_family_ui import register_dona_family as _dona_register_family
_dona_register_family(app, adult_command=_dona_submit_typed)
# DONA_FAMILY_UI_V1_END
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
from services import dona_dynamic as _dona_dynamic
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
from services import dona_dashboard_agent as _dona_dashboard_agent
def _dona_dynamic_submit(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _work():
try:
wake.pause()
emit_state('thinking',transcript=prompt,partial='')
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
_payload={'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content']=_payload
socketio.emit('alfred_render',_payload)
emit_state('idle',partial='')
except Exception as exc:
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
return True
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in.
from services import dona_vision_v95 as _dona_vision_v95
_dona_vision_monitor = _dona_vision_v95.register(app, socketio)
# DONA_LEARNING_V93 : retours explicites et corrections approuvées.
from services import dona_learning_v93 as _dona_learning_v93
_dona_learning_v93.register(app)
# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur.
from services import dona_shortcuts_v92 as _dona_shortcuts_v92
_dona_shortcuts_v92.register(app)
# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée.
from services import dona_files_v91 as _dona_files_v91
_dona_files_v91.register_files(app)
# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines.
from services import dona_suite_v9 as _dona_suite_v9
_dona_suite_v9.register_suite(app,socketio)
# DONA_NAV_V83: géocodage, carte et transfert Waze.
from services import dona_navigation as _dona_nav
_dona_nav.register_navigation(app,socketio)
# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé.
from services import dona_youtube as _dona_youtube
_dona_youtube.register_youtube(app,socketio)
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
_register_dona_spotify(app)
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
from services.dona_face import (register_face as _dona_register_face,
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,22 @@
/* Dona — vagues vocales, sélection réversible sans désinstaller. */
body.dona-family-v1.dona-waves-selected .dona-organic-wrap {
width:min(93vw,1400px)!important;
height:clamp(155px,28vh,330px)!important;
min-width:0!important;min-height:155px!important;
max-width:100%!important;filter:none!important;
overflow:visible!important;
}
body.dona-family-v1.dona-waves-selected #donaOrganicCanvas,
body.dona-family-v1.dona-waves-selected .dona-orb-reflection {display:none!important}
body.dona-family-v1 .dona-organic-wrap #donaWavesCanvas {
display:none;position:absolute;inset:0;width:100%;height:100%;pointer-events:none;
}
body.dona-family-v1.dona-waves-selected .dona-organic-wrap #donaWavesCanvas {display:block}
body.dona-family-v1.dona-waves-selected .dona-art-name {margin-top:-5px!important}
.dona-waves-menu-title{color:#c7b8cc;font-size:11px;letter-spacing:.07em;padding:10px 10px 5px;text-transform:uppercase}
.dv92-menu-entry.dona-waves-option[aria-checked="true"] {background:rgba(94,148,208,.19);color:#ffe1b8}
.dona-waves-option .dona-waves-choice-icon{width:22px;min-width:22px;text-align:center;font-size:18px}
@media(max-width:650px){
body.dona-family-v1.dona-waves-selected .dona-organic-wrap{height:clamp(145px,23vh,240px)!important;min-height:145px!important;width:min(96vw,740px)!important}
body.dona-family-v1.dona-waves-selected .dona-art-name{font-size:clamp(32px,8vw,50px)}
}
@@ -0,0 +1,165 @@
/* Dona Vagues Vocales V1 — Canvas 2D, amplitude PCM Piper envoyée par Socket.IO.
Le mode orb conserve l'ancien canvas et son animation.
Aucune capture micro et aucun audio client ne sont nécessaires. */
(() => {
'use strict';
if (window.DonaWaves) return;
const KEY='dona_visual_voice_v1';
let choice='waves';
try {if (localStorage.getItem(KEY)==='orb')choice='orb';}catch (_) {}
const API={isActive:()=>choice==='waves',select:selectMode,getChoice:()=>choice};
window.DonaWaves=API;
document.body.classList.toggle('dona-waves-selected', choice==='waves');
function selectMode(next){
if(next!=='waves'&&next!=='orb')return;
choice=next;
document.body.classList.toggle('dona-waves-selected',choice==='waves');
try{localStorage.setItem(KEY,choice);}catch(_){}
syncMenu();
}
function syncMenu(){
const menu=document.getElementById('dv92ToolsMenu');if(!menu)return;
for(const el of menu.querySelectorAll('[data-dona-visual]')){
const active=el.dataset.donaVisual===choice;
el.setAttribute('aria-checked',String(active));
el.querySelector('.dona-waves-choice-icon').textContent=active?'◉':'○';
}
}
function addMenuControls(){
const menu=document.getElementById('dv92ToolsMenu');
if(!menu||menu.querySelector('#donaWavesMenuTitle'))return;
const title=document.createElement('div');title.id='donaWavesMenuTitle';
title.className='dona-waves-menu-title';title.textContent='Animation centrale';
menu.append(title);
for(const [mode,label] of [['waves','Vagues colorées (Piper)'],['orb','Orbe d’origine']]){
const b=document.createElement('button');b.type='button';
b.className='dv92-menu-entry dona-waves-option';b.dataset.donaVisual=mode;
b.setAttribute('role','menuitemradio');
const mark=document.createElement('span');mark.className='dona-waves-choice-icon';
b.append(mark,document.createTextNode(label));
b.addEventListener('click',()=>{
selectMode(mode);
menu.hidden=true;
document.getElementById('dv92Hamburger')?.setAttribute('aria-expanded','false');
});
menu.append(b);
}
syncMenu();
}
let started=false;
function init(){
if(started)return true;
const root=document.getElementById('alfredMainV1');
const wrap=root?.querySelector('.dona-organic-wrap');
if(!wrap)return false;
started=true;
const canvas=document.createElement('canvas');canvas.id='donaWavesCanvas';
canvas.setAttribute('role','img');canvas.setAttribute('aria-label','Vagues animées de Dona réagissant à la voix Piper');
wrap.append(canvas);
const ctx=canvas.getContext('2d',{alpha:true});if(!ctx)return true;
const reduce=window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches||false;
let w=0,h=0,dpr=1,mode=String(root.dataset.familyMode||'idle');
let phase=0,smoothed=.02,pcm=0,lastPCM=0,voiceFinished=false,then=0,raf=0;
const socket=window.alfredSocket;
socket?.on('alfred_state',data=>{
const next=String(data?.mode||'idle').toLowerCase();
if(next==='speaking'&&mode!=='speaking')voiceFinished=false;
mode=next;
});
socket?.on('dona_piper_level',data=>{
const value=Number(data?.level);
if(!Number.isFinite(value))return;
pcm=Math.max(0,Math.min(1,value));
lastPCM=performance.now();
});
socket?.on('alfred_tts_finished',()=>{pcm=0;voiceFinished=true;});
const ro=typeof ResizeObserver!=='undefined'?new ResizeObserver(resize):null;
ro?.observe(wrap);window.addEventListener('resize',resize,{passive:true});
function resize(){
const box=wrap.getBoundingClientRect();
if(!box.width||!box.height)return;
const ratio=Math.min(window.devicePixelRatio||1,1.7);
const nw=Math.round(box.width*ratio),nh=Math.round(box.height*ratio);
if(nw===canvas.width&&nh===canvas.height)return;
w=box.width;h=box.height;dpr=ratio;
canvas.width=nw;canvas.height=nh;ctx.setTransform(ratio,0,0,ratio,0,0);
}
function draw(now){
raf=requestAnimationFrame(draw);
if(choice!=='waves'||document.hidden||now-then<30)return;
then=now;
resize();if(!w||!h)return;
const speaking=mode==='speaking'&&!voiceFinished;
const current=(now-lastPCM<240&&speaking)?pcm:0;
const base=mode==='listening'?.16:mode==='thinking'?.115:speaking?.07:.025;
const target=Math.max(base,current*.88);
smoothed+=(target-smoothed)*(target>smoothed?.35:.19);
if(!reduce)phase+=mode==='listening'?.054:mode==='thinking'?.034:speaking?.047:.018;
ctx.clearRect(0,0,w,h);
const mid=h*.51, amplitude=h*(.245+smoothed*.35);
const palette=[
['#1362ff','#11c8fc','#6b56e9'],
['#1e92ef','#9274ff','#e47dbd'],
['#6c4bd9','#ef86b5','#ffd29b'],
['#4a5ad8','#ffb3c2','#ffc282'],
['#16b8ed','#8c66f5','#f596b5'],
['#7854e5','#d891ed','#fdad8b']
];
// Contours en membranes translucides, superposés pour former une onde lumineuse.
for(let layer=0;layer<palette.length;layer++){
const [left,center,right]=palette[layer];
const m=layer*1.07;
const points=[];
for(let x=0;x<=w+5;x+=5){
const nx=x/w;
const envelope=Math.pow(Math.max(0,Math.sin(Math.PI*nx)),.75);
const s1=Math.sin(nx*Math.PI*(2.55+m*.24)+phase*(.70+m*.04)+m*.79);
const s2=Math.sin(nx*Math.PI*(5.0-m*.17)-phase*(.42+m*.02)+m*1.18);
const y=mid+(s1*.77+s2*.23)*amplitude*envelope
+Math.cos(nx*Math.PI*2.6+phase*.28+m)*h*.014;
points.push([x,y]);
}
const gradient=ctx.createLinearGradient(0,0,w,0);
gradient.addColorStop(0,left);gradient.addColorStop(.5,center);gradient.addColorStop(1,right);
ctx.beginPath();ctx.moveTo(0,mid);
for(const [x,y] of points)ctx.lineTo(x,y);
const thickness=h*(.11+.018*(layer%3)+.09*smoothed);
for(let i=points.length-1;i>=0;i--){const [x,y]=points[i];ctx.lineTo(x,y+thickness);}
ctx.closePath();ctx.fillStyle=gradient;ctx.globalAlpha=.095+(layer%3)*.058+smoothed*.1;ctx.fill();
ctx.beginPath();points.forEach(([x,y],i)=>i?ctx.lineTo(x,y):ctx.moveTo(x,y));
ctx.lineWidth=(layer%2?1.5:1.9)+smoothed*1.35;
ctx.strokeStyle=gradient;ctx.globalAlpha=.60+smoothed*.30;
ctx.shadowBlur=15+smoothed*25;ctx.shadowColor=center;ctx.stroke();
ctx.shadowBlur=0;ctx.globalAlpha=1;
}
// Lueur centrale très diffuse, avec énergie liée à l'amplitude Piper.
const glow=ctx.createRadialGradient(w*.5,mid,0,w*.5,mid,w*.34);
glow.addColorStop(0,`rgba(255,194,205,${.06+smoothed*.18})`);
glow.addColorStop(.45,`rgba(107,99,255,${.025+smoothed*.045})`);
glow.addColorStop(1,'rgba(56,138,244,0)');
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
// Petits points lumineux déterministes, très discrets au repos.
for(let i=0;i<22;i++){
const nx=(i*0.61803398875)%1,ny=.5+.33*Math.sin(i*2.18+phase*.14);
const x=nx*w,y=ny*h;
ctx.globalAlpha=(.11+smoothed*.37)*Math.pow(Math.sin(nx*Math.PI),2);
ctx.fillStyle=i%3===0?'#ffb5cc':'#83ceff';ctx.beginPath();ctx.arc(x,y,.8+(i%3)*.35,0,Math.PI*2);ctx.fill();
}
ctx.globalAlpha=1;
}
resize();raf=requestAnimationFrame(draw);
window.addEventListener('pagehide',()=>{cancelAnimationFrame(raf);ro?.disconnect();},{once:true});
return true;
}
let attempts=0;
function ready(){
if(!init()&&attempts++<50)setTimeout(ready,120);
addMenuControls();
const menu=document.getElementById('dv92ToolsMenu');
if(menu&&!menu.dataset.donaWavesObserved){
menu.dataset.donaWavesObserved='1';
new MutationObserver(()=>addMenuControls()).observe(menu,{childList:true});
}
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',ready,{once:true});else ready();
})();
@@ -0,0 +1,917 @@
import os, threading, hmac
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Flask, render_template, request, jsonify, Response
from flask_socketio import SocketIO
from dotenv import load_dotenv
# Charger la configuration AVANT les imports des services
load_dotenv("/opt/alfred/.env")
load_dotenv(
"/opt/alfred/app/config/control_center.env",
override=True
)
from services.audio import listen_once, speak_async, tts_backend_detect
from services.wakeword import WakeWordListener
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
from services.piper_tts import stop_playback as duplex_stop_piper
from services import admin_console # ALFRED_ADMIN_PAGES_V1
from services.hermes_client import ask_hermes
from services import dona_clock_local as _dona_clock_local # DONA_CLOCK_SCULPTURE_V11
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
from services import alfred_features # ALFRED_FEATURES_V45
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
from services.camera import video_stream
from services import ha_views, alfred_views_controller
from services import camera_settings, ha_weather, weather_outfits
load_dotenv()
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
##
from services.audio import get_vosk_model
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
print("[ALFRED] Modèles vocaux prêts.", flush=True)
##
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
# DONA_WAVES_V1 : événement léger, pas de flux audio transmis au navigateur.
from services import piper_tts as _dona_waves_piper
_dona_waves_piper.dona_set_level_callback(
lambda level: socketio.emit('dona_piper_level', {'level': round(level, 4)})
)
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
STATE_LOCK = threading.RLock()
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
conversation_lock=threading.Lock()
mic_stop_event=threading.Event()
# Service temps autonome (survit aux conversations Hermes)
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
audio_settings.initialize()
audio_settings.update_settings({"mic_gain": 30})
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
on_interrupt=duplex_stop_piper,
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
camera_settings.restore_saved_settings()
def emit_state(mode=None, **extra):
with STATE_LOCK:
if mode: STATE["mode"] = mode
STATE.update(extra)
socketio.emit("alfred_state", dict(STATE))
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
if not conversation_lock.acquire(blocking=False):
return
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
try:
emit_state("listening", partial="", transcript="", wake_score=score)
print("[ALFRED] Début écoute STT", flush=True)
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
from services.audio import STTResult
r = STTResult(text=injected_text)
else:
r = listen_once(
stop_event=mic_stop_event,
on_partial=lambda p: emit_state("listening", partial=p),
on_level=audio_level
)
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
if audio_settings.mic_muted():
emit_state("idle", partial="")
return
if r.error:
emit_state(
"error",
content={"type": "text", "text": r.error}
)
return
if not r.text:
emit_state("idle", partial="")
return
# DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée.
_dona_v93_original_vosk = r.text
_dona_v93_ack = _dona_learning_v93.voice_feedback(r.text)
if _dona_v93_ack is not None:
socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack})
if not audio_settings.speaker_muted():
_dona_v93_done = threading.Event()
if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set):
_dona_v93_done.wait(timeout=120)
emit_state('idle',partial='')
return
r.text = _dona_learning_v93.correct_transcript(r.text)
if r.text != _dona_v93_original_vosk:
socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text})
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# DONA_CLOCK_SCULPTURE_V11 : heure/date locales AVANT Hermes.
_dona_local_clock_reply = _dona_clock_local.answer(r.text)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,_dona_local_clock_reply))
if not audio_settings.speaker_muted():
emit_state('speaking')
_dona_clock_done=threading.Event()
_dona_clock_started=speak_async(_dona_local_clock_reply,
on_end=lambda: (_dona_clock_done.set(),
socketio.emit('alfred_tts_finished', {'source':'voice'})))
if _dona_clock_started:_dona_clock_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
if _dona_vision_v95.is_camera_question(r.text):
try:
_vision_answer = _dona_vision_monitor.analyze(r.text)
except RuntimeError as _vision_exc:
_vision_answer = str(_vision_exc)
except Exception as _vision_exc:
print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True)
_vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer))
if _vision_answer and not audio_settings.speaker_muted():
emit_state('speaking')
_vision_done=threading.Event()
if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_FILES_V91 : questions explicites sur la pièce jointe active.
if _dona_files_v91.is_file_question(r.text):
_file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech))
if _file_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_file_done = threading.Event()
if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120)
emit_state('idle', partial='')
return
# DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes.
if _dona_suite_v9.is_request(r.text):
_v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech))
if _v9_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_v9_done=threading.Event()
_v9_ok=speak_async(_v9_speech,on_end=_v9_done.set)
if _v9_ok:_v9_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM.
if _dona_nav.is_request(r.text):
_nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply))
if _nav_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_nav_done=threading.Event()
_nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'})))
if _nav_ok:_nav_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix.
if _dona_youtube.is_search(r.text):
_speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech))
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_evt=threading.Event()
_ok=speak_async(_speech,on_end=_evt.set)
if _ok: _evt.wait(timeout=120)
emit_state('idle',partial='')
return
if _dona_youtube.is_control(r.text,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(r.text,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
if _dona_dynamic.is_dynamic_request(r.text):
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic', 'dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render', _payload)
if not audio_settings.speaker_muted():
emit_state('speaking')
_finished = threading.Event()
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
if _started: _finished.wait(timeout=120)
emit_state('idle', partial='')
return
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
done.wait(timeout=120)
emit_state("idle", partial="")
return
# ALFRED_LISTES_GLASS_V1_VOICE_START
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
try:
_lists_reply = _alfred_handle_list_voice(
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
except Exception as _lists_error:
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
if _lists_reply is not None:
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply))
_lists_display = {'type':'text', 'text':_lists_reply}
with STATE_LOCK:
STATE['content'] = _lists_display
socketio.emit('alfred_render', _lists_display)
if _lists_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_lists_done = threading.Event()
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_lists_done.wait(timeout=120)
emit_state('idle', partial='')
return
# ALFRED_LISTES_GLASS_V1_VOICE_END
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(r.text))
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
alfred_context_images.schedule_context_image(
r.text, speech, socketio,
lambda: STATE.get("transcript") == r.text,
) # ALFRED_TIMERS_CONTEXT_V1
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
alfred_features.publish_voice_display(display)
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
try:
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
if started:
done.wait(timeout=120)
finally:
if not duplex_monitor.interrupted():
duplex_monitor.stop()
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
if duplex_monitor.interrupted():
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
duplex_monitor.stop()
else:
# A few wake-free seconds after the assistant finishes speaking.
pending_duplex_text = duplex_monitor.listen_conversation()
if pending_duplex_text:
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
# Only the voice is cancelled; the original Hermes job is untouched.
wake.pause()
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
daemon=True, name='dona-duplex-followup').start()
elif not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@app.get('/api/duplex/status')
def duplex_status():
return jsonify(duplex_monitor.status())
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
def _alfred_dashboard_quick_command(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _run():
try:
wake.pause()
with STATE_LOCK:
_image_revision_before = IMAGE_REVISION
emit_state('thinking', transcript=prompt, partial='',
content={'type':'text', 'text':prompt})
_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=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_done.wait(timeout=120)
emit_state('idle', partial='')
except Exception as _exc:
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
emit_state('error', content={'type':'text', 'text':str(_exc)})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
return True
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
# ALFRED_ADMIN_GLASS_MAISON_V1_START
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
_alfred_register_home_glass(app)
# ALFRED_ADMIN_GLASS_MAISON_V1_END
# ALFRED_LISTES_GLASS_V1_BACKEND_START
from services.alfred_lists import register_lists as _alfred_register_lists
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
_alfred_register_lists(app, socketio)
# ALFRED_LISTES_GLASS_V1_BACKEND_END
# ALFRED_GLASS_V2_BACKEND_START
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
_alfred_register_logs_v2(app)
# ALFRED_GLASS_V2_BACKEND_END
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
_alfred_identity_register(app)
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
_alfred_developer_register(app)
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
_register_hermes_new_session(app)
# DONA_FAMILY_UI_V1_START
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
def _dona_submit_typed(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _dona_run_typed():
try:
wake.pause()
with STATE_LOCK:
_before_image = IMAGE_REVISION
emit_state('thinking', partial='', transcript=prompt,
content={'type': 'text', 'text': prompt})
# DONA_CLOCK_SCULPTURE_V11 : même traitement depuis Écrire à Dona / raccourcis.
_dona_local_clock_reply = _dona_clock_local.answer(prompt)
if _dona_local_clock_reply is not None:
with STATE_LOCK:
STATE['content']={'type':'text','text':_dona_local_clock_reply}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_dona_local_clock_reply))
emit_state('idle',partial='')
return
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
if _dona_vision_v95.is_camera_question(prompt):
try:
_vision_answer=_dona_vision_monitor.analyze(prompt)
except Exception as _vision_exc:
_vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer))
emit_state('idle',partial='')
return
# DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe.
if _dona_files_v91.is_file_question(prompt):
_dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK)
emit_state('idle',partial='')
return
# DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes.
if _dona_suite_v9.is_request(prompt):
_v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes.
if _dona_nav.is_request(prompt):
_v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
if _dona_youtube.is_search(prompt):
_v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
if _dona_youtube.is_control(prompt,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(prompt,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
if _dona_dynamic.is_dynamic_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render',_payload)
emit_state('idle', partial='')
return
_answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt))
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
# DONA_LEARNING_V93 : évaluation de l’échange écrit.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_speech))
# Deliberately no speak_async(): typed interaction is silent.
emit_state('idle', partial='')
except Exception as exc:
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
import traceback
traceback.print_exc()
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
return True
from services.dona_family_ui import register_dona_family as _dona_register_family
_dona_register_family(app, adult_command=_dona_submit_typed)
# DONA_FAMILY_UI_V1_END
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
from services import dona_dynamic as _dona_dynamic
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
from services import dona_dashboard_agent as _dona_dashboard_agent
def _dona_dynamic_submit(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _work():
try:
wake.pause()
emit_state('thinking',transcript=prompt,partial='')
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
_payload={'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content']=_payload
socketio.emit('alfred_render',_payload)
emit_state('idle',partial='')
except Exception as exc:
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
return True
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in.
from services import dona_vision_v95 as _dona_vision_v95
_dona_vision_monitor = _dona_vision_v95.register(app, socketio)
# DONA_LEARNING_V93 : retours explicites et corrections approuvées.
from services import dona_learning_v93 as _dona_learning_v93
_dona_learning_v93.register(app)
# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur.
from services import dona_shortcuts_v92 as _dona_shortcuts_v92
_dona_shortcuts_v92.register(app)
# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée.
from services import dona_files_v91 as _dona_files_v91
_dona_files_v91.register_files(app)
# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines.
from services import dona_suite_v9 as _dona_suite_v9
_dona_suite_v9.register_suite(app,socketio)
# DONA_NAV_V83: géocodage, carte et transfert Waze.
from services import dona_navigation as _dona_nav
_dona_nav.register_navigation(app,socketio)
# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé.
from services import dona_youtube as _dona_youtube
_dona_youtube.register_youtube(app,socketio)
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
_register_dona_spotify(app)
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
from services.dona_face import (register_face as _dona_register_face,
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,14 @@
{
"version": 1,
"appdir": "/opt/alfred/app",
"files": {
"app.py": {
"old": "246913f10e751bbd35f71dbcfa553f8284cdd1c050d3eadf1d2f276ce33ef184",
"new": "367380be744b53e3cb478b359726e18964971dac16540f88b922e90f932eafa7"
},
"services/dona_json_autorender_v1.py": {
"old": null,
"new": "3cb1e5147af521a3a133c3568b33aa5eb6a0e5d38eee24a89a852eb575fbbf18"
}
}
}
@@ -0,0 +1,19 @@
{
"kind": "dona-reply-floating-v1",
"app": "/opt/alfred/app",
"modified": [
"templates/index.html"
],
"created": [
"static/css/dona-reply-floating-v1.css",
"static/js/dona-reply-floating-v1.js"
],
"original_sha256": {
"templates/index.html": "5033a7cdccf4ca47307afea1f70ca648d9c91d3e310969b384b23d9648f0a854"
},
"installed_sha256": {
"templates/index.html": "8f6a1a7d9fa934d1fefa646976b6cf2b27e0ecc9e1dc0d2adf5bbf9ab3e1c390",
"static/css/dona-reply-floating-v1.css": "c3411ba829e1217885a9f32e6122099c12e921119e65ad6586339b84a3f8ee40",
"static/js/dona-reply-floating-v1.js": "d0feb5ca2152ba3b7291a2c26e81ec1541aca37051d10e2d883c9db0d7a237b3"
}
}
@@ -0,0 +1,262 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
<!-- ALFRED_MAIN_VISUAL_V1 -->
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
<!-- ALFRED_LISTES_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
<!-- ALFRED_GLASS_V2 -->
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
<!-- ALFRED_REPONSE_CLOSE_V1 -->
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
<!-- DONA_FAMILY_UI_V1 -->
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
<!-- DONA_V7_RELEASE -->
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
<!-- DONA_V8_DYNAMIC -->
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
<!-- DONA_V8_1_ISOLATION -->
<link rel="stylesheet" href="/static/css/dona-header-v8-1.css?v=81">
<!-- DONA_YOUTUBE_V82 -->
<link rel="stylesheet" href="/static/css/dona-youtube-v8-2.css?v=82">
<!-- DONA_NAV_V83 -->
<link rel="stylesheet" href="/static/css/dona-navigation-v8-3.css?v=83">
<!-- DONA_SUITE_V9 -->
<link rel="stylesheet" href="/static/css/dona-suite-v9.css?v=90">
<!-- DONA_FILES_V91 -->
<link rel="stylesheet" href="/static/css/dona-files-v9-1.css?v=91">
<!-- DONA_UI_V92 -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2.css?v=92">
<!-- DONA_UI_V921_CLOSE -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2-1-close.css?v=921">
<!-- DONA_LEARNING_V93 -->
<link rel="stylesheet" href="/static/css/dona-learning-v9-3.css?v=93">
<!-- DONA_VISION_V95 -->
<link rel="stylesheet" href="/static/css/dona-vision-v9-5.css?v=95">
<!-- DONA_WAVES_V1 -->
<link rel="stylesheet" href="/static/css/dona-waves-v1.css?v=1">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
<path d="m16.5 10 5-3v10l-5-3"/>
</svg>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
<div id="alfredTimersList"></div>
</aside>
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
aria-modal="true" hidden>
<article class="alfred-context-card">
<button id="alfredContextClose" class="alfred-context-close" type="button"
aria-label="Fermer l'illustration" title="Fermer">×</button>
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
<div class="alfred-context-details">
<h2 id="alfredContextTitle"></h2>
<p id="alfredContextText"></p>
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
</div>
</article>
</div>
<script src="/static/js/alfred-timers-context.js"></script>
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
<script src="/static/js/alfred-main-v1.js?v=1"></script>
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<script src="/static/js/alfred-maison-card.js?v=1"></script>
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
<script src="/static/js/alfred-main-v2.js?v=2"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
<script defer src="/static/js/dona-spotify.js?v=6"></script>
<!-- dona-ui-fix start -->
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
<!-- dona-ui-fix end -->
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
<script defer src="/static/js/dona-header-v8-1.js?v=81"></script>
<script defer src="/static/js/dona-youtube-v8-2.js?v=82"></script>
<script defer src="/static/js/dona-navigation-v8-3.js?v=83"></script>
<script defer src="/static/js/dona-suite-v9.js?v=90"></script>
<script defer src="/static/js/dona-files-v9-1.js?v=91"></script>
<script defer src="/static/js/dona-ui-v9-2.js?v=92"></script>
<script defer src="/static/js/dona-ui-v9-2-1-close.js?v=921"></script>
<script defer src="/static/js/dona-learning-v9-3.js?v=93"></script>
<script defer src="/static/js/dona-vision-v9-5.js?v=95"></script>
<!-- DONA_WAVES_V1 -->
<script defer src="/static/js/dona-waves-v1.js?v=1"></script>
</body>
</html>
@@ -0,0 +1,22 @@
{
"kind": "dona-waves-voice-v1",
"app": "/opt/alfred/app",
"original_files": [
"templates/index.html",
"static/js/dona-accueil-ecrit-v1.js",
"services/piper_tts.py",
"app.py"
],
"created_files": [
"static/css/dona-waves-v1.css",
"static/js/dona-waves-v1.js"
],
"installed_sha256": {
"templates/index.html": "5033a7cdccf4ca47307afea1f70ca648d9c91d3e310969b384b23d9648f0a854",
"static/js/dona-accueil-ecrit-v1.js": "aba0a743b74d8fd5f2e5e91b129db69885a350a40c095f90d15ef28c0bc20c8e",
"services/piper_tts.py": "a80aecfa38ceeeb1779da502801878ab435b6b0721ec2ffc26b020871115dd71",
"app.py": "472959331cfece1d75100f32d77c57b4b1838fafff45f0d49e13fd17837d942a",
"static/css/dona-waves-v1.css": "9c9586493b6b6ec506928f6ebac79f28053b9df36b680ce1c0835aa75bf3f1cb",
"static/js/dona-waves-v1.js": "8c33a71da919da54533ecdffc1a4368c9ea16daff893a082a14f05170ac9b3c5"
}
}
@@ -0,0 +1,882 @@
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
# DONA_LEARNING_V93 : évaluation vocale explicite, puis correction Vosk approuvée.
_dona_v93_original_vosk = r.text
_dona_v93_ack = _dona_learning_v93.voice_feedback(r.text)
if _dona_v93_ack is not None:
socketio.emit('alfred_render', {'type':'text','text':_dona_v93_ack})
if not audio_settings.speaker_muted():
_dona_v93_done = threading.Event()
if speak_async(_dona_v93_ack,on_end=_dona_v93_done.set):
_dona_v93_done.wait(timeout=120)
emit_state('idle',partial='')
return
r.text = _dona_learning_v93.correct_transcript(r.text)
if r.text != _dona_v93_original_vosk:
socketio.emit('dona_learning_stt', {'raw':_dona_v93_original_vosk,'used':r.text})
emit_state(
"thinking",
partial="",
transcript=r.text,
content={"type": "text", "text": r.text}
)
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
if _dona_vision_v95.is_camera_question(r.text):
try:
_vision_answer = _dona_vision_monitor.analyze(r.text)
except RuntimeError as _vision_exc:
_vision_answer = str(_vision_exc)
except Exception as _vision_exc:
print('[DONA VISION] Analyse vocale :',type(_vision_exc).__name__,flush=True)
_vision_answer = 'Analyse visuelle indisponible. Vérifie le modèle vision Hermes.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_vision_answer))
if _vision_answer and not audio_settings.speaker_muted():
emit_state('speaking')
_vision_done=threading.Event()
if speak_async(_vision_answer[:850],on_end=_vision_done.set):_vision_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_FILES_V91 : questions explicites sur la pièce jointe active.
if _dona_files_v91.is_file_question(r.text):
_file_speech = _dona_files_v91.handle_voice(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_file_speech))
if _file_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_file_done = threading.Event()
if speak_async(_file_speech, on_end=_file_done.set): _file_done.wait(timeout=120)
emit_state('idle', partial='')
return
# DONA_SUITE_V9: intents locaux explicites, sinon continuer vers Hermes.
if _dona_suite_v9.is_request(r.text):
_v9_speech=_dona_suite_v9.handle_and_emit(r.text,socketio)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_v9_speech))
if _v9_speech and not audio_settings.speaker_muted():
emit_state('speaking')
_v9_done=threading.Event()
_v9_ok=speak_async(_v9_speech,on_end=_v9_done.set)
if _v9_ok:_v9_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_NAV_V83: cartes et itinéraires à la demande, sans LLM.
if _dona_nav.is_request(r.text):
_nav_reply=_dona_nav.handle_and_emit(r.text,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_nav_reply))
if _nav_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_nav_done=threading.Event()
_nav_ok=speak_async(_nav_reply,on_end=lambda: (_nav_done.set(),socketio.emit('alfred_tts_finished',{'source':'voice'})))
if _nav_ok:_nav_done.wait(timeout=120)
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : recherche publique et contrôle du lecteur à la voix.
if _dona_youtube.is_search(r.text):
_speech=_dona_youtube.search_and_emit(r.text, socketio, STATE, STATE_LOCK)
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_speech))
if _speech and not audio_settings.speaker_muted():
emit_state('speaking')
_evt=threading.Event()
_ok=speak_async(_speech,on_end=_evt.set)
if _ok: _evt.wait(timeout=120)
emit_state('idle',partial='')
return
if _dona_youtube.is_control(r.text,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(r.text,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
if _dona_dynamic.is_dynamic_request(r.text):
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic', 'dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render', _payload)
if not audio_settings.speaker_muted():
emit_state('speaking')
_finished = threading.Event()
_started = speak_async('Voici ton tableau de bord : ' + _board['title'], on_end=_finished.set)
if _started: _finished.wait(timeout=120)
emit_state('idle', partial='')
return
# Vue dynamique : commandes déterministes exécutées sans faire
# dépendre l'affichage des capacités ou sessions Hermes.
visual = None
try:
visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type'))
except (ha_views.HAError, ValueError) as exc:
print(f"[ALFRED] Vue indisponible : {exc}", flush=True)
visual = ({"type":"text", "text":str(exc)}, str(exc))
if visual is not None:
content, speech = visual
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
with STATE_LOCK:
STATE["content"] = content
socketio.emit("alfred_render", content)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
done.wait(timeout=120)
emit_state("idle", partial="")
return
# ALFRED_LISTES_GLASS_V1_VOICE_START
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
try:
_lists_reply = _alfred_handle_list_voice(
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
except Exception as _lists_error:
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
if _lists_reply is not None:
socketio.emit('dona_learning_turn', _dona_learning_v93.track('voice',_dona_v93_original_vosk,_lists_reply))
_lists_display = {'type':'text', 'text':_lists_reply}
with STATE_LOCK:
STATE['content'] = _lists_display
socketio.emit('alfred_render', _lists_display)
if _lists_reply and not audio_settings.speaker_muted():
emit_state('speaking')
_lists_done = threading.Event()
speak_async(_lists_reply, on_end=lambda: (_lists_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_lists_done.wait(timeout=120)
emit_state('idle', partial='')
return
# ALFRED_LISTES_GLASS_V1_VOICE_END
print("[ALFRED] Envoi à Hermes", flush=True)
# ComfyUI publie l'image pendant cet appel Hermes.
with STATE_LOCK:
image_revision_before = IMAGE_REVISION
# ALFRED_FEATURES_V45 voice opt-in background mode
if alfred_features.should_run_in_background(r.text):
try:
alfred_features.submit(r.text, source='voice')
emit_state('idle', partial='', content={
'type': 'text',
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
})
except ValueError as exc:
emit_state('error', content={'type': 'text', 'text': str(exc)})
return
# DONA_V7_RELEASE: reconnaissance sur ordre vocal explicite, sinon Hermes normal.
answer = _dona_face_recognize(ask_hermes) if _dona_face_request(r.text) else ask_hermes(_dona_learning_v93.enhance_prompt(r.text))
print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True)
if isinstance(answer, dict):
speech = str(answer.get("speech") or answer.get("text") or "")
display = answer.get("display") or {
"type": "text",
"text": speech
}
else:
speech = str(answer or "")
display = {"type": "text", "text": speech}
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('voice',_dona_v93_original_vosk,speech))
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
alfred_context_images.schedule_context_image(
r.text, speech, socketio,
lambda: STATE.get("transcript") == r.text,
) # ALFRED_TIMERS_CONTEXT_V1
# Si le MCP ComfyUI a affiché une image pendant la requête,
# ne pas l'écraser par la réponse textuelle d'Hermes.
with STATE_LOCK:
if IMAGE_REVISION == image_revision_before:
STATE["content"] = display
alfred_features.publish_voice_display(display)
socketio.emit("alfred_render", display)
else:
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
if speech and not audio_settings.speaker_muted():
emit_state("speaking")
done = threading.Event()
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
try:
started = speak_async(speech, on_end=lambda: (done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
if started:
done.wait(timeout=120)
finally:
if not duplex_monitor.interrupted():
duplex_monitor.stop()
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
if duplex_monitor.interrupted():
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
duplex_monitor.stop()
else:
# A few wake-free seconds after the assistant finishes speaking.
pending_duplex_text = duplex_monitor.listen_conversation()
if pending_duplex_text:
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
emit_state("idle", partial="")
except Exception as exc:
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
emit_state(
"error",
content={"type": "text", "text": str(exc)}
)
finally:
conversation_lock.release()
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
# Only the voice is cancelled; the original Hermes job is untouched.
wake.pause()
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
daemon=True, name='dona-duplex-followup').start()
elif not audio_settings.mic_muted():
wake.resume()
print("[ALFRED] Wake word réactivé", flush=True)
def on_wake(score):
if audio_settings.mic_muted():
return
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
threading.Thread(target=conversation,args=(score,),daemon=True).start()
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
@app.get('/api/duplex/status')
def duplex_status():
return jsonify(duplex_monitor.status())
@app.get('/api/audio/settings')
def get_audio_settings():
return jsonify(audio_settings.get_settings())
@app.post('/api/audio/settings')
def set_audio_settings():
data = request.get_json(silent=True)
try:
old = audio_settings.get_settings()
new = audio_settings.update_settings(data)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
return jsonify({'error': f'ALSA : {exc}'}), 500
if new['mic_muted'] != old['mic_muted']:
if new['mic_muted']:
mic_stop_event.set()
wake.pause()
else:
mic_stop_event.clear()
if not conversation_lock.locked():
wake.resume()
socketio.emit('audio_settings', new)
return jsonify(new)
@app.get('/api/camera/settings')
def get_camera_settings():
try:
return jsonify(camera_settings.get_settings())
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/settings')
def set_camera_settings():
# Même garde-fou que les commandes calendrier existantes.
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({'error': 'JSON invalide.'}), 400
try:
result = camera_settings.update_setting(data.get('control'), data.get('value'))
socketio.emit('camera_settings', result)
return jsonify(result)
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.post('/api/camera/reset')
def reset_camera_settings():
if not _same_origin_json():
return jsonify({'error': 'Requête non autorisée.'}), 403
try:
result = camera_settings.reset_defaults()
socketio.emit('camera_settings', result)
return jsonify(result)
except camera_settings.CameraSettingsError as exc:
return jsonify({'error': str(exc)}), 503
@app.get('/api/camera/stream')
def camera_stream():
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
try:
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
except (ImportError, ValueError, RuntimeError, OSError) as exc:
return jsonify({'error': str(exc)}), 503
return Response(
frames,
mimetype='multipart/x-mixed-replace; boundary=frame',
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
)
# ALFRED_FEATURES_V45 init
alfred_features.init_app(app, ask_hermes, socketio=socketio)
@app.get("/")
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
@app.get("/api/state")
def state():
with STATE_LOCK:
snapshot = dict(STATE)
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
@app.post("/api/state")
def set_state():
data = request.get_json(force=True)
with STATE_LOCK:
STATE.update(data)
socketio.emit("alfred_state", dict(STATE))
return jsonify({"ok": True})
@app.post("/api/render")
def render_payload():
global IMAGE_REVISION
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
if token and not hmac.compare_digest(
request.headers.get("X-Alfred-Render-Token", ""), token
):
return jsonify({"ok": False, "error": "Non autorisé"}), 401
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
if data.get("type") == "image":
image_url = data.get("url")
if not isinstance(image_url, str) or not (
image_url.startswith(("http://", "https://", "/"))
and not image_url.startswith("//")
):
return jsonify({"ok": False, "error": "URL image invalide"}), 400
with STATE_LOCK:
if data.get("type") == "image":
IMAGE_REVISION += 1
STATE["content"] = data
socketio.emit("alfred_render", data)
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
# --- Agenda / capteurs / caméras Home Assistant ---
def _calendar_range():
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
now = datetime.now(tz)
def parse(name, default):
raw = request.args.get(name)
if not raw:return default
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
if value.tzinfo is None: value = value.replace(tzinfo=tz)
return value.astimezone(tz)
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
end = parse('end',start+timedelta(days=7))
return start,end
def _json_error(exc):
code = 400 if isinstance(exc,ValueError) else 502
return jsonify({'error':str(exc)}),code
def _writes_enabled():
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
def _same_origin_json():
# POSTs require a custom header and same-origin browser requests.
if request.headers.get('X-Alfred-Request') != '1':return False
origin=request.headers.get('Origin')
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
return True
@app.get('/api/calendar/calendars')
def calendars_list():
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/calendar/events')
def calendar_events():
try:
start,end=_calendar_range()
selected=request.args.getlist('calendar_id')
return jsonify(ha_views.get_events(start,end,selected or None))
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/calendar/create')
def calendar_create():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
def read_dt(key):
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
str(data.get('location') or ''),bool(data.get('all_day',False)))
from services import alfred_intents
alfred_intents.clear_create()
socketio.emit('calendar_changed', {'type':'created'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.post('/api/calendar/delete')
def calendar_delete():
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
data=request.get_json(silent=True)
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
try:
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
start=datetime.fromisoformat(str(data['start']))
end=datetime.fromisoformat(str(data['end']))
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
rid=str(data.get('recurrence_id') or '')
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
result=ha_views.delete_event(eid,uid,rid or None)
from services import alfred_intents
alfred_intents.clear_delete()
socketio.emit('calendar_changed',{'type':'deleted'})
return jsonify(result)
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
@app.get('/api/ha/weather')
def ha_weather_data():
# Read-only route, token always stays on the Alfred server.
try:
q = request.args.get('q', '')
return jsonify(ha_weather.get_weather(q))
except (ha_views.HAError, ValueError) as exc:
return _json_error(exc)
@app.get('/api/ha/weather/outfits')
def ha_weather_outfits_data():
try:
q = request.args.get('q', '')
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
except (ha_views.HAError, ValueError, RuntimeError) as exc:
return _json_error(exc)
@app.get('/api/ha/temperatures')
def ha_temperatures():
try:return jsonify({'items':ha_views.get_temperatures()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/temperature/history')
def ha_temperature_history():
try:
eid=request.args.get('entity_id','')
hours=int(request.args.get('hours','24'))
return jsonify({'items':ha_views.temperature_history(eid,hours)})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/cameras')
def ha_cameras():
try:return jsonify({'items':ha_views.list_cameras()})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
def ha_camera_snapshot(entity_id):
try:
binary,mime=ha_views.camera_snapshot(entity_id)
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
@app.post('/api/ui/home')
def ui_home():
with STATE_LOCK:
STATE['content']={'type':'home','text':'À ton écoute.'}
socketio.emit('alfred_render',STATE['content'])
return jsonify({'ok':True})
@app.post("/api/audio-level")
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
@app.get("/api/wake/status")
def wake_status(): return jsonify(wake.status())
@app.post("/api/wake/start")
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/wake/stop")
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
@app.post("/api/tts/say")
def tts_say():
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
def _alfred_dashboard_quick_command(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _run():
try:
wake.pause()
with STATE_LOCK:
_image_revision_before = IMAGE_REVISION
emit_state('thinking', transcript=prompt, partial='',
content={'type':'text', 'text':prompt})
_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=lambda: (_done.set(), socketio.emit('alfred_tts_finished', {'source':'voice'})))
_done.wait(timeout=120)
emit_state('idle', partial='')
except Exception as _exc:
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
emit_state('error', content={'type':'text', 'text':str(_exc)})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
return True
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
# ALFRED_ADMIN_GLASS_MAISON_V1_START
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
_alfred_register_home_glass(app)
# ALFRED_ADMIN_GLASS_MAISON_V1_END
# ALFRED_LISTES_GLASS_V1_BACKEND_START
from services.alfred_lists import register_lists as _alfred_register_lists
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
_alfred_register_lists(app, socketio)
# ALFRED_LISTES_GLASS_V1_BACKEND_END
# ALFRED_GLASS_V2_BACKEND_START
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
_alfred_register_logs_v2(app)
# ALFRED_GLASS_V2_BACKEND_END
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
_alfred_identity_register(app)
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
_alfred_developer_register(app)
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
_register_hermes_new_session(app)
# DONA_FAMILY_UI_V1_START
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
def _dona_submit_typed(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _dona_run_typed():
try:
wake.pause()
with STATE_LOCK:
_before_image = IMAGE_REVISION
emit_state('thinking', partial='', transcript=prompt,
content={'type': 'text', 'text': prompt})
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
if _dona_vision_v95.is_camera_question(prompt):
try:
_vision_answer=_dona_vision_monitor.analyze(prompt)
except Exception as _vision_exc:
_vision_answer=str(_vision_exc)[:190] if isinstance(_vision_exc,RuntimeError) else 'Analyse visuelle indisponible.'
with STATE_LOCK:
STATE['content']={'type':'text','text':_vision_answer}
socketio.emit('alfred_render',STATE['content'])
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_vision_answer))
emit_state('idle',partial='')
return
# DONA_FILES_V91 : question écrite à propos de la dernière pièce jointe.
if _dona_files_v91.is_file_question(prompt):
_dona_files_v91.handle_voice(prompt, socketio, STATE, STATE_LOCK)
emit_state('idle',partial='')
return
# DONA_SUITE_V9: commandes quotidiennes écrites, sans perturber Hermes.
if _dona_suite_v9.is_request(prompt):
_v93_local=_dona_suite_v9.handle_and_emit(prompt,socketio)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_NAV_V83: commande écrite navigation, sans polluer la session Hermes.
if _dona_nav.is_request(prompt):
_v93_local=_dona_nav.handle_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
# DONA_YOUTUBE_V82 : vidéos et commandes écrites hors conversation Hermes.
if _dona_youtube.is_search(prompt):
_v93_local=_dona_youtube.search_and_emit(prompt,socketio,STATE,STATE_LOCK)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_v93_local))
emit_state('idle',partial='')
return
if _dona_youtube.is_control(prompt,_dona_youtube.player_active()):
_dona_youtube.control_and_emit(prompt,socketio)
emit_state('idle',partial='')
return
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
if _dona_dynamic.is_dynamic_request(prompt):
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
_payload = {'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content'] = _payload
socketio.emit('alfred_render',_payload)
emit_state('idle', partial='')
return
_answer = ask_hermes(_dona_learning_v93.enhance_prompt(prompt))
if isinstance(_answer, dict):
_speech = str(_answer.get('speech') or _answer.get('text') or '')
_display = _answer.get('display') or {'type':'text','text':_speech}
else:
_speech = str(_answer or '')
_display = {'type':'text','text':_speech}
with STATE_LOCK:
if IMAGE_REVISION == _before_image:
STATE['content'] = _display
socketio.emit('alfred_render', _display)
# DONA_LEARNING_V93 : évaluation de l’échange écrit.
socketio.emit('dona_learning_turn',
_dona_learning_v93.track('typed',prompt,_speech))
# Deliberately no speak_async(): typed interaction is silent.
emit_state('idle', partial='')
except Exception as exc:
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, str(exc), flush=True)
import traceback
traceback.print_exc()
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
return True
from services.dona_family_ui import register_dona_family as _dona_register_family
_dona_register_family(app, adult_command=_dona_submit_typed)
# DONA_FAMILY_UI_V1_END
# DONA_V8_DYNAMIC — tableau de bord choisi, généré et rendu en composants contrôlés.
from services import dona_dynamic as _dona_dynamic
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
from services import dona_dashboard_agent as _dona_dashboard_agent
def _dona_dynamic_submit(prompt):
if not conversation_lock.acquire(blocking=False):
return False
def _work():
try:
wake.pause()
emit_state('thinking',transcript=prompt,partial='')
_board=_dona_dynamic.build_dashboard(prompt,_dona_dashboard_agent.ask_dashboard)
_payload={'type':'dona_dynamic','dashboard':_board}
with STATE_LOCK:
STATE['content']=_payload
socketio.emit('alfred_render',_payload)
emit_state('idle',partial='')
except Exception as exc:
print('[DONA DYNAMIC V8] ',type(exc).__name__,str(exc),flush=True)
emit_state('error',content={'type':'text','text':'Impossible de préparer ce tableau de bord.'})
finally:
conversation_lock.release()
if not audio_settings.mic_muted():
wake.resume()
threading.Thread(target=_work,daemon=True,name='dona-dynamic-v8').start()
return True
_dona_dynamic.register_dynamic(app,_dona_dynamic_submit)
# DONA_VISION_V95 : API vision/YOLOv8, surveillance opt-in.
from services import dona_vision_v95 as _dona_vision_v95
_dona_vision_monitor = _dona_vision_v95.register(app, socketio)
# DONA_LEARNING_V93 : retours explicites et corrections approuvées.
from services import dona_learning_v93 as _dona_learning_v93
_dona_learning_v93.register(app)
# DONA_UI_V92 : préférences de raccourcis persistantes, menus gérés côté navigateur.
from services import dona_shortcuts_v92 as _dona_shortcuts_v92
_dona_shortcuts_v92.register(app)
# DONA_FILES_V91 : fichiers temporaires et analyse Hermes isolée.
from services import dona_files_v91 as _dona_files_v91
_dona_files_v91.register_files(app)
# DONA_SUITE_V9 : routeur local, cockpit, mémoire, cuisine, documents, routines.
from services import dona_suite_v9 as _dona_suite_v9
_dona_suite_v9.register_suite(app,socketio)
# DONA_NAV_V83: géocodage, carte et transfert Waze.
from services import dona_navigation as _dona_nav
_dona_nav.register_navigation(app,socketio)
# DONA_YOUTUBE_V82 : lecture publique, sans clé API, moteur Hermes isolé.
from services import dona_youtube as _dona_youtube
_dona_youtube.register_youtube(app,socketio)
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
_register_dona_spotify(app)
# DONA_V7_RELEASE : route manuelle caméra + MCP existant; aucune base de visage modifiée.
from services.dona_face import (register_face as _dona_register_face,
recognize_once as _dona_face_recognize, is_face_request as _dona_face_request)
_dona_register_face(app, socketio, ask_hermes, conversation_lock)
if __name__ == "__main__":
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
@@ -0,0 +1,152 @@
import asyncio
import os
import subprocess
import tempfile
import time
import threading # ALFRED_DUPLEX_BARGE_IN_V1
import wave
from wyoming.audio import AudioChunk, AudioStart, AudioStop
from wyoming.client import AsyncTcpClient
from wyoming.tts import Synthesize, SynthesizeVoice
PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1")
PIPER_PORT = int(os.getenv("PIPER_PORT", "10200"))
AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0")
async def synthesize(text, wav_path):
client = AsyncTcpClient(PIPER_HOST, PIPER_PORT)
await client.connect()
try:
await client.write_event(
Synthesize(
text=text,
voice=SynthesizeVoice(
name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium")
)).event()
)
wav_file = None
try:
while True:
event = await asyncio.wait_for(
client.read_event(),
timeout=30
)
if event is None:
raise RuntimeError("Connexion Piper interrompue")
if AudioStart.is_type(event.type):
start = AudioStart.from_event(event)
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(start.channels)
wav_file.setsampwidth(start.width)
wav_file.setframerate(start.rate)
elif AudioChunk.is_type(event.type):
chunk = AudioChunk.from_event(event)
if wav_file is None:
wav_file = wave.open(wav_path, "wb")
wav_file.setnchannels(chunk.channels)
wav_file.setsampwidth(chunk.width)
wav_file.setframerate(chunk.rate)
wav_file.writeframes(chunk.audio)
elif AudioStop.is_type(event.type):
break
finally:
if wav_file is not None:
wav_file.close()
finally:
await client.disconnect()
# ALFRED_DUPLEX_BARGE_IN_V1: control only this module's aplay process.
_playback_lock = threading.RLock()
_playback_cancel = threading.Event()
_playback_proc = None
def stop_playback():
"""Interrupts Piper playback or marks pending synth for cancellation."""
global _playback_proc
_playback_cancel.set()
with _playback_lock:
process = _playback_proc
if process is not None and process.poll() is None:
try:
process.terminate()
except OSError:
pass
def speak(text):
if not text or not text.strip():
return
_playback_cancel.clear() # ALFRED_DUPLEX_BARGE_IN_V1
fd, wav_path = tempfile.mkstemp(
prefix="alfred_piper_",
suffix=".wav"
)
os.close(fd)
start = time.monotonic()
try:
print("[ALFRED PIPER] Synthèse...", flush=True)
asyncio.run(synthesize(text, wav_path))
print("[ALFRED PIPER] Lecture...", flush=True)
# ALFRED_DUPLEX_BARGE_IN_V1: allow a concurrent microphone detector
# to terminate playback, without killing Hermes or the entire service.
global _playback_proc
if _playback_cancel.is_set():
print("[ALFRED PIPER] Annulé avant lecture", flush=True)
return
proc = subprocess.Popen(["aplay", "-q", "-D", AUDIO_DEVICE, wav_path])
with _playback_lock:
_playback_proc = proc
if _playback_cancel.is_set() and proc.poll() is None:
proc.terminate()
try:
result = proc.wait(timeout=120)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
raise
finally:
with _playback_lock:
if _playback_proc is proc:
_playback_proc = None
if _playback_cancel.is_set():
print("[ALFRED PIPER] Lecture interrompue par la voix", flush=True)
return
if result:
raise subprocess.CalledProcessError(result, proc.args)
print(
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
flush=True
)
except Exception as e:
print(f"[ALFRED PIPER] Erreur : {e}", flush=True)
raise
finally:
if os.path.exists(wav_path):
os.remove(wav_path)
@@ -0,0 +1,104 @@
/* 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);
// Minimal permanent one-row text field: no expandable tray, send at right.
const compose=document.createElement('div');compose.className='dona-compose dona-compose-compact';
compose.innerHTML=`<form class="dona-compose-body" id="donaComposeBody">
<label class="dona-sr-only" for="donaComposeInput">Écrire à Dona</label>
<div class="dona-compose-row"><textarea id="donaComposeInput" maxlength="1600" rows="1" placeholder="Écrire à Dona…" aria-label="Écrire à Dona" required></textarea><button id="donaComposeSend" type="submit" aria-label="Envoyer la demande" title="Envoyer">➤</button></div>
<span id="donaComposeStatus" role="status" aria-live="polite"></span>
</form>`;
root.append(compose);
const body=$('donaComposeBody'),input=$('donaComposeInput'),send=$('donaComposeSend'),status=$('donaComposeStatus');
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,258 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#202326">
<title>{{ name }}</title>
<link rel="stylesheet" href="/static/css/alfred.css">
<link rel="stylesheet" href="/static/css/alfred-controls.css">
<link rel="stylesheet" href="/static/css/alfred-views.css">
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
<!-- ALFRED_MAIN_VISUAL_V1 -->
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
<!-- ALFRED_LISTES_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
<!-- ALFRED_GLASS_V2 -->
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
<!-- ALFRED_REPONSE_CLOSE_V1 -->
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
<!-- DONA_HOLO_V1 -->
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
<!-- DONA_FAMILY_UI_V1 -->
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
<!-- DONA_V7_RELEASE -->
<link rel="stylesheet" href="/static/css/dona-v7.css?v=7">
<!-- DONA_V8_DYNAMIC -->
<link rel="stylesheet" href="/static/css/dona-dynamic-v8.css?v=8">
<!-- DONA_V8_1_ISOLATION -->
<link rel="stylesheet" href="/static/css/dona-header-v8-1.css?v=81">
<!-- DONA_YOUTUBE_V82 -->
<link rel="stylesheet" href="/static/css/dona-youtube-v8-2.css?v=82">
<!-- DONA_NAV_V83 -->
<link rel="stylesheet" href="/static/css/dona-navigation-v8-3.css?v=83">
<!-- DONA_SUITE_V9 -->
<link rel="stylesheet" href="/static/css/dona-suite-v9.css?v=90">
<!-- DONA_FILES_V91 -->
<link rel="stylesheet" href="/static/css/dona-files-v9-1.css?v=91">
<!-- DONA_UI_V92 -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2.css?v=92">
<!-- DONA_UI_V921_CLOSE -->
<link rel="stylesheet" href="/static/css/dona-ui-v9-2-1-close.css?v=921">
<!-- DONA_LEARNING_V93 -->
<link rel="stylesheet" href="/static/css/dona-learning-v9-3.css?v=93">
<!-- DONA_VISION_V95 -->
<link rel="stylesheet" href="/static/css/dona-vision-v9-5.css?v=95">
</head>
<body>
<main class="alfred-shell">
<header class="topbar">
<!-- Le statut remplace l'ancien titre « Alfred ». -->
<div class="status" role="status" aria-live="polite">
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
<span id="mode" data-mode="idle">En attente</span>
</div>
<div class="header-actions" aria-label="Navigation et commandes audio">
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
<!-- ALFRED_CAMERA_POPUP_V1 -->
<button id="cameraPopupButton" class="icon-button" type="button"
title="Afficher la caméra" aria-label="Afficher la caméra"
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
<path d="m16.5 10 5-3v10l-5-3"/>
</svg>
</button>
<button id="audioSettingsButton" class="icon-button" type="button"
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
</svg>
</button>
<button id="micMute" class="icon-button" type="button"
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
</svg>
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
</svg>
</button>
<button id="speakerMuteQuick" class="icon-button" type="button"
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
</svg>
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
</svg>
</button>
</div>
</header>
<!-- Pop-up : commandes connectées à /api/audio/settings -->
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
<div class="popover-heading">
<strong>Réglages</strong>
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
</div>
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
</div>
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
<div class="setting-row">
<label for="volumeSlider">Volume du haut-parleur</label>
<output id="volumeValue" for="volumeSlider">70 %</output>
</div>
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
<div class="setting-row">
<label for="micGainSlider">Sensibilité du microphone</label>
<output id="micGainValue" for="micGainSlider">—</output>
</div>
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
<div class="camera-settings-actions">
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
</div>
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
</div>
</div>
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
<div class="camera-popup-frame">
<div class="camera-popup-titlebar">
<strong class="camera-popup-title">Caméra en direct</strong>
<button id="cameraPopupClose" class="camera-popup-close" type="button"
aria-label="Fermer la caméra" title="Fermer">×</button>
</div>
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
</div>
<div class="camera-viewport">
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
<div id="cameraPlaceholder" class="camera-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
</svg>
<span id="cameraMessage">Caméra désactivée</span>
</div>
</div>
</aside>
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
</div>
</div>
<section id="stage" aria-label="Réponse d'Alfred">
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
</section>
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
<aside class="transcript-panel" aria-label="Transcription vocale">
<div class="panel-heading">
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
<span id="transcriptState" class="transcript-state">En attente</span>
</div>
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
</aside>
</main>
<script src="/static/js/alfred.js"></script>
<script src="/static/js/alfred-views.js"></script>
<script src="/static/js/alfred-controls.js"></script>
<script src="/static/js/alfred-camera.js"></script>
<script src="/static/js/alfred-camera-settings.js"></script>
<script src="/static/js/alfred-camera-popup.js"></script>
<script defer src="/static/js/alfred-features.js?v=45"></script>
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
<div id="alfredTimersList"></div>
</aside>
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
aria-modal="true" hidden>
<article class="alfred-context-card">
<button id="alfredContextClose" class="alfred-context-close" type="button"
aria-label="Fermer l'illustration" title="Fermer">×</button>
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
<div class="alfred-context-details">
<h2 id="alfredContextTitle"></h2>
<p id="alfredContextText"></p>
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
</div>
</article>
</div>
<script src="/static/js/alfred-timers-context.js"></script>
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
<script src="/static/js/alfred-main-v1.js?v=1"></script>
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
<script src="/static/js/alfred-maison-card.js?v=1"></script>
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
<script src="/static/js/alfred-main-v2.js?v=2"></script>
<!-- ALFRED_IDENTITY_GLASS_V1 -->
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
<!-- ALFRED_DEVELOPER_V1 -->
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
<script defer src="/static/js/dona-reply-auto-close-v7.js?v=7"></script>
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
<script defer src="/static/js/dona-spotify.js?v=6"></script>
<!-- dona-ui-fix start -->
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
<!-- dona-ui-fix end -->
<script defer src="/static/js/dona-face-v7.js?v=7"></script>
<script defer src="/static/js/dona-dynamic-v8.js?v=8"></script>
<script defer src="/static/js/dona-header-v8-1.js?v=81"></script>
<script defer src="/static/js/dona-youtube-v8-2.js?v=82"></script>
<script defer src="/static/js/dona-navigation-v8-3.js?v=83"></script>
<script defer src="/static/js/dona-suite-v9.js?v=90"></script>
<script defer src="/static/js/dona-files-v9-1.js?v=91"></script>
<script defer src="/static/js/dona-ui-v9-2.js?v=92"></script>
<script defer src="/static/js/dona-ui-v9-2-1-close.js?v=921"></script>
<script defer src="/static/js/dona-learning-v9-3.js?v=93"></script>
<script defer src="/static/js/dona-vision-v9-5.js?v=95"></script>
</body>
</html>