v4
This commit is contained in:
+55
-8
@@ -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.piper_tts import stop_playback as duplex_stop_piper
|
||||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||||
from services.hermes_client import ask_hermes
|
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_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||||
from services import alfred_features # ALFRED_FEATURES_V45
|
from services import alfred_features # ALFRED_FEATURES_V45
|
||||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
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)
|
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||||
##
|
##
|
||||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
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={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||||
STATE_LOCK = threading.RLock()
|
STATE_LOCK = threading.RLock()
|
||||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
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}
|
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.
|
# DONA_VISION_V95 : photo locale et compréhension par Hermes sur demande.
|
||||||
if _dona_vision_v95.is_camera_question(r.text):
|
if _dona_vision_v95.is_camera_question(r.text):
|
||||||
try:
|
try:
|
||||||
@@ -200,7 +224,7 @@ def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V
|
|||||||
return
|
return
|
||||||
|
|
||||||
# DONA_V8_DYNAMIC — creation visuelle explicite, source Hermes/MCP ou API directe.
|
# 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)
|
_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')))
|
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}
|
_payload = {'type':'dona_dynamic', 'dashboard':_board}
|
||||||
@@ -288,6 +312,7 @@ def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V
|
|||||||
else:
|
else:
|
||||||
speech = str(answer or "")
|
speech = str(answer or "")
|
||||||
display = {"type": "text", "text": speech}
|
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 👍 / 👎.
|
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
|
||||||
socketio.emit('dona_learning_turn',
|
socketio.emit('dona_learning_turn',
|
||||||
@@ -642,13 +667,21 @@ def _alfred_dashboard_quick_command(prompt):
|
|||||||
_image_revision_before = IMAGE_REVISION
|
_image_revision_before = IMAGE_REVISION
|
||||||
emit_state('thinking', transcript=prompt, partial='',
|
emit_state('thinking', transcript=prompt, partial='',
|
||||||
content={'type':'text', 'text':prompt})
|
content={'type':'text', 'text':prompt})
|
||||||
_answer = ask_hermes(prompt)
|
# DONA_JSON_AUTORENDER_V1: operational shortcuts use the isolated
|
||||||
if isinstance(_answer, dict):
|
# dashboard session; ordinary shortcuts remain regular Hermes calls.
|
||||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
|
||||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
_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:
|
else:
|
||||||
_speech = str(_answer or '')
|
_answer = ask_hermes(prompt)
|
||||||
_display = {'type':'text','text':_speech}
|
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}
|
||||||
|
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
|
||||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||||
with STATE_LOCK:
|
with STATE_LOCK:
|
||||||
if IMAGE_REVISION == _image_revision_before:
|
if IMAGE_REVISION == _image_revision_before:
|
||||||
@@ -722,6 +755,17 @@ def _dona_submit_typed(prompt):
|
|||||||
_before_image = IMAGE_REVISION
|
_before_image = IMAGE_REVISION
|
||||||
emit_state('thinking', partial='', transcript=prompt,
|
emit_state('thinking', partial='', transcript=prompt,
|
||||||
content={'type': 'text', 'text': 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.
|
# DONA_VISION_V95 : demande écrite de vision depuis la caméra réelle.
|
||||||
if _dona_vision_v95.is_camera_question(prompt):
|
if _dona_vision_v95.is_camera_question(prompt):
|
||||||
try:
|
try:
|
||||||
@@ -767,7 +811,7 @@ def _dona_submit_typed(prompt):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# DONA_V8_DYNAMIC — commandes visuelles saisies via le formulaire habituel.
|
# 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)
|
_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')))
|
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
|
||||||
_payload = {'type':'dona_dynamic','dashboard':_board}
|
_payload = {'type':'dona_dynamic','dashboard':_board}
|
||||||
@@ -783,6 +827,7 @@ def _dona_submit_typed(prompt):
|
|||||||
else:
|
else:
|
||||||
_speech = str(_answer or '')
|
_speech = str(_answer or '')
|
||||||
_display = {'type':'text','text':_speech}
|
_display = {'type':'text','text':_speech}
|
||||||
|
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _speech)
|
||||||
with STATE_LOCK:
|
with STATE_LOCK:
|
||||||
if IMAGE_REVISION == _before_image:
|
if IMAGE_REVISION == _before_image:
|
||||||
STATE['content'] = _display
|
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.
|
# 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
|
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
|
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
|
||||||
from services import dona_dashboard_agent as _dona_dashboard_agent
|
from services import dona_dashboard_agent as _dona_dashboard_agent
|
||||||
|
|
||||||
|
|||||||
+116
@@ -1032,5 +1032,121 @@
|
|||||||
},
|
},
|
||||||
"cached_at": 1793428787.6485865,
|
"cached_at": 1793428787.6485865,
|
||||||
"focus_label": "aujourd’hui"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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']
|
||||||
@@ -91,6 +91,58 @@ def stop_playback():
|
|||||||
pass
|
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):
|
def speak(text):
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
return
|
return
|
||||||
@@ -123,6 +175,9 @@ def speak(text):
|
|||||||
_playback_proc = proc
|
_playback_proc = proc
|
||||||
if _playback_cancel.is_set() and proc.poll() is None:
|
if _playback_cancel.is_set() and proc.poll() is None:
|
||||||
proc.terminate()
|
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:
|
try:
|
||||||
result = proc.wait(timeout=120)
|
result = proc.wait(timeout=120)
|
||||||
except subprocess.TimeoutExpired:
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
if(k===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
|
||||||
}ctx.closePath();}
|
}ctx.closePath();}
|
||||||
function frame(now){raf=requestAnimationFrame(frame);if(document.hidden||now-last<30)return;last=now;
|
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;
|
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;
|
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;
|
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();
|
||||||
|
})();
|
||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -51,6 +51,10 @@
|
|||||||
<link rel="stylesheet" href="/static/css/dona-learning-v9-3.css?v=93">
|
<link rel="stylesheet" href="/static/css/dona-learning-v9-3.css?v=93">
|
||||||
<!-- DONA_VISION_V95 -->
|
<!-- DONA_VISION_V95 -->
|
||||||
<link rel="stylesheet" href="/static/css/dona-vision-v9-5.css?v=95">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="alfred-shell">
|
<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-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-learning-v9-3.js?v=93"></script>
|
||||||
<script defer src="/static/js/dona-vision-v9-5.js?v=95"></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>
|
</body>
|
||||||
</html>
|
</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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -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)
|
||||||
|
|
||||||
+22
@@ -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)}
|
||||||
|
}
|
||||||
+165
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+262
@@ -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)
|
||||||
|
|
||||||
+152
@@ -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)
|
||||||
+104
@@ -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();
|
||||||
|
})();
|
||||||
+258
@@ -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>
|
||||||
Reference in New Issue
Block a user