debut illustration
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": "DONA_UNIFIED_ROUTING_V4",
|
||||
"files": {
|
||||
"app.py": {
|
||||
"old": "5905244f51a8d3ad639444bd9a204a63d49c0daef4c5c67a754e82752ee98ecb",
|
||||
"new": "bf6466ec54c06500bbcfb79a0e2bd986134edb254855c8123bb64ab30af00fc2",
|
||||
"mode": 420
|
||||
},
|
||||
"services/hermes_client.py": {
|
||||
"old": "39736acf8e8ec4d109207f0592616f2225ee33fe0041f7fe476cdadf9362ad78",
|
||||
"new": "7cdf8b45601a1e7b2c26423acfe8af002df1c19d649cdb84c73e505d41fa46cb",
|
||||
"mode": 420
|
||||
},
|
||||
"static/js/dona-dynamic-v8.js": {
|
||||
"old": "9f5762e6f6b2dfd7f1924261259a2f90e77ceb067c83f323ab55a301ea62a49d",
|
||||
"new": "4e231a7a8227bd379cfda43d4f7977d011ac507243ebe155c3b92f47b170ed83",
|
||||
"mode": 420
|
||||
},
|
||||
"services/dona_unified_router_v4.py": {
|
||||
"old": null,
|
||||
"new": "5ffc53c57fd0fc9ce6ff9116e53e156236aff737abd6bfa86a7a53cd08b8f48d",
|
||||
"mode": 420
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
|
||||
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_HERMES_PERF_V1: progression non bloquante des réponses Hermes
|
||||
from services.dona_hermes_stream import configure_emitter as _dona_stream_emitter
|
||||
_dona_stream_emitter(lambda event: socketio.emit("dona_hermes_stream", event))
|
||||
# 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) or _dona_json_autorender.is_monitor_request(r.text):
|
||||
_board = _dona_dynamic.build_dashboard(r.text, _dona_dashboard_agent.ask_dashboard)
|
||||
socketio.emit('dona_learning_turn',_dona_learning_v93.track('voice',_dona_v93_original_vosk,_board.get('title','Tableau de bord')))
|
||||
_payload = {'type':'dona_dynamic', 'dashboard':_board}
|
||||
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}
|
||||
display, speech = _dona_json_autorender.convert(answer, r.text, display, speech)
|
||||
|
||||
# DONA_LEARNING_V93 : évaluation du tour Hermes, RAM seulement avant 👍 / 👎.
|
||||
socketio.emit('dona_learning_turn',
|
||||
_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})
|
||||
# DONA_JSON_AUTORENDER_V1: operational shortcuts use the isolated
|
||||
# dashboard session; ordinary shortcuts remain regular Hermes calls.
|
||||
if _dona_dynamic.is_dynamic_request(prompt) or _dona_json_autorender.is_monitor_request(prompt):
|
||||
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
|
||||
_display = {'type':'dona_dynamic','dashboard':_board}
|
||||
_speech = 'Voici le tableau de bord : ' + _board['title']
|
||||
else:
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_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.
|
||||
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) or _dona_json_autorender.is_monitor_request(prompt):
|
||||
_board = _dona_dynamic.build_dashboard(prompt, _dona_dashboard_agent.ask_dashboard)
|
||||
socketio.emit('dona_learning_turn',_dona_learning_v93.track('typed',prompt,_board.get('title','Tableau de bord')))
|
||||
_payload = {'type':'dona_dynamic','dashboard':_board}
|
||||
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}
|
||||
_display, _speech = _dona_json_autorender.convert(_answer, prompt, _display, _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_JSON_AUTORENDER_V1 : rendu automatique JSON Hermes, demandes et raccourcis
|
||||
from services import dona_json_autorender_v1 as _dona_json_autorender
|
||||
# DONA_V8_1_ISOLATION : tableaux isolés des conversations normales
|
||||
from services import dona_dashboard_agent as _dona_dashboard_agent
|
||||
|
||||
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)
|
||||
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
|
||||
"""Pont Alfred -> Hermes avec session persistante."""
|
||||
from services.dona_hermes_stream import run_jsonl_command as _dona_run_jsonl_command # DONA_HERMES_PERF_V1
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION
|
||||
# --------------------------------------------------
|
||||
|
||||
DEFAULT_IDENTITY_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "identity.md"
|
||||
)
|
||||
|
||||
DEFAULT_SESSION_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "hermes_session.json"
|
||||
)
|
||||
|
||||
_REQUEST_LOCK = threading.Lock()
|
||||
|
||||
_ID_PATTERN = re.compile(
|
||||
r"^[A-Za-z0-9_:.\-]{1,128}$"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# IDENTITÉ D'ALFRED
|
||||
# --------------------------------------------------
|
||||
|
||||
def _build_prompt(question: str) -> str:
|
||||
"""Send only the real user utterance. Personality is configured in Hermes SOUL.md.
|
||||
|
||||
The previous identity.md wrapper caused absent/unknown-tool decisions in
|
||||
both Spotify and Home Assistant. Never inject it into each user turn.
|
||||
"""
|
||||
return str(question or '').strip()
|
||||
|
||||
|
||||
def _toolsets_for(question: str) -> str | None:
|
||||
"""Conservative domain routing to avoid huge MCP schemas on simple turns.
|
||||
|
||||
Cross-domain, ambiguous, or generic requests retain Hermes' full toolset.
|
||||
Set HERMES_TOOLSET_ROUTING=off for instant rollback without code changes.
|
||||
"""
|
||||
if os.getenv('HERMES_TOOLSET_ROUTING', 'on').lower() not in ('on','1','true','yes'):
|
||||
return None
|
||||
q = str(question or '').casefold()
|
||||
domains = {
|
||||
'spotify': (r'\bspotify\b|\bplaylist\b|\bmusique\b|\bmorceau\b|\bchanson\b|\balbum\b|\bspotify connect\b'),
|
||||
'homeassistant': (r'\blumi[eè]res?\b|\blampes?\b|\bvolets?\b|\bdomotique\b|\bthermostat\b|\bchauffage\b|\bprises?\b|\bhome assistant\b|\ballume\b|\b[eé]teins?\b'),
|
||||
'face_recognition': (r'\bface_recognition\b|\bidentify_people\b|\breconnaissance faciale\b'), # DONA_V7_RELEASE
|
||||
'proxmox': (r'\bproxmox\b|\bcluster proxmox\b'), # DONA_V8_DYNAMIC
|
||||
'wazuh': (r'\bwazuh\b'),
|
||||
}
|
||||
import re as _re
|
||||
matching = [domain for domain, pattern in domains.items() if _re.search(pattern, q)]
|
||||
# Combined or potentially compound queries keep full access to every MCP.
|
||||
if len(matching) != 1:
|
||||
return None
|
||||
if _re.search(r'\b(et aussi|en m[eê]me temps|puis v[eé]rifie|compare avec|cherche sur|envoie|par mail|gmail|calendrier|agenda|comfyui|document|paperless)\b', q):
|
||||
return None
|
||||
return {'spotify':'spotify', 'homeassistant':'homeassistant,home_assistant', 'face_recognition':'face_recognition', 'proxmox':'proxmox', 'wazuh':'wazuh'}[matching[0]]
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FICHIER DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _session_file() -> Path:
|
||||
|
||||
return Path(
|
||||
os.getenv(
|
||||
"HERMES_SESSION_FILE",
|
||||
str(DEFAULT_SESSION_FILE)
|
||||
)
|
||||
).expanduser()
|
||||
|
||||
|
||||
def _read_session(path: Path) -> str | None:
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
obj = json.loads(
|
||||
path.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
sid = obj["session_id"]
|
||||
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError
|
||||
) as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"État de session Hermes invalide "
|
||||
f"({path}) : {exc}"
|
||||
) from exc
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Identifiant de session Hermes "
|
||||
f"invalide dans {path}"
|
||||
)
|
||||
|
||||
return sid
|
||||
|
||||
|
||||
def _save_session(path: Path, sid: str):
|
||||
|
||||
if not _ID_PATTERN.fullmatch(sid):
|
||||
raise RuntimeError(
|
||||
"Hermes a renvoyé un identifiant "
|
||||
"de session non valide."
|
||||
)
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
prefix=".hermes_session_",
|
||||
suffix=".tmp",
|
||||
dir=path.parent
|
||||
)
|
||||
|
||||
temp_path = Path(temp_name)
|
||||
|
||||
with os.fdopen(
|
||||
fd,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as handle:
|
||||
|
||||
json.dump(
|
||||
{"session_id": sid},
|
||||
handle,
|
||||
indent=2
|
||||
)
|
||||
|
||||
handle.write("\n")
|
||||
|
||||
os.replace(temp_path, path)
|
||||
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# VERROUILLAGE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def _session_lock(path: Path):
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
lock_path = path.with_name(
|
||||
path.name + ".lock"
|
||||
)
|
||||
|
||||
fd = os.open(
|
||||
lock_path,
|
||||
os.O_CREAT | os.O_RDWR,
|
||||
0o600
|
||||
)
|
||||
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_EX
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_UN
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION HERMES
|
||||
# --------------------------------------------------
|
||||
|
||||
def _hermes_binary() -> str:
|
||||
|
||||
return os.getenv(
|
||||
"HERMES_CLI",
|
||||
"/root/.local/bin/hermes"
|
||||
)
|
||||
|
||||
|
||||
def _hermes_timeout() -> float:
|
||||
|
||||
return max(
|
||||
10.0,
|
||||
float(
|
||||
os.getenv("HERMES_TIMEOUT", "180")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# ANCIEN MODE SANS SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _oneshot_legacy(prompt: str) -> str:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_hermes_binary(),
|
||||
"--oneshot",
|
||||
prompt
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=True
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
details = (
|
||||
exc.stderr
|
||||
or exc.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {exc.returncode}): {details}"
|
||||
) from exc
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# INTERPRÉTATION RÉPONSE JSONL
|
||||
# --------------------------------------------------
|
||||
|
||||
def _parse_jsonl(stdout: str) -> tuple[str, str]:
|
||||
|
||||
final = None
|
||||
initial_session_id = None
|
||||
|
||||
for line in stdout.splitlines():
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
if (
|
||||
event.get("type") == "system"
|
||||
and event.get("subtype") == "init"
|
||||
):
|
||||
initial_session_id = event.get(
|
||||
"session_id"
|
||||
)
|
||||
|
||||
if event.get("type") == "result":
|
||||
final = event
|
||||
|
||||
if final is None:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas renvoyé de résultat "
|
||||
"JSONL. Vérifie la prise en charge "
|
||||
"de --format stream-json."
|
||||
)
|
||||
|
||||
if final.get("exit_code", 0) != 0:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes : "
|
||||
f"{final.get('error') or 'échec'}"
|
||||
)
|
||||
|
||||
sid = (
|
||||
final.get("session_id")
|
||||
or initial_session_id
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas fourni "
|
||||
"d'identifiant de session utilisable."
|
||||
)
|
||||
|
||||
answer = final.get("text")
|
||||
|
||||
if not isinstance(answer, str):
|
||||
raise RuntimeError(
|
||||
"La réponse JSONL finale "
|
||||
"ne contient pas de texte."
|
||||
)
|
||||
|
||||
return answer.strip(), sid
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# APPEL HERMES AVEC REPRISE DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _call_hermes(
|
||||
prompt: str,
|
||||
session_id: str | None
|
||||
) -> tuple[str, str]:
|
||||
|
||||
cmd = [
|
||||
_hermes_binary(),
|
||||
"chat",
|
||||
"--oneshot",
|
||||
"--query-file",
|
||||
"-",
|
||||
"--format",
|
||||
"stream-json",
|
||||
"--source",
|
||||
"cli"
|
||||
]
|
||||
|
||||
route = _toolsets_for(prompt)
|
||||
if route and not session_id:
|
||||
cmd.extend(["--toolsets", route])
|
||||
|
||||
if session_id:
|
||||
cmd.extend([
|
||||
"--resume",
|
||||
session_id
|
||||
])
|
||||
|
||||
try:
|
||||
# DONA_HERMES_PERF_V1: conserver la session, transmettre les deltas en temps réel
|
||||
process = _dona_run_jsonl_command(
|
||||
cmd, prompt, _hermes_timeout()
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except FileNotFoundError as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes introuvable : "
|
||||
f"{_hermes_binary()}"
|
||||
) from exc
|
||||
|
||||
if process.returncode != 0:
|
||||
|
||||
msg = (
|
||||
process.stderr
|
||||
or process.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
if len(msg) > 1500:
|
||||
msg = msg[-1500:]
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {process.returncode}) : "
|
||||
f"{msg}"
|
||||
)
|
||||
|
||||
return _parse_jsonl(process.stdout)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FONCTION PRINCIPALE APPELÉE PAR FLASK
|
||||
# --------------------------------------------------
|
||||
|
||||
def ask_hermes(text: str) -> str:
|
||||
|
||||
prompt = _build_prompt(text)
|
||||
|
||||
# Ancien fonctionnement sans session
|
||||
if (
|
||||
os.getenv(
|
||||
"HERMES_SESSION_MODE",
|
||||
"persistent"
|
||||
).lower() == "legacy"
|
||||
):
|
||||
return _oneshot_legacy(prompt)
|
||||
|
||||
base_path = _session_file()
|
||||
route = _toolsets_for(prompt)
|
||||
|
||||
if route:
|
||||
safe_route = re.sub(
|
||||
r"[^a-z0-9]+", "_", route.lower()
|
||||
).strip("_")
|
||||
path = base_path.with_name(
|
||||
f"{base_path.stem}.{safe_route}{base_path.suffix}"
|
||||
)
|
||||
else:
|
||||
path = base_path
|
||||
|
||||
# Empêche les appels concurrents
|
||||
with _REQUEST_LOCK, _session_lock(path):
|
||||
|
||||
# Recherche la session précédente
|
||||
current_sid = _read_session(path)
|
||||
|
||||
# Envoie la question à Hermes
|
||||
answer, new_sid = _call_hermes(
|
||||
prompt,
|
||||
current_sid
|
||||
)
|
||||
|
||||
# Sauvegarde l'identifiant
|
||||
_save_session(
|
||||
path,
|
||||
new_sid
|
||||
)
|
||||
|
||||
print(
|
||||
f"[ALFRED] Session Hermes : "
|
||||
f"{new_sid} "
|
||||
f"({'reprise' if current_sid else 'création'})",
|
||||
flush=True
|
||||
)
|
||||
|
||||
return answer
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
/* DONA_V8_DYNAMIC — data-only dashboards. All user/model text is textContent, never HTML. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const ID='donaDynamicV8';
|
||||
if(document.getElementById(ID))return;
|
||||
const $=id=>document.getElementById(id);
|
||||
const el=(tag,cls,text)=>{
|
||||
const item=document.createElement(tag);if(cls)item.className=cls;
|
||||
if(text!==undefined)item.textContent=String(text);return item;
|
||||
};
|
||||
const btn=(text,fn,cls='d8-btn')=>{const b=el('button',cls,text);b.type='button';b.addEventListener('click',fn);return b;};
|
||||
let active=null,opened=false,busy=false,ticker=null;
|
||||
// DONA_DYNAMIC_PERF_V2 — HTTP 202 is queue acceptance, not generation completion.
|
||||
let creating=false, createTimer=null;
|
||||
const CREATE_MAX_WAIT_MS=240000;
|
||||
function updateCreationControls(){
|
||||
const root=document.getElementById(ID);if(!root)return;
|
||||
root.querySelectorAll('.d8-submit, .d8-examples button').forEach(button=>{
|
||||
button.disabled=creating;button.setAttribute('aria-disabled',String(creating));
|
||||
});
|
||||
}
|
||||
function finishCreating(){
|
||||
if(createTimer!==null){clearTimeout(createTimer);createTimer=null;}
|
||||
creating=false;updateCreationControls();
|
||||
}
|
||||
function startCreating(){
|
||||
creating=true;updateCreationControls();
|
||||
if(createTimer!==null)clearTimeout(createTimer);
|
||||
createTimer=setTimeout(()=>{
|
||||
finishCreating();
|
||||
if(opened)status('Confirmation non reçue. Tu peux réessayer ; une génération peut encore être en cours côté serveur.');
|
||||
},CREATE_MAX_WAIT_MS);
|
||||
}
|
||||
const KEY='dona.dynamic.pins.v8';
|
||||
const safeGetPins=()=>{
|
||||
try{const pins=JSON.parse(localStorage.getItem(KEY)||'[]');return Array.isArray(pins)?pins.slice(0,8):[];}
|
||||
catch(_){return []}
|
||||
};
|
||||
const safeSavePins=list=>{try{localStorage.setItem(KEY,JSON.stringify(list.slice(0,8)));}catch(_){status('Stockage local indisponible.');}};
|
||||
function status(text){$('d8Status').textContent=text||'';}
|
||||
function makeModal(){
|
||||
const root=el('div','d8-overlay');root.id=ID;root.hidden=true;
|
||||
root.setAttribute('role','dialog');root.setAttribute('aria-modal','true');root.setAttribute('aria-label','Tableaux dynamiques Dona');
|
||||
const panel=el('section','d8-panel');
|
||||
const head=el('header','d8-head');
|
||||
const ident=el('div','d8-brand');ident.append(el('span','d8-eyebrow','DONA · INTERFACE DYNAMIQUE'),el('h1','d8-title','Tableaux de bord'));
|
||||
head.append(ident,btn('×',close,'d8-close'));panel.append(head);
|
||||
const bar=el('form','d8-compose');bar.id='d8Compose';
|
||||
const input=el('input','d8-input');input.id='d8Input';input.required=true;input.maxLength=500;
|
||||
input.placeholder='Ex. Tableau de bord Proxmox, Wazuh, lumières…';input.setAttribute('aria-label','Demande de tableau dynamique');
|
||||
bar.append(input,btn('Créer',()=>{},'d8-submit'));bar.lastChild.type='submit';
|
||||
bar.addEventListener('submit',e=>{e.preventDefault();create(input.value);});panel.append(bar);
|
||||
const examples=el('div','d8-examples');
|
||||
[['Spotify','Tableau de bord Spotify'],['Lumières','Tableau de bord des lumières'],
|
||||
['Températures','Tableau de bord des températures'],['Proxmox','Tableau de bord Proxmox'],
|
||||
['Wazuh','Tableau de bord Wazuh']].forEach(([text,prompt])=>examples.append(btn(text,()=>{input.value=prompt;create(prompt)},'d8-chip')));
|
||||
panel.append(examples);
|
||||
const actions=el('div','d8-toolbar');
|
||||
actions.append(btn('↻ Actualiser',refresh,'d8-btn'),btn('☆ Épingler',pin,'d8-btn'));
|
||||
actions.children[0].id='d8Refresh'; actions.children[1].id='d8Pin';panel.append(actions);
|
||||
const caption=el('div','d8-note');caption.id='d8Status';caption.setAttribute('role','status');caption.setAttribute('aria-live','polite');panel.append(caption);
|
||||
const pinned=el('div','d8-pinned');pinned.id='d8Pinned';panel.append(pinned);
|
||||
const grid=el('main','d8-grid');grid.id='d8Grid';panel.append(grid);
|
||||
const foot=el('p','d8-footer','Données provenant de Spotify, Home Assistant ou des outils Hermes. Aucun code produit par l’IA n’est exécuté.');panel.append(foot);
|
||||
root.append(panel);document.body.append(root);
|
||||
root.addEventListener('click',e=>{if(e.target===root)close()});
|
||||
document.addEventListener('keydown',e=>{if(e.key==='Escape'&&opened)close();});
|
||||
return root;
|
||||
}
|
||||
makeModal();
|
||||
function open(){opened=true;$(ID).hidden=false;document.body.classList.add('d8-open');$('d8Input').focus();renderPins();schedule();}
|
||||
function close(){opened=false;$(ID).hidden=true;document.body.classList.remove('d8-open');stopTimer();}
|
||||
function stopTimer(){if(ticker){clearInterval(ticker);ticker=null;}}
|
||||
function schedule(){stopTimer();if(!opened||!active||!['spotify','homeassistant','lights','proxmox'].includes(active.origin))return;
|
||||
ticker=setInterval(()=>{if(!document.hidden&&!busy)reloadLive(false);},active.origin==='spotify'?15000:30000);
|
||||
}
|
||||
function originLabel(origin){return ({spotify:'Spotify · direct',homeassistant:'Home Assistant · direct',lights:'Home Assistant · direct',hermes:'Hermes · MCP',fallback:'Hermes · réponse texte',proxmox:'Proxmox · API directe' /* DONA_DYNAMIC_PROXMOX_V3 */})[origin]||'Données contextuelles';}
|
||||
const tagText=(parent,tag,cls,text)=>{parent.append(el(tag,cls,text));};
|
||||
function drawCard(c){
|
||||
const section=el('article','d8-card d8-'+c.type);
|
||||
if(c.title)tagText(section,'h2','d8-card-title',c.title);
|
||||
if(c.type==='metric'){
|
||||
const value=el('div','d8-metric-value',c.value??'—');
|
||||
if(c.unit)value.append(el('small','d8-unit',c.unit));section.append(value);
|
||||
if(c.detail)tagText(section,'p','d8-detail',c.detail);
|
||||
} else if(c.type==='text'){
|
||||
tagText(section,'p','d8-text',c.text||'—');
|
||||
} else if(c.type==='list'){
|
||||
const list=el('ul','d8-list');(c.items||[]).forEach(x=>list.append(el('li','',x)));
|
||||
if(!list.childNodes.length)list.append(el('li','','Aucune donnée.'));section.append(list);
|
||||
} else if(c.type==='table'){
|
||||
const scroll=el('div','d8-table-scroll'),table=el('table','d8-table'),thead=el('thead'),headrow=el('tr');
|
||||
(c.columns||[]).forEach(x=>headrow.append(el('th','',x)));thead.append(headrow);table.append(thead);
|
||||
const tbody=el('tbody');(c.rows||[]).forEach(r=>{const tr=el('tr');(r||[]).forEach(v=>tr.append(el('td','',v)));tbody.append(tr);});
|
||||
table.append(tbody);scroll.append(table);section.append(scroll);
|
||||
} else if(c.type==='bars'){
|
||||
const values=(c.items||[]).map(x=>Number(x.value)).filter(x=>Number.isFinite(x));
|
||||
const max=Math.max(1,...values.map(x=>Math.abs(x)));
|
||||
(c.items||[]).forEach(item=>{
|
||||
const row=el('div','d8-bar-row'),line=el('div','d8-bar-line');
|
||||
line.append(el('span','',item.label),el('strong','',String(item.value)+(item.unit?' '+item.unit:'')));
|
||||
const track=el('div','d8-track'),fill=el('div','d8-fill');
|
||||
fill.style.width=Math.max(0,Math.min(100,Math.abs(Number(item.value))/max*100))+'%';
|
||||
track.append(fill);row.append(line,track);section.append(row);
|
||||
});
|
||||
} else if(c.type==='light' && active?.origin==='lights'){
|
||||
const state=String(c.state||'unknown');tagText(section,'p','d8-light-state',state==='on'?'Allumée':state==='off'?'Éteinte':'Indisponible');
|
||||
if(state==='on'||state==='off'){
|
||||
const b=btn(state==='on'?'Éteindre':'Allumer',()=>toggleLight(c.entity_id,c.title),'d8-btn d8-action');section.append(b);
|
||||
}
|
||||
}
|
||||
return section;
|
||||
}
|
||||
function render(board){
|
||||
if(!board||board.schema!=='dona.dashboard.v1'||!Array.isArray(board.cards))return;
|
||||
active=board;open();
|
||||
$('d8Input').value=board.prompt||$('d8Input').value;
|
||||
$('d8Grid').replaceChildren();
|
||||
const sub=el('div','d8-heading');sub.append(el('h2','',board.title||'Tableau de bord'));
|
||||
sub.append(el('p','',board.subtitle||originLabel(board.origin)));
|
||||
if(board.generated_at){const d=new Date(board.generated_at);sub.append(el('small','',`Mis à jour : ${Number.isNaN(d.getTime())?'—':d.toLocaleTimeString('fr-FR')}`));}
|
||||
$('d8Grid').append(sub);
|
||||
if(board.origin==='spotify'){
|
||||
const controls=el('div','d8-player-controls');
|
||||
[['⏮ Précédent','previous'],['▶ Lecture','play'],['Ⅱ Pause','pause'],['⏭ Suivant','next']].forEach(([label,action])=>{
|
||||
controls.append(btn(label,async()=>{
|
||||
try{await post('/api/dona/spotify/control',{action});await reloadLive(false);}
|
||||
catch(e){status('Spotify : '+e.message)}
|
||||
},'d8-btn'));
|
||||
});
|
||||
$('d8Grid').append(controls);
|
||||
}
|
||||
board.cards.slice(0,16).forEach(c=>{if(c&&typeof c==='object')$('d8Grid').append(drawCard(c));});
|
||||
status(originLabel(board.origin)+(board.origin==='hermes'?' · contenu communiqué par l’agent':''));
|
||||
renderPins();schedule();
|
||||
}
|
||||
function renderPins(){
|
||||
const host=$('d8Pinned');host.replaceChildren();
|
||||
const pins=safeGetPins();if(!pins.length)return;
|
||||
host.append(el('span','d8-pins-label','Épinglés :'));
|
||||
pins.forEach((p,i)=>{
|
||||
const item=el('span','d8-pin-item');
|
||||
item.append(btn(p.title||'Tableau '+(i+1),()=>render(p),'d8-chip'));
|
||||
item.append(btn('×',()=>{const rest=safeGetPins().filter((_,j)=>j!==i);safeSavePins(rest);renderPins();},'d8-pin-remove'));
|
||||
host.append(item);
|
||||
});
|
||||
}
|
||||
function pin(){if(!active){status('Crée d’abord un tableau.');return;}
|
||||
const pins=safeGetPins();pins.unshift(active);safeSavePins(pins);renderPins();status('Tableau épinglé dans ce navigateur.');}
|
||||
async function post(url,body){
|
||||
const res=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(body)});
|
||||
const obj=await res.json().catch(()=>({}));if(!res.ok||obj.ok===false){const error=Error(obj.error||'HTTP '+res.status);error.httpStatus=res.status;throw error;}return obj;
|
||||
}
|
||||
async function create(prompt){
|
||||
const query=String(prompt||'').trim();if(query.length<2||query.length>500){status('Décris le tableau en 2 à 500 caractères.');return;}
|
||||
if(creating){status('Une génération est déjà en cours.');return;}
|
||||
if(busy){status('Une actualisation est en cours.');return;}
|
||||
startCreating();status('Dona prépare le tableau…');
|
||||
try{
|
||||
await post('/api/dona/dynamic/create',{prompt:query});
|
||||
// A fast Socket.IO completion may have already cleared creating.
|
||||
if(creating)status('Dona consulte les données ; la vue apparaîtra ici.');
|
||||
}catch(e){
|
||||
if(e.httpStatus===409){
|
||||
// Another tab or another entry point may have submitted the job.
|
||||
// Wait for its completion event, or release after the safety timeout.
|
||||
status('Une génération est déjà en cours côté serveur. Attente du résultat…');
|
||||
}else{
|
||||
finishCreating();status(e.message||'Demande impossible.');
|
||||
}
|
||||
}
|
||||
}
|
||||
async function reloadLive(notify=true){
|
||||
if(!active)return;
|
||||
if(['spotify','homeassistant','lights','proxmox'].includes(active.origin)){
|
||||
if(busy)return;const current=active;busy=true;
|
||||
try{const r=await fetch('/api/dona/dynamic/live?source='+encodeURIComponent(active.origin),{cache:'no-store',credentials:'same-origin'});
|
||||
const j=await r.json();if(!r.ok||!j.ok)throw Error(j.error||'Source indisponible');
|
||||
if(current===active)render(j.dashboard);
|
||||
}catch(e){status('Actualisation impossible : '+e.message);}finally{busy=false;}
|
||||
} else if(notify && active.prompt){create(active.prompt);}
|
||||
}
|
||||
function refresh(){if(!active){status('Aucun tableau à actualiser.');return;}reloadLive(true);}
|
||||
async function toggleLight(entity,name){
|
||||
if(!active||active.origin!=='lights')return;
|
||||
if(!/^light\.[a-z0-9_]{1,100}$/.test(String(entity)))return;
|
||||
if(!confirm('Changer l’état de « '+String(name).slice(0,100)+' » ?'))return;
|
||||
try{await post('/api/dona/dynamic/lights/toggle',{entity_id:entity});await reloadLive();}
|
||||
catch(e){status('Commande refusée : '+e.message);}
|
||||
}
|
||||
function initButton(){
|
||||
const primary=document.querySelector('#alfredMainV1 .av-head-actions')||document.querySelector('.header-actions');
|
||||
if(!primary)return;
|
||||
if($('d8Open'))return;
|
||||
const b=btn('▦',open,'d8-open-button');b.id='d8Open';b.title='Tableaux dynamiques';b.setAttribute('aria-label','Ouvrir les tableaux de bord dynamiques');
|
||||
primary.prepend(b);
|
||||
}
|
||||
let tries=0;const timer=setInterval(()=>{initButton();if($('d8Open')||++tries>70)clearInterval(timer);},150);
|
||||
const socket=window.alfredSocket;
|
||||
socket?.on('alfred_render',data=>{
|
||||
if(data?.type==='dona_dynamic'){
|
||||
// Only a server result (not a pinned board or HTTP 202) ends the wait.
|
||||
if(data.dashboard?.schema==='dona.dashboard.v1'&&Array.isArray(data.dashboard.cards))finishCreating();
|
||||
render(data.dashboard);
|
||||
}
|
||||
else if(opened && data?.type!=='dona_dynamic')close();
|
||||
});
|
||||
socket?.on('alfred_state',data=>{
|
||||
if(opened && data?.mode==='error'){
|
||||
if(creating)finishCreating();
|
||||
status('Erreur lors de la préparation du tableau.');
|
||||
}
|
||||
});
|
||||
document.addEventListener('visibilitychange',()=>{if(!document.hidden&&opened&&active&&['spotify','homeassistant','lights','proxmox'].includes(active.origin))reloadLive(false);});
|
||||
window.DonaDynamicV8={open,render,close};
|
||||
})();
|
||||
Reference in New Issue
Block a user