diff --git a/alfred/.env b/alfred/.env index 8bad3f0..0a6460f 100644 --- a/alfred/.env +++ b/alfred/.env @@ -25,3 +25,11 @@ COMFYUI_URL=http://192.168.1.13:8188 WORKFLOWS_DIR=/opt/hermes/MCP_comfyui/workflows COMFY_PROMPT_NODE=6 COMFYUI_TIMEOUT=300 + +ALFRED_ALLOW_WEB_RESTART=true + +STT_ENGINE=wyoming +WYOMING_WHISPER_HOST=192.168.1.13 +WYOMING_WHISPER_PORT=10300 +WYOMING_WHISPER_LANGUAGE=fr +WYOMING_WHISPER_TIMEOUT=20 diff --git a/alfred/app/.alfred_backups/features_45_20261026_022229_600013/app.py b/alfred/app/.alfred_backups/features_45_20261026_022229_600013/app.py new file mode 100644 index 0000000..06fd108 --- /dev/null +++ b/alfred/app/.alfred_backups/features_45_20261026_022229_600013/app.py @@ -0,0 +1,425 @@ +import os, threading, hmac +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services import admin_console # ALFRED_ADMIN_PAGES_V1 +from services.hermes_client import ask_hermes +from services import 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() +audio_settings.initialize() +audio_settings.update_settings({"mic_gain": 30}) +# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente. +camera_settings.restore_saved_settings() + +def emit_state(mode=None, **extra): + with STATE_LOCK: + if mode: STATE["mode"] = mode + STATE.update(extra) + socketio.emit("alfred_state", dict(STATE)) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # Vue dynamique : commandes déterministes exécutées sans faire + # dépendre l'affichage des capacités ou sessions Hermes. + visual = None + try: + visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type')) + except (ha_views.HAError, ValueError) as exc: + print(f"[ALFRED] Vue indisponible : {exc}", flush=True) + visual = ({"type":"text", "text":str(exc)}, str(exc)) + + if visual is not None: + content, speech = visual + with STATE_LOCK: + STATE["content"] = content + socketio.emit("alfred_render", content) + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + print("[ALFRED] Envoi à Hermes", flush=True) + # ComfyUI publie l'image pendant cet appel Hermes. + with STATE_LOCK: + image_revision_before = IMAGE_REVISION + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + # 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 + socketio.emit("alfred_render", display) + else: + print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1 +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/settings') +def get_camera_settings(): + try: + return jsonify(camera_settings.get_settings()) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/settings') +def set_camera_settings(): + # Même garde-fou que les commandes calendrier existantes. + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'JSON invalide.'}), 400 + try: + result = camera_settings.update_setting(data.get('control'), data.get('value')) + socketio.emit('camera_settings', result) + return jsonify(result) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/reset') +def reset_camera_settings(): + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + try: + result = camera_settings.reset_defaults() + socketio.emit('camera_settings', result) + return jsonify(result) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +@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//snapshot') +def ha_camera_snapshot(entity_id): + try: + binary,mime=ha_views.camera_snapshot(entity_id) + return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/ui/home') +def ui_home(): + with STATE_LOCK: + STATE['content']={'type':'home','text':'À ton écoute.'} + socketio.emit('alfred_render',STATE['content']) + return jsonify({'ok':True}) + +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) + diff --git a/alfred/app/.alfred_backups/features_45_20261026_022229_600013/manifest.json b/alfred/app/.alfred_backups/features_45_20261026_022229_600013/manifest.json new file mode 100644 index 0000000..c91cafe --- /dev/null +++ b/alfred/app/.alfred_backups/features_45_20261026_022229_600013/manifest.json @@ -0,0 +1,13 @@ +{ + "files": [ + "app.py", + "templates/index.html", + "services/alfred_features.py", + "static/css/alfred-features.css", + "static/js/alfred-features.js" + ], + "originals": [ + "app.py", + "templates/index.html" + ] +} \ No newline at end of file diff --git a/alfred/app/.alfred_backups/features_45_20261026_022229_600013/templates/index.html b/alfred/app/.alfred_backups/features_45_20261026_022229_600013/templates/index.html new file mode 100644 index 0000000..517b3d8 --- /dev/null +++ b/alfred/app/.alfred_backups/features_45_20261026_022229_600013/templates/index.html @@ -0,0 +1,160 @@ + + + + + + + {{ name }} + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + diff --git a/alfred/app/__pycache__/app.cpython-313.pyc b/alfred/app/__pycache__/app.cpython-313.pyc index c4f59ae..65f71e3 100644 Binary files a/alfred/app/__pycache__/app.cpython-313.pyc and b/alfred/app/__pycache__/app.cpython-313.pyc differ diff --git a/alfred/app/app.py b/alfred/app/app.py index 06fd108..0cda174 100644 --- a/alfred/app/app.py +++ b/alfred/app/app.py @@ -8,6 +8,8 @@ from services.audio import listen_once, speak_async, tts_backend_detect from services.wakeword import WakeWordListener from services import admin_console # ALFRED_ADMIN_PAGES_V1 from services.hermes_client import ask_hermes +from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1 +from services import alfred_features # ALFRED_FEATURES_V45 from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1 from services.camera import video_stream from services import ha_views, alfred_views_controller @@ -29,6 +31,8 @@ STATE_LOCK = threading.RLock() IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI. conversation_lock=threading.Lock() mic_stop_event=threading.Event() +# Service temps autonome (survit aux conversations Hermes) +alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1 audio_settings.initialize() audio_settings.update_settings({"mic_gain": 30}) # Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente. @@ -107,6 +111,17 @@ def conversation(score=1.0): # ComfyUI publie l'image pendant cet appel Hermes. with STATE_LOCK: image_revision_before = IMAGE_REVISION + # ALFRED_FEATURES_V45 voice opt-in background mode + if alfred_features.should_run_in_background(r.text): + try: + alfred_features.submit(r.text, source='voice') + emit_state('idle', partial='', content={ + 'type': 'text', + 'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.' + }) + except ValueError as exc: + emit_state('error', content={'type': 'text', 'text': str(exc)}) + return answer = ask_hermes(r.text) print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) if isinstance(answer, dict): @@ -119,11 +134,19 @@ def conversation(score=1.0): speech = str(answer or "") display = {"type": "text", "text": speech} + # Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes. + # Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale. + alfred_context_images.schedule_context_image( + r.text, speech, socketio, + lambda: STATE.get("transcript") == r.text, + ) # ALFRED_TIMERS_CONTEXT_V1 + # Si le MCP ComfyUI a affiché une image pendant la requête, # ne pas l'écraser par la réponse textuelle d'Hermes. with STATE_LOCK: if IMAGE_REVISION == image_revision_before: STATE["content"] = display + alfred_features.publish_voice_display(display) socketio.emit("alfred_render", display) else: print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True) @@ -233,6 +256,9 @@ def camera_stream(): 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") diff --git a/alfred/app/app.py.bak.timers_context.20261027_123527 b/alfred/app/app.py.bak.timers_context.20261027_123527 new file mode 100644 index 0000000..72abbb4 --- /dev/null +++ b/alfred/app/app.py.bak.timers_context.20261027_123527 @@ -0,0 +1,441 @@ +import os, threading, hmac +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo +from flask import Flask, render_template, request, jsonify, Response +from flask_socketio import SocketIO +from dotenv import load_dotenv +from services.audio import listen_once, speak_async, tts_backend_detect +from services.wakeword import WakeWordListener +from services import admin_console # ALFRED_ADMIN_PAGES_V1 +from services.hermes_client import ask_hermes +from services import alfred_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() +audio_settings.initialize() +audio_settings.update_settings({"mic_gain": 30}) +# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente. +camera_settings.restore_saved_settings() + +def emit_state(mode=None, **extra): + with STATE_LOCK: + if mode: STATE["mode"] = mode + STATE.update(extra) + socketio.emit("alfred_state", dict(STATE)) + +def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))}) + +def conversation(score=1.0): + if not conversation_lock.acquire(blocking=False): + return + + try: + emit_state("listening", partial="", transcript="", wake_score=score) + + print("[ALFRED] Début écoute STT", flush=True) + + r = listen_once( + stop_event=mic_stop_event, + on_partial=lambda p: emit_state("listening", partial=p), + on_level=audio_level + ) + + print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True) + + if audio_settings.mic_muted(): + emit_state("idle", partial="") + return + + if r.error: + emit_state( + "error", + content={"type": "text", "text": r.error} + ) + return + + if not r.text: + emit_state("idle", partial="") + return + + emit_state( + "thinking", + partial="", + transcript=r.text, + content={"type": "text", "text": r.text} + ) + + # Vue dynamique : commandes déterministes exécutées sans faire + # dépendre l'affichage des capacités ou sessions Hermes. + visual = None + try: + visual = alfred_views_controller.route(r.text, (STATE.get('content') or {}).get('type')) + except (ha_views.HAError, ValueError) as exc: + print(f"[ALFRED] Vue indisponible : {exc}", flush=True) + visual = ({"type":"text", "text":str(exc)}, str(exc)) + + if visual is not None: + content, speech = visual + with STATE_LOCK: + STATE["content"] = content + socketio.emit("alfred_render", content) + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + emit_state("idle", partial="") + return + + print("[ALFRED] Envoi à Hermes", flush=True) + # ComfyUI publie l'image pendant cet appel Hermes. + with STATE_LOCK: + image_revision_before = IMAGE_REVISION + # ALFRED_FEATURES_V45 voice opt-in background mode + if alfred_features.should_run_in_background(r.text): + try: + alfred_features.submit(r.text, source='voice') + emit_state('idle', partial='', content={ + 'type': 'text', + 'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.' + }) + except ValueError as exc: + emit_state('error', content={'type': 'text', 'text': str(exc)}) + return + answer = ask_hermes(r.text) + print(f"[ALFRED] Réponse Hermes : {answer!r}", flush=True) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "") + display = answer.get("display") or { + "type": "text", + "text": speech + } + else: + speech = str(answer or "") + display = {"type": "text", "text": speech} + + # Si le MCP ComfyUI a affiché une image pendant la requête, + # ne pas l'écraser par la réponse textuelle d'Hermes. + with STATE_LOCK: + if IMAGE_REVISION == image_revision_before: + STATE["content"] = display + alfred_features.publish_voice_display(display) + socketio.emit("alfred_render", display) + else: + print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True) + + if speech and not audio_settings.speaker_muted(): + emit_state("speaking") + done = threading.Event() + speak_async(speech, on_end=done.set) + done.wait(timeout=120) + + emit_state("idle", partial="") + + except Exception as exc: + print(f"[ALFRED] Erreur conversation : {exc}", flush=True) + emit_state( + "error", + content={"type": "text", "text": str(exc)} + ) + + finally: + conversation_lock.release() + if not audio_settings.mic_muted(): + wake.resume() + print("[ALFRED] Wake word réactivé", flush=True) + + + +def on_wake(score): + if audio_settings.mic_muted(): + return + socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score}) + threading.Thread(target=conversation,args=(score,),daemon=True).start() + +wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18)) + +admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1 +@app.get('/api/audio/settings') +def get_audio_settings(): + return jsonify(audio_settings.get_settings()) + +@app.post('/api/audio/settings') +def set_audio_settings(): + data = request.get_json(silent=True) + try: + old = audio_settings.get_settings() + new = audio_settings.update_settings(data) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': f'ALSA : {exc}'}), 500 + if new['mic_muted'] != old['mic_muted']: + if new['mic_muted']: + mic_stop_event.set() + wake.pause() + else: + mic_stop_event.clear() + if not conversation_lock.locked(): + wake.resume() + socketio.emit('audio_settings', new) + return jsonify(new) + +@app.get('/api/camera/settings') +def get_camera_settings(): + try: + return jsonify(camera_settings.get_settings()) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/settings') +def set_camera_settings(): + # Même garde-fou que les commandes calendrier existantes. + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'JSON invalide.'}), 400 + try: + result = camera_settings.update_setting(data.get('control'), data.get('value')) + socketio.emit('camera_settings', result) + return jsonify(result) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.post('/api/camera/reset') +def reset_camera_settings(): + if not _same_origin_json(): + return jsonify({'error': 'Requête non autorisée.'}), 403 + try: + result = camera_settings.reset_defaults() + socketio.emit('camera_settings', result) + return jsonify(result) + except camera_settings.CameraSettingsError as exc: + return jsonify({'error': str(exc)}), 503 + +@app.get('/api/camera/stream') +def camera_stream(): + # Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan. + try: + frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0'))) + except (ImportError, ValueError, RuntimeError, OSError) as exc: + return jsonify({'error': str(exc)}), 503 + return Response( + frames, + mimetype='multipart/x-mixed-replace; boundary=frame', + headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'} + ) + +# ALFRED_FEATURES_V45 init +alfred_features.init_app(app, ask_hermes, socketio=socketio) + +@app.get("/") +def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred")) +@app.get("/api/state") +def state(): + with STATE_LOCK: + snapshot = dict(STATE) + return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()}) + +@app.post("/api/state") +def set_state(): + data = request.get_json(force=True) + with STATE_LOCK: + STATE.update(data) + socketio.emit("alfred_state", dict(STATE)) + return jsonify({"ok": True}) +@app.post("/api/render") +def render_payload(): + global IMAGE_REVISION + token = os.getenv("ALFRED_RENDER_TOKEN", "").strip() + if token and not hmac.compare_digest( + request.headers.get("X-Alfred-Render-Token", ""), token + ): + return jsonify({"ok": False, "error": "Non autorisé"}), 401 + + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"ok": False, "error": "JSON objet attendu"}), 400 + if data.get("type") == "image": + image_url = data.get("url") + if not isinstance(image_url, str) or not ( + image_url.startswith(("http://", "https://", "/")) + and not image_url.startswith("//") + ): + return jsonify({"ok": False, "error": "URL image invalide"}), 400 + + with STATE_LOCK: + if data.get("type") == "image": + IMAGE_REVISION += 1 + STATE["content"] = data + socketio.emit("alfred_render", data) + return jsonify({"ok": True, "image_revision": IMAGE_REVISION}) +# --- Agenda / capteurs / caméras Home Assistant --- +def _calendar_range(): + tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris')) + now = datetime.now(tz) + def parse(name, default): + raw = request.args.get(name) + if not raw:return default + value = datetime.fromisoformat(raw.replace('Z','+00:00')) + if value.tzinfo is None: value = value.replace(tzinfo=tz) + return value.astimezone(tz) + start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0)) + end = parse('end',start+timedelta(days=7)) + return start,end + +def _json_error(exc): + code = 400 if isinstance(exc,ValueError) else 502 + return jsonify({'error':str(exc)}),code + +def _writes_enabled(): + return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes') + +def _same_origin_json(): + # POSTs require a custom header and same-origin browser requests. + if request.headers.get('X-Alfred-Request') != '1':return False + origin=request.headers.get('Origin') + if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False + return True + +@app.get('/api/calendar/calendars') +def calendars_list(): + try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/calendar/events') +def calendar_events(): + try: + start,end=_calendar_range() + selected=request.args.getlist('calendar_id') + return jsonify(ha_views.get_events(start,end,selected or None)) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/calendar/create') +def calendar_create(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris')) + def read_dt(key): + dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00')) + return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz) + result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')), + read_dt('start'),read_dt('end'),str(data.get('description') or ''), + str(data.get('location') or ''),bool(data.get('all_day',False))) + from services import alfred_intents + alfred_intents.clear_create() + socketio.emit('calendar_changed', {'type':'created'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.post('/api/calendar/delete') +def calendar_delete(): + if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403 + if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403 + data=request.get_json(silent=True) + if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400 + try: + eid=str(data.get('calendar_id',''));uid=str(data.get('uid','')) + start=datetime.fromisoformat(str(data['start'])) + end=datetime.fromisoformat(str(data['end'])) + if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.') + matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events'] + rid=str(data.get('recurrence_id') or '') + if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches): + return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409 + result=ha_views.delete_event(eid,uid,rid or None) + from services import alfred_intents + alfred_intents.clear_delete() + socketio.emit('calendar_changed',{'type':'deleted'}) + return jsonify(result) + except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc) + +@app.get('/api/ha/weather') +def ha_weather_data(): + # Read-only route, token always stays on the Alfred server. + try: + q = request.args.get('q', '') + return jsonify(ha_weather.get_weather(q)) + except (ha_views.HAError, ValueError) as exc: + return _json_error(exc) + +@app.get('/api/ha/weather/outfits') +def ha_weather_outfits_data(): + try: + q = request.args.get('q', '') + refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on') + return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh)) + except (ha_views.HAError, ValueError, RuntimeError) as exc: + return _json_error(exc) + +@app.get('/api/ha/temperatures') +def ha_temperatures(): + try:return jsonify({'items':ha_views.get_temperatures()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/temperature/history') +def ha_temperature_history(): + try: + eid=request.args.get('entity_id','') + hours=int(request.args.get('hours','24')) + return jsonify({'items':ha_views.temperature_history(eid,hours)}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/cameras') +def ha_cameras(): + try:return jsonify({'items':ha_views.list_cameras()}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.get('/api/ha/camera//snapshot') +def ha_camera_snapshot(entity_id): + try: + binary,mime=ha_views.camera_snapshot(entity_id) + return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}) + except (ha_views.HAError,ValueError) as exc:return _json_error(exc) + +@app.post('/api/ui/home') +def ui_home(): + with STATE_LOCK: + STATE['content']={'type':'home','text':'À ton écoute.'} + socketio.emit('alfred_render',STATE['content']) + return jsonify({'ok':True}) + +@app.post("/api/audio-level") +def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True}) +@app.get("/api/wake/status") +def wake_status(): return jsonify(wake.status()) +@app.post("/api/wake/start") +def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/wake/stop") +def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()}) +@app.post("/api/tts/say") +def tts_say(): + text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)}) + +if __name__ == "__main__": + if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start() + socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True) + diff --git a/alfred/app/backup_whisper_wyoming_v1_20261027_163959/audio.py b/alfred/app/backup_whisper_wyoming_v1_20261027_163959/audio.py new file mode 100644 index 0000000..390f1d4 --- /dev/null +++ b/alfred/app/backup_whisper_wyoming_v1_20261027_163959/audio.py @@ -0,0 +1,392 @@ +"""Audio local sounddevice.RawInputStreamd'Alfred, repris de l'application historique de Pierre. + +STT: Vosk + sounddevice, avec fallback arecord/ALSA. +TTS: Piper local, avec fallback espeak puis spd-say. +Sortie: haut-parleur local via paplay/aplay. + +Ce module ne parle pas directement au LLM/Hermes: il retourne le texte reconnu +à l'orchestrateur Alfred, afin de conserver Hermes comme cerveau unique. +""" +from __future__ import annotations +from functools import lru_cache + +import time +import json +import os +import queue +import shutil +import subprocess +import tempfile +import threading +from dataclasses import dataclass +from typing import Callable, Optional + + +def _cmd_ok(cmd, **kwargs): + try: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + check=True, text=True, **kwargs) + except Exception: + return None + + +def audio_backend_detect() -> str: + configured = os.getenv("AUDIO_BACKEND", "auto").lower() + if configured in ("pactl", "alsa"): + return configured + if shutil.which("pactl"): + info = _cmd_ok(["pactl", "info"]) + if info and "Server Name" in info.stdout: + return "pactl" + if shutil.which("amixer"): + return "alsa" + return "none" + + +def _which(cmd): + return shutil.which(cmd) + + +def _is_piper_tts(binary: Optional[str]) -> bool: + if not binary: + return False + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + if "gtk" in out or "ratbag" in out: + return False + return (("--model" in out or " -m " in out) and + ("--output_file" in out or " -o " in out)) + except Exception: + return False + + +def _find_piper_binary() -> Optional[str]: + candidates = [ + os.getenv("PIPER_BIN"), + "/usr/local/bin/piper-tts", "/usr/bin/piper-tts", _which("piper-tts"), + "/usr/local/bin/piper", "/usr/bin/piper", _which("piper"), + ] + for candidate in candidates: + if candidate and _is_piper_tts(candidate): + return candidate + return None + + +def _piper_supports_long_flag(binary: str) -> bool: + try: + r = subprocess.run([binary, "--help"], stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, timeout=3) + out = (r.stdout or "").lower() + return "--model" in out and "--output_file" in out + except Exception: + return True + +### +def tts_backend_detect() -> str: + """Retourne le moteur TTS configuré pour Alfred.""" + + if os.getenv("PIPER_HOST"): + return "wyoming-piper" + + if _which("espeak-ng"): + return "espeak-ng" + + return "none" + + +''' +def tts_backend_detect() -> str: + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + if piper and model and os.path.exists(model): + return "piper" + if _which("espeak"): + return "espeak" + if _which("spd-say"): + return "spd-say" + return "none" +''' +#### + +def speak_text(text: str): + """Synthèse vocale via Wyoming Piper, avec secours eSpeak.""" + from services.audio_settings import speaker_muted + if speaker_muted(): + return + if not text or not text.strip(): + return + + text = text[:int(os.getenv("TTS_MAX_CHARS", "2000"))] + + try: + from services.piper_tts import speak as piper_speak + + print("[ALFRED TTS] Utilisation de Wyoming Piper", flush=True) + piper_speak(text) + return + + except Exception as exc: + print(f"[ALFRED TTS] Piper indisponible : {exc}", flush=True) + + # Secours eSpeak + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + print("[ALFRED TTS] Secours eSpeak", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + except Exception as exc: + print(f"[ALFRED TTS] Erreur eSpeak : {exc}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + +''' +def speak_text(text: str): + if not text or not text.strip(): + return + + fd, wav_path = tempfile.mkstemp(prefix="alfred_", suffix=".wav") + os.close(fd) + + try: + start = time.monotonic() + print("[ALFRED TTS] Génération audio...", flush=True) + + subprocess.run( + ["espeak-ng", "-v", "fr", "-w", wav_path, text], + check=True, + timeout=30 + ) + + print("[ALFRED TTS] Lecture audio...", flush=True) + + subprocess.run( + ["aplay", "-q", "-D", "plughw:1,0", wav_path], + check=True, + timeout=120 + ) + + print( + f"[ALFRED TTS] Terminé en {time.monotonic() - start:.2f}s", + flush=True + ) + + except Exception as e: + print(f"[ALFRED TTS] Erreur : {e}", flush=True) + + finally: + if os.path.exists(wav_path): + os.remove(wav_path) + + + + +def speak_text(text: str): + """Synthétise et joue localement le texte. Retourne (ok, backend, erreur).""" + text = (text or "").strip() + if not text: + return False, "none", "texte vide" + text = text[: int(os.getenv("TTS_MAX_CHARS", "2000"))] + + backend = audio_backend_detect() + piper = _find_piper_binary() + model = os.getenv("PIPER_MODEL", "") + + if piper and model and os.path.exists(model): + wav_path = None + try: + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + wav_path = tmp.name + cmd = [piper] + if _piper_supports_long_flag(piper): + cmd += ["--model", model, "--output_file", wav_path] + else: + cmd += ["-m", model, "-o", wav_path] + p = subprocess.run(cmd, input=text, text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if p.returncode != 0: + return False, "piper", (p.stderr or p.stdout or "")[:2000] + if not os.path.exists(wav_path) or os.path.getsize(wav_path) == 0: + return False, "piper", "Piper n'a pas produit de WAV" + if backend == "pactl" and shutil.which("paplay"): + play = subprocess.run(["paplay", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + elif shutil.which("aplay"): + play = subprocess.run(["aplay", "-q", wav_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + else: + return False, "piper", "paplay/aplay introuvable" + return play.returncode == 0, "piper", None if play.returncode == 0 else "lecture WAV échouée" + except Exception as exc: + return False, "piper", str(exc) + finally: + if wav_path: + try: os.unlink(wav_path) + except OSError: pass + + if _which("espeak"): + args = ["espeak", "-v", os.getenv("ESPEAK_VOICE", "fr"), text] + if backend == "pactl" and shutil.which("padsp"): + args = ["padsp"] + args + ok = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE).returncode == 0 + return ok, "espeak", None if ok else "espeak a échoué" + + if _which("spd-say"): + ok = subprocess.run(["spd-say", "-l", "fr", text], stdout=subprocess.PIPE, + stderr=subprocess.PIPE).returncode == 0 + return ok, "spd-say", None if ok else "spd-say a échoué" + return False, "none", "aucun backend TTS disponible" +''' + +_tts_lock = threading.Lock() +def speak_async(text: str, on_start: Optional[Callable] = None, + on_end: Optional[Callable] = None) -> bool: + """Lecture non bloquante; refuse d'empiler deux réponses vocales.""" + if _tts_lock.locked(): + return False + def worker(): + with _tts_lock: + if on_start: on_start() + try: speak_text(text) + finally: + if on_end: on_end() + threading.Thread(target=worker, daemon=True).start() + return True + + +@dataclass +class STTResult: + text: str = "" + error: Optional[str] = None + + + +@lru_cache(maxsize=2) +def get_vosk_model(model_path): + from vosk import Model + + print(f"[ALFRED] Chargement du modèle Vosk : {model_path}", flush=True) + model = Model(model_path) + print("[ALFRED] Modèle Vosk prêt", flush=True) + + return model + + +def listen_once(stop_event: Optional[threading.Event] = None, + on_partial: Optional[Callable[[str], None]] = None, + on_level: Optional[Callable[[float], None]] = None) -> STTResult: + """Attend une phrase finale Vosk et la retourne. + + Reprend le comportement du prototype: sounddevice en priorité, arecord sinon. + Le niveau audio transmis à l'UI est une approximation RMS sur int16. + """ + try: + import numpy as np + from vosk import Model, KaldiRecognizer + except Exception as exc: + return STTResult(error=f"Dépendance STT absente: {exc}") + + model_path = os.getenv("VOSK_MODEL_PATH", "") + sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000")) + if not model_path or not os.path.exists(model_path): + return STTResult(error=f"Modèle Vosk introuvable: {model_path}") + try: +# rec = KaldiRecognizer(Model(model_path), sample_rate) + rec = KaldiRecognizer(get_vosk_model(model_path), sample_rate) + except Exception as exc: + return STTResult(error=f"Init Vosk: {exc}") + + def process(data: bytes) -> Optional[str]: + if on_level and data: + try: + a = np.frombuffer(data, dtype=np.int16).astype(np.float32) + rms = float(np.sqrt(np.mean(a * a))) if a.size else 0.0 + on_level(min(1.0, rms / 6000.0)) + except Exception: + pass + if rec.AcceptWaveform(data): + text = (json.loads(rec.Result() or "{}").get("text") or "").strip() + if text: + return text + elif on_partial: + partial = json.loads(rec.PartialResult() or "{}").get("partial", "") + print(f"[ALFRED STT] Reconnu partiellement : {partial}", flush=True) + on_partial(partial) + return None + + try: + import sounddevice as sd + q = queue.Queue(maxsize=32) + def callback(indata, frames, time_info, status): + try: + q.put_nowait(bytes(indata)) + except queue.Full: + pass + device = os.getenv("AUDIO_INPUT_DEVICE") or None + with sd.RawInputStream(samplerate=sample_rate, blocksize=8000, dtype="int16", + channels=1, callback=callback, device=device): + while not (stop_event and stop_event.is_set()): + try: + chunk = q.get(timeout=0.25) + except queue.Empty: + continue + text = process(chunk) + if text: + return STTResult(text=text) + except Exception as exc: + print(f"[ALFRED STT] Erreur microphone sounddevice : {exc}", flush=True) + +# except Exception: +# pass + + if stop_event and stop_event.is_set(): + return STTResult() + + if not shutil.which("arecord"): + return STTResult(error="sounddevice indisponible et arecord introuvable") + cmd = ["arecord", "-q", "-f", "S16_LE", "-r", str(sample_rate), "-c", "1"] + alsa_device = os.getenv("ALSA_CAPTURE_DEVICE", "").strip() + if alsa_device: + cmd += ["-D", alsa_device] + proc = None + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + while not (stop_event and stop_event.is_set()): + chunk = proc.stdout.read(8000) + if not chunk: + continue + text = process(chunk) + if text: + return STTResult(text=text) + return STTResult() + except Exception as exc: + return STTResult(error=f"arecord: {exc}") + finally: + if proc and proc.poll() is None: + proc.terminate() + + +def devices(): + try: +# import sounddevice as sd +# return sd.query_devices() + print("[ALFRED STT] Périphériques audio :", flush=True) + print(sd.query_devices(), flush=True) + print("[ALFRED STT] Périphérique par défaut :", sd.default.device, flush=True) + return sd.query_devices() + except Exception: + return [] diff --git a/alfred/app/cache/weather_outfit_cache.json b/alfred/app/cache/weather_outfit_cache.json index 576a120..08ff115 100644 --- a/alfred/app/cache/weather_outfit_cache.json +++ b/alfred/app/cache/weather_outfit_cache.json @@ -220,5 +220,208 @@ }, "cached_at": 1792973038.224328, "focus_label": "aujourd’hui" + }, + "15bd2b3a5a6d5684d37c06efb995a3091c6343d28963637ea3d5c147fc463df2": { + "file": "15bd2b3a5a6d5684d37c06efb995a3091c6343d28963637ea3d5c147fc463df2.jpg", + "prompt_ids": [ + "8d166bb7-3261-43f9-bb1b-1e38258057db", + "72f1f4b6-f15e-453f-8de5-f2099fdaa2a5" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793008787.1798131, + "focus_label": "aujourd’hui" + }, + "374b0c2d16b1ed073a9e7f80f6ccfbeb1b92558ccb3185e5a114a16952593b9a": { + "file": "374b0c2d16b1ed073a9e7f80f6ccfbeb1b92558ccb3185e5a114a16952593b9a.jpg", + "prompt_ids": [ + "23ee327d-c004-4275-85d0-213c962b0205", + "50c78e8e-3416-4b93-8c2f-f96cce79ffcc" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793094060.4219398, + "focus_label": "aujourd’hui" + }, + "1f86b5da98a11bff1b4fcf7d00ab333ada5049911f2366246eb1b7d85ed4d3eb": { + "file": "1f86b5da98a11bff1b4fcf7d00ab333ada5049911f2366246eb1b7d85ed4d3eb.jpg", + "prompt_ids": [ + "6e8dd4ee-c156-473b-954d-6d556bd1e835", + "613c90ea-5139-4bd0-9626-a6a27da62f77" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793107791.0520644, + "focus_label": "aujourd’hui" + }, + "789424e1128744c7716424ddca8c6ae5837fd74ec345347d0d397fc6812880ee": { + "file": "789424e1128744c7716424ddca8c6ae5837fd74ec345347d0d397fc6812880ee.jpg", + "prompt_ids": [ + "fe04dfc2-2b2c-4b1e-aa17-1f22d98d37fb", + "2072f159-d0e5-43ff-89ff-9f3beea58013" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793108064.0707133, + "focus_label": "aujourd’hui" + }, + "85baafac1958bbda68b3009e06defef30efe35d8d0db9079e9d9104c21f7d176": { + "file": "85baafac1958bbda68b3009e06defef30efe35d8d0db9079e9d9104c21f7d176.jpg", + "prompt_ids": [ + "b92d5bea-b532-45dc-86d5-0180138b1d04", + "6cccd63a-0258-43fd-a42c-0389e8bf1c08" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793109801.1181734, + "focus_label": "actuellement" + }, + "fb8537a8794ba1c550911b830b18c9b0db7bf115ecdfaba70664c0a16d2aecc8": { + "file": "fb8537a8794ba1c550911b830b18c9b0db7bf115ecdfaba70664c0a16d2aecc8.jpg", + "prompt_ids": [ + "1f481cdc-071d-4cc3-b394-524ffbe2da6b", + "e8a0aa26-6cb3-4691-9c5c-ea921870ad63" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793111812.2039518, + "focus_label": "aujourd’hui" + }, + "ae215efab8ab6a13202af254f30e539d0aed4c1946f4456c7e37dd31de021f57": { + "file": "ae215efab8ab6a13202af254f30e539d0aed4c1946f4456c7e37dd31de021f57.jpg", + "prompt_ids": [ + "2e552503-10af-4349-8694-ab6bc2d1a2d3", + "d8420f14-30de-49cf-a5ba-4427d6c12944" + ], + "reviews": { + "child": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + }, + "adult": { + "score": 0.0, + "ok": false, + "issues": [ + "review_unavailable" + ], + "reviewed": false, + "reference_applied": true + } + }, + "cached_at": 1793111827.3695734, + "focus_label": "samedi 31 octobre" } } \ No newline at end of file diff --git a/alfred/app/cache/weather_outfit_images/15bd2b3a5a6d5684d37c06efb995a3091c6343d28963637ea3d5c147fc463df2.jpg b/alfred/app/cache/weather_outfit_images/15bd2b3a5a6d5684d37c06efb995a3091c6343d28963637ea3d5c147fc463df2.jpg new file mode 100644 index 0000000..5eac8d9 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/15bd2b3a5a6d5684d37c06efb995a3091c6343d28963637ea3d5c147fc463df2.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/1f86b5da98a11bff1b4fcf7d00ab333ada5049911f2366246eb1b7d85ed4d3eb.jpg b/alfred/app/cache/weather_outfit_images/1f86b5da98a11bff1b4fcf7d00ab333ada5049911f2366246eb1b7d85ed4d3eb.jpg new file mode 100644 index 0000000..df9170d Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/1f86b5da98a11bff1b4fcf7d00ab333ada5049911f2366246eb1b7d85ed4d3eb.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/374b0c2d16b1ed073a9e7f80f6ccfbeb1b92558ccb3185e5a114a16952593b9a.jpg b/alfred/app/cache/weather_outfit_images/374b0c2d16b1ed073a9e7f80f6ccfbeb1b92558ccb3185e5a114a16952593b9a.jpg new file mode 100644 index 0000000..44649b8 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/374b0c2d16b1ed073a9e7f80f6ccfbeb1b92558ccb3185e5a114a16952593b9a.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/789424e1128744c7716424ddca8c6ae5837fd74ec345347d0d397fc6812880ee.jpg b/alfred/app/cache/weather_outfit_images/789424e1128744c7716424ddca8c6ae5837fd74ec345347d0d397fc6812880ee.jpg new file mode 100644 index 0000000..f536fcd Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/789424e1128744c7716424ddca8c6ae5837fd74ec345347d0d397fc6812880ee.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/85baafac1958bbda68b3009e06defef30efe35d8d0db9079e9d9104c21f7d176.jpg b/alfred/app/cache/weather_outfit_images/85baafac1958bbda68b3009e06defef30efe35d8d0db9079e9d9104c21f7d176.jpg new file mode 100644 index 0000000..ac1bbee Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/85baafac1958bbda68b3009e06defef30efe35d8d0db9079e9d9104c21f7d176.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/ae215efab8ab6a13202af254f30e539d0aed4c1946f4456c7e37dd31de021f57.jpg b/alfred/app/cache/weather_outfit_images/ae215efab8ab6a13202af254f30e539d0aed4c1946f4456c7e37dd31de021f57.jpg new file mode 100644 index 0000000..a49f186 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/ae215efab8ab6a13202af254f30e539d0aed4c1946f4456c7e37dd31de021f57.jpg differ diff --git a/alfred/app/cache/weather_outfit_images/fb8537a8794ba1c550911b830b18c9b0db7bf115ecdfaba70664c0a16d2aecc8.jpg b/alfred/app/cache/weather_outfit_images/fb8537a8794ba1c550911b830b18c9b0db7bf115ecdfaba70664c0a16d2aecc8.jpg new file mode 100644 index 0000000..bbe5926 Binary files /dev/null and b/alfred/app/cache/weather_outfit_images/fb8537a8794ba1c550911b830b18c9b0db7bf115ecdfaba70664c0a16d2aecc8.jpg differ diff --git a/alfred/app/config/control_center.env b/alfred/app/config/control_center.env index 1798ac1..bd199a4 100644 --- a/alfred/app/config/control_center.env +++ b/alfred/app/config/control_center.env @@ -1,2 +1,7 @@ # Configuration générale Alfred (page /config) # ALFRED_ALLOW_WEB_RESTART=true +ALFRED_NAME=Dona +WAKE_WORD=dona +PIPER_VOICE=fr_FR-upmc-medium +ALFRED_OLLAMA_URL= +HA_WEATHER_ENTITY= diff --git a/alfred/app/config/hermes_session.json b/alfred/app/config/hermes_session.json index de88db4..2d686f1 100644 --- a/alfred/app/config/hermes_session.json +++ b/alfred/app/config/hermes_session.json @@ -1,3 +1,3 @@ { - "session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599" + "session_id": "local-f6c54762e29b37dc1b98da779a3690e4ddf8cb585d11705c6ee88e828ac59737" } diff --git a/alfred/app/config/hermes_session.json.bak b/alfred/app/config/hermes_session.json.bak new file mode 100644 index 0000000..de88db4 --- /dev/null +++ b/alfred/app/config/hermes_session.json.bak @@ -0,0 +1,3 @@ +{ + "session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599" +} diff --git a/alfred/app/config/hermes_session.json.bak-20261027-161031 b/alfred/app/config/hermes_session.json.bak-20261027-161031 new file mode 100644 index 0000000..e4f00a9 --- /dev/null +++ b/alfred/app/config/hermes_session.json.bak-20261027-161031 @@ -0,0 +1,3 @@ +{ + "session_id": "local-0dc2466df78900d9f7fd572cb26be3c88dbfcd31ee2e66056be44f5e3f966595" +} diff --git a/alfred/app/config/weather_outfits.env b/alfred/app/config/weather_outfits.env index 94ebdab..3df7ca8 100644 --- a/alfred/app/config/weather_outfits.env +++ b/alfred/app/config/weather_outfits.env @@ -15,8 +15,9 @@ ALFRED_WEATHER_REVIEW=true # 'off' : desactive les references ALFRED_WEATHER_REFERENCE_MODE=auto # Intensite de transformation de la reference (0.25 stable, 0.85 plus libre) -ALFRED_WEATHER_REFERENCE_DENOISE=0.56 +ALFRED_WEATHER_REFERENCE_DENOISE=0.7 # Maintient visuellement les visages de reference dans le rendu img2img ALFRED_WEATHER_PRESERVE_FACE=true # Nombre maximum d'accessoires visibles par personnage ALFRED_WEATHER_ACCESSORY_LIMIT=1 +ALFRED_COMFYUI_URL= diff --git a/alfred/app/data/alfred_features.sqlite3 b/alfred/app/data/alfred_features.sqlite3 new file mode 100644 index 0000000..d9a60ec Binary files /dev/null and b/alfred/app/data/alfred_features.sqlite3 differ diff --git a/alfred/app/data/alfred_features.sqlite3-shm b/alfred/app/data/alfred_features.sqlite3-shm new file mode 100644 index 0000000..89465dd Binary files /dev/null and b/alfred/app/data/alfred_features.sqlite3-shm differ diff --git a/alfred/app/data/alfred_features.sqlite3-wal b/alfred/app/data/alfred_features.sqlite3-wal new file mode 100644 index 0000000..a2471f1 Binary files /dev/null and b/alfred/app/data/alfred_features.sqlite3-wal differ diff --git a/alfred/app/data/timers.json b/alfred/app/data/timers.json new file mode 100644 index 0000000..940740e --- /dev/null +++ b/alfred/app/data/timers.json @@ -0,0 +1,67 @@ +[ + { + "id": "ff0d02e2b2", + "label": "Essai", + "kind": "timer", + "duration": 15.0, + "elapsed": 15.0, + "started_at": null, + "created_at": 1793101033.4255264, + "state": "finished", + "finished_at": 1793101048.657067 + }, + { + "id": "91edf67b2b", + "label": "Minuteur", + "kind": "timer", + "duration": 30.0, + "elapsed": 30.0, + "started_at": null, + "created_at": 1793103592.8418453, + "state": "finished", + "finished_at": 1793103623.1536205 + }, + { + "id": "a8a9d9ff2d", + "label": "Minuteur", + "kind": "timer", + "duration": 20.0, + "elapsed": 8.051732301712036, + "started_at": null, + "created_at": 1793103921.956481, + "state": "cancelled" + }, + { + "id": "4db3749825", + "label": "Minuteur", + "kind": "timer", + "duration": 20.0, + "elapsed": 20.0, + "started_at": null, + "created_at": 1793104544.2226057, + "state": "finished", + "finished_at": 1793104564.4760897 + }, + { + "id": "0776628fcf", + "label": "Minuteur", + "kind": "timer", + "duration": 20.0, + "elapsed": 20.0, + "started_at": null, + "created_at": 1793104604.8489864, + "state": "finished", + "finished_at": 1793104625.0561383 + }, + { + "id": "3444e4aea3", + "label": "Minuteur", + "kind": "timer", + "duration": 20.0, + "elapsed": 20.0, + "started_at": null, + "created_at": 1793109993.4915156, + "state": "finished", + "finished_at": 1793110013.7477841 + } +] \ No newline at end of file diff --git a/alfred/app/services/__pycache__/alfred_context_images.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_context_images.cpython-313.pyc new file mode 100644 index 0000000..f16b3a0 Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_context_images.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/alfred_features.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_features.cpython-313.pyc new file mode 100644 index 0000000..100f309 Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_features.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/alfred_timers.cpython-313.pyc b/alfred/app/services/__pycache__/alfred_timers.cpython-313.pyc new file mode 100644 index 0000000..17f7409 Binary files /dev/null and b/alfred/app/services/__pycache__/alfred_timers.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/audio.cpython-313.pyc b/alfred/app/services/__pycache__/audio.cpython-313.pyc index a2ea94c..111d273 100644 Binary files a/alfred/app/services/__pycache__/audio.cpython-313.pyc and b/alfred/app/services/__pycache__/audio.cpython-313.pyc differ diff --git a/alfred/app/services/__pycache__/wyoming_stt.cpython-313.pyc b/alfred/app/services/__pycache__/wyoming_stt.cpython-313.pyc new file mode 100644 index 0000000..9454de9 Binary files /dev/null and b/alfred/app/services/__pycache__/wyoming_stt.cpython-313.pyc differ diff --git a/alfred/app/services/alfred_context_images.py b/alfred/app/services/alfred_context_images.py new file mode 100644 index 0000000..05156be --- /dev/null +++ b/alfred/app/services/alfred_context_images.py @@ -0,0 +1,83 @@ +"""Illustrations contextuelles Wikipédia déclenchées APRÈS une réponse Hermes. + +Pas de modification des prompts Hermes ou de /api/render. Échec silencieux. +""" +from __future__ import annotations +import re +import threading +import time +import unicodedata +from urllib.parse import urlencode, quote, urlparse +import requests + +CACHE = {} +LOCK = threading.Lock() + + +def _norm(s): + s = unicodedata.normalize('NFKD', str(s)).casefold() + return re.sub(r'[^a-z0-9 ]+', '', ''.join(c for c in s if not unicodedata.combining(c))) + + +def entity_question(s): + text = ' '.join(str(s or '').split()) + m = re.match(r'^(?:alfred[ ,.:!?]*)?(?:qui\s+(?:est|etait|était|sont)\s+|c.?est\s+qui\s+)([^?!.]{3,95})', text, re.I) + if not m: + return None + entity = m.group(1).strip(' «»\'".,?!') + entity = re.sub(r'\s+(?:au juste|exactement|s.il te plait)$', '', entity, flags=re.I) + if len(entity.split()) > 7 or len(entity) < 3: + return None + return entity + + +def _query_wikipedia(name): + # PageImages retourne une illustration publique et l'URL de la page source. + params = {'action':'query','generator':'search','gsrsearch':name,'gsrlimit':1, + 'prop':'pageimages','piprop':'thumbnail','pithumbsize':500, + 'format':'json','formatversion':'2','redirects':'1'} + headers = {'User-Agent':'AlfredAssistant/1.0 (personal home assistant; image source: Wikipedia)'} + response = requests.get('https://fr.wikipedia.org/w/api.php', params=params, + headers=headers, timeout=4, allow_redirects=False) + response.raise_for_status() + pages = response.json().get('query', {}).get('pages', []) + if not pages: + return None + page = pages[0] + title = str(page.get('title','')) + if not title or _norm(name) not in _norm(title) and _norm(title) not in _norm(name): + return None + url = str(page.get('thumbnail', {}).get('source', '')) + parsed = urlparse(url) + if parsed.scheme != 'https' or parsed.hostname not in ('upload.wikimedia.org','commons.wikimedia.org'): + return None + return {'title': title, 'image_url':url, + 'source_url':'https://fr.wikipedia.org/wiki/'+quote(title.replace(' ','_')), + 'credit':'Image : Wikipédia / Wikimedia Commons'} + + +def image_for_question(name): + key = _norm(name) + with LOCK: + old = CACHE.get(key) + if old and time.monotonic()-old[0] < 4*3600: + return old[1] + try: + result = _query_wikipedia(name) + except (requests.RequestException, ValueError, KeyError): + result = None + with LOCK: + CACHE[key] = (time.monotonic(), result) + return result + + +def schedule_context_image(question, answer, socketio, still_current): + name = entity_question(question) + if not name: + return + def worker(): + info = image_for_question(name) + if info and still_current(): + info['text'] = str(answer or '')[:500] + socketio.emit('alfred_context_image', info) + threading.Thread(target=worker, name='alfred-context-image', daemon=True).start() diff --git a/alfred/app/services/alfred_features.py b/alfred/app/services/alfred_features.py new file mode 100644 index 0000000..7354efc --- /dev/null +++ b/alfred/app/services/alfred_features.py @@ -0,0 +1,329 @@ +"""Alfred 4+5: serial Hermes background jobs + dynamic contextual cards. + +Designed to be mounted into an existing Flask app, preserving STT/TTS/Hermes. +No external dependencies except Flask (which Alfred already uses). +Single-process worker: do not run multiple WSGI workers with this module. +""" +from __future__ import annotations + +import json +import logging +import os +import queue +import re +import sqlite3 +import threading +import uuid +from datetime import datetime, timedelta, timezone +from pathlib import Path +from urllib.parse import urlsplit + +from flask import jsonify, request + +LOG = logging.getLogger("alfred.features") +_manager = None +_lock = threading.Lock() + + +def _utc_now(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _expiry(minutes): + return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat(timespec="seconds") + + +def should_run_in_background(text): + """Conservative opt-in for long requests; ordinary voice Q&A unchanged.""" + txt = (text or "").lower() + patterns = [ + r"en arri[eè]re.plan", r"en t[aâ]che de fond", r"en asynchrone", + r"pr[eé]viens.moi (?:quand|lorsque)", r"avertis.moi (?:quand|lorsque)", + r"quand (?:tu auras|c['’]est) fini", r"prends le temps de", + r"fais (?:moi )?un rapport", r"analyse (?:les?|mes?|l['’]) (?:alertes?|logs?|journaux|rapports?|fichiers?|donn[eé]es?)", + r"fais (?:moi )?une synth[eè]se (?:des?|de mes|du)", + ] + return any(re.search(p, txt) for p in patterns) + + +def _sanitize_url(url): + if not isinstance(url, str) or not url or len(url) > 2048: + return None + try: + u = urlsplit(url) + except ValueError: + return None + # Relative paths and HTTP(S) only; block javascript:, data:, file:, etc. + if u.scheme in ("http", "https") and u.netloc: + return url + if not u.scheme and not u.netloc and url.startswith("/") and not url.startswith("//"): + return url + return None + + +def _safe_display(display): + if not isinstance(display, dict): + return {"type": "text", "text": str(display or "")[:12000]} + kind = str(display.get("type", "text"))[:32].lower() + if kind not in {"text", "image", "weather", "calendar", "notification"}: + kind = "text" + obj = {"type": kind} + for key in ("text", "title", "description", "temperature", "condition"): + if key in display: + obj[key] = str(display[key])[:12000 if key == "text" else 300] + if kind == "image": + obj["url"] = _sanitize_url(display.get("url")) + if kind == "calendar": + obj["events"] = [ + {"title": str(e.get("title", "Événement"))[:200], "start": str(e.get("start", ""))[:80]} + for e in (display.get("events") or [])[:30] if isinstance(e, dict) + ] if isinstance(display.get("events"), list) else [] + return obj + + +class FeatureManager: + def __init__(self, db_path, hermes_call, socketio=None, start_worker=True): + self.db_path = str(db_path) + self.hermes_call = hermes_call + self.socketio = socketio + self.guard = threading.RLock() + self.pending = queue.Queue(maxsize=12) + Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) + self._setup_db() + # Cannot resume a subprocess / Hermes interaction after an app restart. + with self._connect() as db: + db.execute("UPDATE tasks SET status='interrupted', updated_at=? WHERE status IN ('queued','running')", (_utc_now(),)) + db.execute("UPDATE cards SET status='interrupted', body='Interrompue par un redémarrage.' WHERE status IN ('queued','running')") + if start_worker: + self.thread = threading.Thread(target=self._work_loop, name="alfred-hermes-worker", daemon=True) + self.thread.start() + + def _connect(self): + db = sqlite3.connect(self.db_path, timeout=10) + db.row_factory = sqlite3.Row + db.execute("PRAGMA busy_timeout=10000") + return db + + def _setup_db(self): + with self._connect() as db: + db.execute("PRAGMA journal_mode=WAL") + db.execute("""CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, + status TEXT NOT NULL, source TEXT NOT NULL, prompt TEXT NOT NULL, + speech TEXT DEFAULT '', display_json TEXT DEFAULT '{}', error TEXT DEFAULT '' + )""") + db.execute("""CREATE TABLE IF NOT EXISTS cards ( + id TEXT PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', status TEXT DEFAULT '', + display_json TEXT DEFAULT '{}', task_id TEXT, + updated_at TEXT NOT NULL, expires_at TEXT + )""") + db.execute("CREATE INDEX IF NOT EXISTS cards_updated ON cards(updated_at)") + try: + os.chmod(self.db_path, 0o600) + except OSError: + LOG.warning("Unable to restrict database file permissions") + + def _emit(self, event, payload): + if self.socketio: + try: + self.socketio.emit(event, payload) + except Exception: + LOG.exception("Websocket emission failed") + + def _task(self, task_id): + with self._connect() as db: + row = db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone() + if not row: + return None + d = dict(row) + d["display"] = json.loads(d.pop("display_json") or "{}") + return d + + def list_tasks(self, limit=30): + with self._connect() as db: + rows = db.execute("SELECT id FROM tasks ORDER BY created_at DESC LIMIT ?", (min(max(int(limit), 1), 100),)).fetchall() + return [self._task(r["id"]) for r in rows] + + def list_cards(self, limit=25): + with self.guard, self._connect() as db: + db.execute("DELETE FROM cards WHERE expires_at IS NOT NULL AND expires_at < ?", (_utc_now(),)) + rows = db.execute("SELECT * FROM cards ORDER BY updated_at DESC LIMIT ?", (min(max(int(limit), 1), 50),)).fetchall() + cards = [] + for row in rows: + d = dict(row) + d["display"] = json.loads(d.pop("display_json") or "{}") + cards.append(d) + return cards + + def upsert_card(self, card_id, *, kind, title, body="", status="", display=None, task_id=None, ttl=60): + try: + ttl = int(ttl) + except (TypeError, ValueError): + ttl = 30 + expires = _expiry(max(1, min(ttl, 60 * 24))) if ttl > 0 else None + display = _safe_display(display or {"type": "text", "text": body}) + with self.guard, self._connect() as db: + db.execute("""INSERT INTO cards(id, kind, title, body, status, display_json, task_id, updated_at, expires_at) + VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET + kind=excluded.kind, title=excluded.title, body=excluded.body, + status=excluded.status, display_json=excluded.display_json, + task_id=excluded.task_id, updated_at=excluded.updated_at, + expires_at=excluded.expires_at""", ( + str(card_id)[:120], str(kind)[:32], str(title)[:180], str(body)[:12000], + str(status)[:32], json.dumps(display, ensure_ascii=False), task_id, _utc_now(), expires + )) + self._emit("alfred_cards_changed", {"at": _utc_now()}) + + def publish_display(self, display): + d = _safe_display(display) + kind = d["type"] + if kind == "text" and not d.get("text", "").strip(): + return + # Weather/calendar replace the previous contextual card. Others are transient. + card_id = f"context-{kind}" if kind in {"weather", "calendar"} else f"context-{uuid.uuid4().hex[:12]}" + title = d.get("title") or {"weather": "Météo", "calendar": "Agenda", "image": "Image", "notification": "Notification"}.get(kind, "Information") + self.upsert_card(card_id, kind=kind, title=title, body=d.get("text", ""), display=d, + ttl=20 if kind == "weather" else (60 if kind == "calendar" else 15)) + + def submit(self, prompt, source="web"): + prompt = str(prompt or "").strip() + if not prompt or len(prompt) > 8000: + raise ValueError("La demande doit contenir entre 1 et 8 000 caractères.") + source = str(source)[:32] + task_id = uuid.uuid4().hex + now = _utc_now() + with self.guard, self._connect() as db: + active = db.execute("SELECT COUNT(*) FROM tasks WHERE status IN ('queued','running')").fetchone()[0] + if active >= 8 or self.pending.full(): + raise ValueError("La file Hermes est pleine (8 tâches maximum).") + db.execute("INSERT INTO tasks(id,created_at,updated_at,status,source,prompt) VALUES (?,?,?,?,?,?)", + (task_id, now, now, "queued", source, prompt)) + self.upsert_card("task-" + task_id, kind="task", title=prompt[:100], body="En attente d’Hermes…", + status="queued", task_id=task_id, ttl=0) + self.pending.put_nowait(task_id) + self._emit("alfred_task", {"id": task_id, "status": "queued"}) + return self._task(task_id) + + def _mark(self, task_id, status, speech="", display=None, error=""): + now = _utc_now() + with self.guard, self._connect() as db: + db.execute("UPDATE tasks SET status=?, updated_at=?, speech=?, display_json=?, error=? WHERE id=?", + (status, now, speech[:12000], json.dumps(display or {}, ensure_ascii=False), error[:1200], task_id)) + task = self._task(task_id) + display = display or {} + body = { + "running": "Hermes traite la demande…", + "succeeded": speech or display.get("text", "Tâche terminée."), + "failed": "Échec : " + error[:900], + }.get(status, status) + self.upsert_card("task-" + task_id, kind="task", title=task["prompt"][:100], + body=body, status=status, display=display or {"type": "text", "text": body}, + task_id=task_id, ttl=0) + self._emit("alfred_task", {"id": task_id, "status": status}) + + def run_one(self, task_id): + self._mark(task_id, "running") + try: + # This executes serially in one worker, respecting Hermes' session semantics. + answer = self.hermes_call(self._task(task_id)["prompt"]) + if isinstance(answer, dict): + speech = str(answer.get("speech") or answer.get("text") or "")[:12000] + display = _safe_display(answer.get("display") or {"type": "text", "text": speech}) + else: + speech = str(answer or "")[:12000] + display = {"type": "text", "text": speech} + self._mark(task_id, "succeeded", speech=speech, display=display) + except Exception as exc: + LOG.exception("Hermes task failed: %s", task_id) + self._mark(task_id, "failed", error=f"{type(exc).__name__}: {exc}") + + def _work_loop(self): + while True: + task_id = self.pending.get() + try: + self.run_one(task_id) + except Exception: + LOG.exception("Unexpected task runner error") + finally: + self.pending.task_done() + + +def _local_writer_allowed(): + # Web UI read-only; explicit write endpoints enabled only from the same host. + # Avoid trusting X-Forwarded-For, which clients can spoof unless configured. + return request.remote_addr in ("127.0.0.1", "::1") + + +def init_app(app, ask_hermes, socketio=None, db_path=None): + global _manager + with _lock: + if _manager is None: + path = db_path or os.getenv("ALFRED_FEATURES_DB") or str(Path(app.root_path) / "data" / "alfred_features.sqlite3") + _manager = FeatureManager(path, ask_hermes, socketio) + + @app.before_request + def _capture_render(): + if request.path == "/api/render" and request.method == "POST": + data = request.get_json(silent=True) + if isinstance(data, dict): + try: + _manager.publish_display(data) + except Exception: + LOG.exception("Unable to save display card") + + @app.get("/api/alfred/tasks") + def _alfred_list_tasks(): + return jsonify({"tasks": _manager.list_tasks()}) + + @app.get("/api/alfred/tasks/") + def _alfred_task_detail(task_id): + task = _manager._task(task_id) + return (jsonify(task), 200) if task else (jsonify({"error": "Tâche introuvable"}), 404) + + @app.post("/api/alfred/tasks") + def _alfred_start_task(): + if not _local_writer_allowed(): + return jsonify({"error": "Écriture réservée à localhost; utilisez la voix d’Alfred."}), 403 + data = request.get_json(silent=True) or {} + try: + task = _manager.submit(data.get("prompt", ""), "api-local") + return jsonify(task), 202 + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + @app.get("/api/alfred/cards") + def _alfred_list_cards(): + return jsonify({"cards": _manager.list_cards()}) + + @app.post("/api/alfred/cards") + def _alfred_add_card(): + if not _local_writer_allowed(): + return jsonify({"error": "Écriture réservée à localhost"}), 403 + data = request.get_json(silent=True) or {} + if not isinstance(data, dict): + return jsonify({"error": "Payload invalide"}), 400 + title = str(data.get("title") or "Information")[:180] + kind = str(data.get("kind") or "notification")[:32] + _manager.upsert_card("manual-" + uuid.uuid4().hex[:12], kind=kind, title=title, + body=str(data.get("body") or "")[:12000], + display=data.get("display"), ttl=int(data.get("ttl", 30))) + return jsonify({"ok": True}), 201 + + return _manager + + +def submit(prompt, source="voice"): + if _manager is None: + raise RuntimeError("Les fonctionnalités Alfred ne sont pas initialisées") + return _manager.submit(prompt, source) + + +def publish_voice_display(display): + """Nonblocking mirror; main renderer and TTS remain authoritative.""" + if _manager is None: + return + try: + _manager.publish_display(display) + except Exception: + LOG.exception("Could not publish conversational display card") diff --git a/alfred/app/services/alfred_timers.py b/alfred/app/services/alfred_timers.py new file mode 100644 index 0000000..20e1fcd --- /dev/null +++ b/alfred/app/services/alfred_timers.py @@ -0,0 +1,297 @@ +"""Minuteurs + chronomètres indépendants d'Hermes ; stockage JSON durable. + +Le temps est exprimé à partir de l'horloge UTC, pour survivre à un redémarrage +(et continuer à avancer pendant l'arrêt de Flask). Les durées sont limitées +pour éviter les mauvaises interprétations des demandes vocales. +""" +from __future__ import annotations + +import json +import os +import re +import secrets +import threading +import time +from pathlib import Path +from urllib.parse import urlsplit + +from flask import Blueprint, jsonify, request + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_PATH = ROOT / 'data' / 'timers.json' +MAX_SECONDS = 30 * 24 * 3600 +MAX_ACTIVE = 20 + + +class TimerError(ValueError): + pass + + +class TimerStore: + def __init__(self, path: Path, clock=None, on_change=None, on_finished=None): + self.path = Path(path) + self.clock = clock or time.time + self.on_change = on_change or (lambda snapshot: None) + self.on_finished = on_finished or (lambda timer: None) + self.lock = threading.RLock() + self.items: dict[str, dict] = {} + self._read() + + def _read(self): + if not self.path.exists(): + return + try: + data = json.loads(self.path.read_text(encoding='utf-8')) + if not isinstance(data, list): + raise ValueError('Mauvais format JSON') + for item in data: + if (isinstance(item, dict) and isinstance(item.get('id'), str) + and item.get('kind') in ('timer', 'stopwatch') + and item.get('state') in ('running', 'paused', 'finished', 'cancelled')): + self.items[item['id']] = item + except (OSError, ValueError, TypeError) as exc: + raise RuntimeError(f'Impossible de charger {self.path} : {exc}') from exc + + def _persist(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_name(self.path.name + '.tmp.' + secrets.token_hex(3)) + try: + with tmp.open('w', encoding='utf-8') as out: + json.dump(list(self.items.values()), out, ensure_ascii=False, indent=2) + out.flush() + os.fsync(out.fileno()) + os.chmod(tmp, 0o600) + os.replace(tmp, self.path) + finally: + tmp.unlink(missing_ok=True) + + @staticmethod + def _duration(number, *, allow_zero=False): + if isinstance(number, bool) or not isinstance(number, (int, float)): + raise TimerError('Durée numérique attendue en secondes.') + if number < (0 if allow_zero else 1) or number > MAX_SECONDS: + raise TimerError('Durée autorisée : de 1 seconde à 30 jours.') + return float(number) + + @staticmethod + def _label(label, kind): + label = ' '.join(str(label or '').split()).strip() + if not label: + label = 'Minuteur' if kind == 'timer' else 'Chronomètre' + if len(label) > 80 or any(ord(x) < 32 for x in label): + raise TimerError('Nom trop long ou invalide (80 caractères maximum).') + return label + + def _view(self, item, now): + duration = item.get('duration') + elapsed = float(item.get('elapsed', 0)) + if item['state'] == 'running': + elapsed += max(0.0, now - float(item['started_at'])) + remaining = max(0.0, duration - elapsed) if duration is not None else None + result = {k: item[k] for k in ('id', 'label', 'kind', 'state', 'created_at')} + result.update(duration=duration, elapsed=round(elapsed, 2), + remaining=round(remaining, 2) if remaining is not None else None) + return result + + def snapshot(self, *, include_finished=False): + with self.lock: + now = self.clock() + self._expire_locked(now) + items = [self._view(x, now) for x in self.items.values() + if include_finished or x['state'] in ('running', 'paused')] + return sorted(items, key=lambda x: (x['state'] != 'running', x['created_at'])) + + def _expire_locked(self, now): + finished = [] + for item in self.items.values(): + if item['state'] != 'running' or item.get('duration') is None: + continue + elapsed = item['elapsed'] + max(0.0, now - item['started_at']) + if elapsed + 0.005 >= item['duration']: + item['elapsed'] = item['duration'] + item['started_at'] = None + item['state'] = 'finished' + item['finished_at'] = now + finished.append(item.copy()) + if finished: + self._persist() + self.on_change(self._unexpired_snapshot(now)) + for item in finished: + self.on_finished(item) + + def _unexpired_snapshot(self, now): + return sorted([self._view(x, now) for x in self.items.values() + if x['state'] in ('running', 'paused')], + key=lambda x: x['created_at']) + + def start(self, kind, seconds=None, label=None): + if kind not in ('timer', 'stopwatch'): + raise TimerError('Type attendu : timer ou stopwatch.') + if kind == 'timer' and seconds is None: + raise TimerError('Il faut préciser la durée du minuteur.') + if seconds is not None: + seconds = self._duration(seconds) + with self.lock: + now = self.clock() + self._expire_locked(now) + if sum(1 for x in self.items.values() if x['state'] in ('running', 'paused')) >= MAX_ACTIVE: + raise TimerError('Maximum de 20 minuteurs ou chronomètres en cours.') + ident = secrets.token_hex(5) + item = {'id': ident, 'label': self._label(label, kind), 'kind': kind, + 'duration': seconds, 'elapsed': 0.0, + 'started_at': now, 'created_at': now, 'state': 'running'} + self.items[ident] = item + # Conserver au plus 80 éléments historiques. + old = sorted((x for x in self.items.values() if x['state'] in ('finished', 'cancelled')), + key=lambda x: x['created_at']) + for x in old[:-80]: + self.items.pop(x['id'], None) + self._persist() + self.on_change(self._unexpired_snapshot(now)) + return self._view(item, now) + + def _resolve(self, ident): + if not isinstance(ident, str): + raise TimerError('Identifiant attendu.') + ident = ident.strip() + if ident in self.items: + return self.items[ident] + candidates = [x for x in self.items.values() + if x['state'] in ('running', 'paused') and + (x['label'].casefold() == ident.casefold() or x['id'].startswith(ident))] + if len(candidates) == 1: + return candidates[0] + if not candidates: + raise TimerError('Minuteur ou chronomètre introuvable.') + raise TimerError('Plusieurs minuteurs portent ce nom : préciser l’identifiant.') + + def action(self, action, ident, seconds=None): + if action not in ('pause', 'resume', 'cancel', 'add_time'): + raise TimerError('Action inconnue.') + with self.lock: + now = self.clock() + self._expire_locked(now) + item = self._resolve(ident) + if item['state'] in ('cancelled', 'finished'): + raise TimerError('Ce minuteur est déjà terminé.') + if action == 'pause': + if item['state'] != 'running': + raise TimerError('Déjà en pause.') + item['elapsed'] += max(0, now - item['started_at']) + item['started_at'] = None + item['state'] = 'paused' + elif action == 'resume': + if item['state'] != 'paused': + raise TimerError('Ce minuteur n’est pas en pause.') + item['started_at'] = now + item['state'] = 'running' + elif action == 'cancel': + if item['state'] == 'running': + item['elapsed'] += max(0, now - item['started_at']) + item['started_at'] = None + item['state'] = 'cancelled' + else: + if item['duration'] is None: + raise TimerError('Chronomètre libre : aucune durée maximale à prolonger.') + amount = self._duration(seconds) + item['duration'] = self._duration(item['duration'] + amount) + self._persist() + self.on_change(self._unexpired_snapshot(now)) + return self._view(item, now) + + def cancel_all(self): + with self.lock: + now = self.clock() + affected = [] + for item in self.items.values(): + if item['state'] not in ('running', 'paused'): + continue + if item['state'] == 'running': + item['elapsed'] += max(0, now - item['started_at']) + item['started_at'] = None + item['state'] = 'cancelled' + affected.append(self._view(item, now)) + if affected: + self._persist() + self.on_change(self._unexpired_snapshot(now)) + return affected + + def tick(self): + with self.lock: + self._expire_locked(self.clock()) + + def run(self, stop_event=None, interval=0.5): + stop_event = stop_event or threading.Event() + while not stop_event.wait(interval): + try: + self.tick() + except Exception as exc: + print('[ALFRED TIMERS] erreur boucle :', repr(exc), flush=True) + + +def register(app, socketio, speak_async, audio_settings): + def changed(items): + socketio.emit('alfred_timers_changed', {'items': items}) + + def finished(item): + payload = {'id': item['id'], 'label': item['label'], 'kind': item['kind']} + socketio.emit('alfred_timer_finished', payload) + if not audio_settings.speaker_muted(): + msg = f"Le minuteur {item['label']} est terminé." if item['kind'] == 'timer' else f"Le chronomètre {item['label']} est terminé." + try: + speak_async(msg) + except Exception as exc: + print('[ALFRED TIMERS] alerte vocale :', repr(exc), flush=True) + + store = TimerStore(Path(os.getenv('ALFRED_TIMERS_DB', str(DEFAULT_PATH))), + on_change=changed, on_finished=finished) + bp = Blueprint('alfred_timers', __name__) + + def safe_post(): + # MCP fonctionne uniquement depuis la boucle locale (aucun accès LAN). + if request.remote_addr in ('127.0.0.1', '::1') and request.headers.get('X-Alfred-MCP-Local') == 'timers': + return True + origin = request.headers.get('Origin') + if request.headers.get('X-Alfred-Request') != '1': + return False + fetch_site = request.headers.get('Sec-Fetch-Site', '').lower() + if fetch_site == 'cross-site': + return False + if not origin: + # Chromium peut omettre Origin sur certaines requêtes même origine. + return fetch_site == 'same-origin' + p = urlsplit(origin) + return p.scheme in ('http','https') and p.netloc.lower() == request.host.lower() + + @bp.get('/api/timers') + def list_timers(): + return jsonify({'items': store.snapshot(), 'server_time': store.clock()}) + + @bp.post('/api/timers/action') + def timers_action(): + if not safe_post(): + return jsonify({'error': 'Requête non autorisée (origine ou en-tête MCP invalide).'}), 403 + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({'error': 'Objet JSON attendu.'}), 400 + command = data.get('action') + try: + if command == 'create_timer': + item = store.start('timer', data.get('seconds'), data.get('label')) + elif command == 'create_stopwatch': + item = store.start('stopwatch', data.get('seconds'), data.get('label')) + elif command == 'cancel_all': + cancelled = store.cancel_all() + return jsonify({'ok': True, 'cancelled': cancelled, 'items': store.snapshot()}) + else: + item = store.action(command, data.get('id'), data.get('seconds')) + return jsonify({'ok': True, 'item': item, 'items': store.snapshot()}) + except TimerError as exc: + return jsonify({'error': str(exc)}), 400 + except OSError as exc: + print('[ALFRED TIMERS] écriture échouée:', repr(exc), flush=True) + return jsonify({'error': 'Stockage des minuteurs indisponible.'}), 503 + + app.register_blueprint(bp) + threading.Thread(target=store.run, name='alfred-timers', daemon=True).start() + return store diff --git a/alfred/app/services/audio.py b/alfred/app/services/audio.py index 390f1d4..753fbf1 100644 --- a/alfred/app/services/audio.py +++ b/alfred/app/services/audio.py @@ -302,6 +302,12 @@ def listen_once(stop_event: Optional[threading.Event] = None, model_path = os.getenv("VOSK_MODEL_PATH", "") sample_rate = int(os.getenv("VOSK_SAMPLE_RATE", "16000")) + # ALFRED_WYOMING_WHISPER_STT_V1: Vosk segments the utterance and + # provides UI partials. Wyoming Whisper replaces only its final transcript. + stt_engine = os.getenv("STT_ENGINE", "vosk").strip().lower() + wyoming_enabled = stt_engine in ("wyoming", "wyoming_whisper", "whisper") + wyoming_audio = bytearray() + wyoming_max_bytes = max(16000, int(os.getenv("STT_MAX_AUDIO_SECONDS", "30")) * sample_rate * 2) if not model_path or not os.path.exists(model_path): return STTResult(error=f"Modèle Vosk introuvable: {model_path}") try: @@ -311,6 +317,9 @@ def listen_once(stop_event: Optional[threading.Event] = None, return STTResult(error=f"Init Vosk: {exc}") def process(data: bytes) -> Optional[str]: + # ALFRED_WYOMING_WHISPER_STT_V1: retain the exact PCM heard by Vosk. + if wyoming_enabled and len(wyoming_audio) < wyoming_max_bytes: + wyoming_audio.extend(data[:wyoming_max_bytes - len(wyoming_audio)]) if on_level and data: try: a = np.frombuffer(data, dtype=np.int16).astype(np.float32) @@ -321,6 +330,25 @@ def listen_once(stop_event: Optional[threading.Event] = None, if rec.AcceptWaveform(data): text = (json.loads(rec.Result() or "{}").get("text") or "").strip() if text: + if wyoming_enabled and wyoming_audio: + # ALFRED_WYOMING_WHISPER_STT_V1: remote final STT, local fallback. + try: + from services.wyoming_stt import transcribe_pcm + whisper_text = transcribe_pcm( + bytes(wyoming_audio), + host=os.getenv("WYOMING_WHISPER_HOST", "192.168.1.13"), + port=int(os.getenv("WYOMING_WHISPER_PORT", "10300")), + language=os.getenv("WYOMING_WHISPER_LANGUAGE", "fr"), + sample_rate=sample_rate, + connect_timeout=float(os.getenv("WYOMING_WHISPER_CONNECT_TIMEOUT", "3")), + response_timeout=float(os.getenv("WYOMING_WHISPER_TIMEOUT", "20")), + ) + if whisper_text: + print(f"[ALFRED STT] Whisper final : {whisper_text!r} (Vosk : {text!r})", flush=True) + return whisper_text + print("[ALFRED STT] Whisper réponse vide, secours Vosk", flush=True) + except Exception as exc: + print(f"[ALFRED STT] Whisper indisponible, secours Vosk : {exc}", flush=True) return text elif on_partial: partial = json.loads(rec.PartialResult() or "{}").get("partial", "") diff --git a/alfred/app/services/wyoming_stt.py b/alfred/app/services/wyoming_stt.py new file mode 100644 index 0000000..7b14b0b --- /dev/null +++ b/alfred/app/services/wyoming_stt.py @@ -0,0 +1,98 @@ +"""Minimal Wyoming TCP ASR client for Alfred. + +No external Wyoming Python dependency. Transport: PCM signed 16-bit LE mono, +16 kHz by default, following the official transcribe/audio-start/chunk/stop flow. +""" +from __future__ import annotations + +import json +import socket +from typing import Optional + + +class WyomingSTTError(RuntimeError): + pass + + +def _event(kind: str, data: Optional[dict] = None, payload: bytes = b"") -> bytes: + header = {"type": kind} + if data is not None: + header["data"] = data + if payload: + header["payload_length"] = len(payload) + return (json.dumps(header, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8") + payload + + +def _read_event(stream) -> tuple[str, dict]: + header_bytes = stream.readline(65537) + if not header_bytes: + raise WyomingSTTError("Connexion Wyoming fermée sans transcription") + if len(header_bytes) > 65536 or not header_bytes.endswith(b"\n"): + raise WyomingSTTError("En-tête Wyoming invalide ou trop long") + try: + header = json.loads(header_bytes) + if not isinstance(header, dict): + raise ValueError("en-tête non objet") + data_length = int(header.get("data_length", 0)) + payload_length = int(header.get("payload_length", 0)) + if not 0 <= data_length <= 65536 or not 0 <= payload_length <= 1_048_576: + raise ValueError("taille d'événement excessive") + data = header.get("data") or {} + if not isinstance(data, dict): + raise ValueError("données non objet") + if data_length: + extra_data = stream.read(data_length) + if len(extra_data) != data_length: + raise WyomingSTTError("Données Wyoming incomplètes") + more = json.loads(extra_data) + if not isinstance(more, dict): + raise ValueError("données additionnelles non objet") + data.update(more) + if payload_length: + payload = stream.read(payload_length) + if len(payload) != payload_length: + raise WyomingSTTError("Charge utile Wyoming incomplète") + return str(header["type"]), data + except (KeyError, ValueError, TypeError, json.JSONDecodeError) as exc: + raise WyomingSTTError(f"Message Wyoming incorrect : {exc}") from exc + + +def transcribe_pcm( + pcm: bytes, + *, + host: str = "192.168.1.13", + port: int = 10300, + language: str = "fr", + sample_rate: int = 16000, + connect_timeout: float = 3.0, + response_timeout: float = 20.0, +) -> str: + """Send recorded S16LE/mono PCM to Wyoming ASR and return the transcript.""" + if not pcm or len(pcm) % 2: + raise WyomingSTTError("Audio PCM vide ou mal aligné") + if sample_rate <= 0: + raise WyomingSTTError("Fréquence PCM invalide") + if not 0 < response_timeout <= 300: + raise WyomingSTTError("Délai Wyoming invalide") + + try: + with socket.create_connection((host, int(port)), timeout=connect_timeout) as sock: + sock.settimeout(response_timeout) + sock.sendall(_event("transcribe", {"language": language})) + audio_meta = {"rate": int(sample_rate), "width": 2, "channels": 1} + sock.sendall(_event("audio-start", audio_meta)) + for offset in range(0, len(pcm), 16000): + chunk = pcm[offset : offset + 16000] + sock.sendall(_event("audio-chunk", audio_meta, chunk)) + sock.sendall(_event("audio-stop")) + with sock.makefile("rb") as stream: + # Some servers send informational events before the transcript. + for _ in range(16): + kind, data = _read_event(stream) + if kind == "transcript": + return str(data.get("text") or "").strip() + if kind == "error": + raise WyomingSTTError(str(data.get("text") or data.get("message") or data)) + raise WyomingSTTError("Aucune transcription après 16 événements Wyoming") + except (OSError, TimeoutError) as exc: + raise WyomingSTTError(f"Serveur Whisper {host}:{port} indisponible : {exc}") from exc diff --git a/alfred/app/static/css/alfred-features.css b/alfred/app/static/css/alfred-features.css new file mode 100644 index 0000000..ff0513e --- /dev/null +++ b/alfred/app/static/css/alfred-features.css @@ -0,0 +1,34 @@ +/* Additive CSS: activity dock occupies the right side below the audio controls. */ +.alfred-feature-dock { + position: fixed; z-index: 110; top: 104px; right: 16px; + width: min(370px, 34vw); max-height: calc(100dvh - 220px); + overflow: hidden; display: flex; flex-direction: column; + background: rgba(19, 24, 32, .94); color: #e7eef7; + border: 1px solid rgba(104, 159, 216, .30); border-radius: 15px; + box-shadow: 0 10px 44px rgba(0,0,0,.36); + font-family: system-ui, -apple-system, sans-serif; font-size: 13px; + backdrop-filter: blur(12px); +} +.alfred-feature-header { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid rgba(255,255,255,.08); } +.alfred-feature-toggle { background: transparent; color: #dceafe; border: 0; font-weight: 650; font: inherit; cursor: pointer; text-align: left; flex: 1; padding: 3px; } +.alfred-feature-count { color:#92c5fc; border: 1px solid rgba(113,175,255,.3); border-radius: 99px; padding: 1px 7px; font-variant-numeric: tabular-nums; } +.alfred-feature-contents { min-height: 44px; max-height: calc(100dvh - 280px); overflow: auto; padding: 8px; } +.alfred-feature-collapsed { width: 190px; } +.alfred-feature-collapsed .alfred-feature-header { border-bottom: 0; } +.alfred-feature-collapsed .alfred-feature-contents { display: none; } +.alfred-feature-card { padding: 11px 12px; margin-bottom: 9px; background: rgba(35,44,56,.95); border: 1px solid rgba(160,180,220,.13); border-radius: 10px; overflow-wrap: anywhere; } +.alfred-feature-card-header { display: flex; align-items: flex-start; gap: 8px; } +.alfred-feature-card-title { flex: 1; font-size: 13px; font-weight: 650; line-height: 1.45; } +.alfred-feature-status { flex: none; padding: 2px 6px; font-size: 11px; border-radius: 9px; background: #445065; color:#f5f5f5; } +.alfred-feature-status.running { color: #c3e3ff; background:#245074; } +.alfred-feature-status.succeeded { color: #b8e7d0; background:#234c3a; } +.alfred-feature-status.failed, .alfred-feature-status.interrupted { color:#ffd5d5; background:#75383a; } +.alfred-feature-body, .alfred-feature-detail { margin: 9px 0 5px; color:#d9e2ed; line-height: 1.5; white-space: pre-wrap; } +.alfred-feature-detail { font-size: 12px; color: #b8d1ec; } +.alfred-feature-image { display:block; width: 100%; max-height: 230px; object-fit: contain; margin: 8px 0; border-radius: 8px; } +.alfred-feature-weather { color: #f0cd88; font-size: 16px; } +.alfred-feature-event { padding: 5px 0; border-top: 1px solid rgba(255,255,255,.07); } +.alfred-feature-footer { display:flex; align-items:center; justify-content:space-between; margin-top:8px; color:#91a7c0; font-size: 11px; } +.alfred-feature-more, .alfred-feature-speak { background: rgba(70,119,175,.17); border: 1px solid rgba(118,173,230,.35); color:#d2e8ff; border-radius:7px; padding:3px 9px; cursor:pointer; } +.alfred-feature-empty { color:#9db2c8; padding: 0 6px; } +@media (max-width: 720px) { .alfred-feature-dock { top: 86px; right: 8px; width: min(310px, 82vw); max-height: calc(100dvh - 190px); } .alfred-feature-contents { max-height: calc(100dvh - 255px); } } diff --git a/alfred/app/static/css/alfred-timers-context.css b/alfred/app/static/css/alfred-timers-context.css new file mode 100644 index 0000000..48dedb8 --- /dev/null +++ b/alfred/app/static/css/alfred-timers-context.css @@ -0,0 +1,21 @@ +/* Dock discrets : la caméra reste accessible via son bouton popup. */ +#alfredTimersDock[hidden],#alfredContextPopup[hidden],#alfredTimerToast[hidden]{display:none!important} +#alfredTimersDock{position:fixed;left:36px;bottom:32px;z-index:5;width:min(315px,30vw);max-height:min(46vh,380px);overflow-y:auto;border:1px solid #ffffff25;background:#242a2fe8;border-radius:17px;padding:12px 14px;box-shadow:0 15px 45px #0005;backdrop-filter:blur(14px);color:#eef3f5} +.alfred-time-head{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:650;color:#bdd5df;padding:0 0 8px;border-bottom:1px solid #ffffff15} +.alfred-time-card{display:grid;grid-template-columns:1fr auto;gap:6px 4px;align-items:center;padding:9px 0;border-bottom:1px solid #ffffff11} +.alfred-time-card:last-child{border-bottom:0} +.alfred-time-label{font-size:12px;color:#e3ebed;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;grid-column:1/3} +.alfred-time-value{font-size:25px;font-variant-numeric:tabular-nums;letter-spacing:.02em;font-weight:550} +.alfred-time-value.is-paused{color:#e2c183} +.alfred-time-actions{display:flex;gap:5px} +.alfred-time-icon{color:#dae4e8;background:#ffffff10;border:1px solid #ffffff1e;border-radius:8px;width:32px;height:31px;cursor:pointer;font-size:18px} +.alfred-time-icon:hover{background:#ffffff2a} +#alfredTimerToast{position:fixed;bottom:205px;left:36px;max-width:350px;background:#354a4b;border:1px solid #96d3c8;border-radius:12px;padding:13px 16px;color:#f1faf9;z-index:40;box-shadow:0 15px 40px #0008} +#alfredContextPopup{position:fixed;inset:0;display:grid;place-items:center;background:#05090dcc;backdrop-filter:blur(4px);z-index:48;padding:20px} +.alfred-context-card{position:relative;display:grid;grid-template-columns:minmax(160px,280px) minmax(220px,380px);gap:24px;align-items:center;max-width:min(92vw,740px);max-height:90vh;overflow:auto;border:1px solid #ffffff35;border-radius:20px;background:#252d32;padding:25px;box-shadow:0 26px 80px #000b} +#alfredContextImage{display:block;width:100%;height:280px;object-fit:contain;border-radius:12px;background:#14191d} +.alfred-context-details h2{font-size:clamp(21px,2vw,31px);line-height:1.2;margin:0 0 15px;color:#f3f7f9} +.alfred-context-details p{font-size:14px;color:#d3dfe4;line-height:1.6;max-height:225px;overflow-y:auto;white-space:pre-wrap} +.alfred-context-details a{color:#a0d9f0;font-size:12px;text-decoration:underline} +.alfred-context-close{position:absolute;right:12px;top:12px;border:1px solid #ffffff2c;background:#414b50;color:#f3f5f6;border-radius:10px;cursor:pointer;width:33px;height:33px;font-size:23px} +@media(max-width:850px){#alfredTimersDock{width:255px;left:14px;bottom:15px;max-height:36vh}#alfredTimerToast{left:14px;bottom:180px}.alfred-context-card{grid-template-columns:1fr;gap:12px;max-width:420px}#alfredContextImage{height:220px}} diff --git a/alfred/app/static/js/alfred-features.js b/alfred/app/static/js/alfred-features.js new file mode 100644 index 0000000..e495658 --- /dev/null +++ b/alfred/app/static/js/alfred-features.js @@ -0,0 +1,172 @@ +/* Alfred V4+V5: visual activity dock. Does not alter the existing interface. */ +(() => { + 'use strict'; + const CSS = 'alfred-feature-'; + const state = { cards: [], collapsed: false, lastStatus: new Map(), receivedOnce: false }; + const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = CSS + className; + if (text != null) node.textContent = String(text); + return node; + }; + const root = el('aside', 'dock'); + root.setAttribute('aria-label', 'Activité et informations d’Alfred'); + const header = el('header', 'header'); + const toggle = el('button', 'toggle', 'Activité Hermes'); + toggle.type = 'button'; + toggle.setAttribute('aria-expanded', 'true'); + const count = el('span', 'count', '0'); + const contents = el('section', 'contents'); + contents.setAttribute('aria-live', 'polite'); + contents.setAttribute('aria-relevant', 'additions text'); + header.append(toggle, count); + root.append(header, contents); + document.body.append(root); + + const toggleDock = (collapse) => { + state.collapsed = collapse; + root.classList.toggle(CSS + 'collapsed', collapse); + toggle.setAttribute('aria-expanded', String(!collapse)); + toggle.textContent = collapse ? '☰ Activité Hermes' : '⌄ Activité Hermes'; + }; + toggle.addEventListener('click', () => toggleDock(!state.collapsed)); + + const addText = (parent, cls, text, maxlength=1600) => { + if (text === undefined || text === null || String(text).trim() === '') return; + const value = String(text); + if (value.length <= maxlength) { + parent.append(el('p', cls, value)); + return; + } + const p = el('p', cls, value.slice(0, maxlength) + '…'); + const button = el('button', 'more', 'Afficher tout'); + button.type = 'button'; + let open = false; + button.addEventListener('click', () => { + open = !open; + p.textContent = open ? value : value.slice(0, maxlength) + '…'; + button.textContent = open ? 'Réduire' : 'Afficher tout'; + }); + parent.append(p, button); + }; + + const safeImage = url => { + if (typeof url !== 'string') return null; + try { + const parsed = new URL(url, location.origin); + return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : null; + } catch { return null; } + }; + const displayExtra = (item, display) => { + if (!display || typeof display !== 'object') return; + const kind = display.type; + if (kind === 'image' && display.url) { + const src = safeImage(display.url); + if (src) { + const img = el('img', 'image'); + img.src = src; + img.alt = display.title || 'Illustration Alfred'; + img.loading = 'lazy'; + item.append(img); + } + } else if (kind === 'weather') { + const line = [display.temperature, display.condition || display.description].filter(Boolean).join(' · '); + addText(item, 'weather', line, 300); + } else if (kind === 'calendar' && Array.isArray(display.events)) { + for (const event of display.events.slice(0, 6)) { + addText(item, 'event', [event.start, event.title].filter(Boolean).join(' — '), 300); + } + } + if (display.text && display.text !== item.dataset.body) addText(item, 'detail', display.text); + }; + const STATUS_LABELS = { + queued: 'En attente', running: 'En cours', succeeded: 'Terminé', + failed: 'Erreur', interrupted: 'Interrompu' + }; + + function render() { + contents.replaceChildren(); + count.textContent = String(state.cards.length); + if (!state.cards.length) { + contents.append(el('p', 'empty', 'Aucune activité pour le moment.')); + return; + } + state.cards.forEach(card => { + const item = el('article', 'card'); + item.dataset.body = card.body || ''; + const heading = el('div', 'card-header'); + const title = el('strong', 'card-title', card.title || 'Information'); + heading.append(title); + if (card.status) { + const status = el('span', 'status ' + (card.status || ''), STATUS_LABELS[card.status] || card.status); + heading.append(status); + } + item.append(heading); + const body = card.body || ''; + if (body && (!card.display?.text || card.display.text !== body)) addText(item, 'body', body); + displayExtra(item, card.display); + if (body && card.display?.text === body) addText(item, 'body', body); + const footer = el('div', 'footer'); + if (card.updated_at) { + const date = new Date(card.updated_at); + footer.append(el('time', 'date', isNaN(date.getTime()) ? '' : date.toLocaleTimeString('fr-FR', {hour:'2-digit', minute:'2-digit'}))); + } + if (card.status === 'succeeded' && body) { + const say = el('button', 'speak', 'Lire'); + say.type = 'button'; + say.title = 'Faire lire le résultat par la voix Piper configurée'; + say.addEventListener('click', async () => { + say.disabled = true; + try { + const response = await fetch('/api/tts/say', { + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({text:body.slice(0,1800)}) + }); + if (!response.ok) throw new Error('TTS indisponible'); + } catch (error) { say.title = String(error); } + finally { say.disabled = false; } + }); + footer.append(say); + } + item.append(footer); + contents.append(item); + }); + } + let fetching = false; + async function refresh() { + if (fetching) return; + fetching = true; + try { + const response = await fetch('/api/alfred/cards', {cache:'no-store'}); + if (!response.ok) throw new Error('Cartes indisponibles'); + const payload = await response.json(); + const cards = Array.isArray(payload.cards) ? payload.cards : []; + const sig = JSON.stringify(cards); + if (sig === state.signature) return; + state.signature = sig; + const hasNewCompletion = cards.some(card => + card.status === 'succeeded' && state.lastStatus.has(card.id) && + state.lastStatus.get(card.id) !== 'succeeded'); + const hasNewCard = cards.some(card => !state.lastStatus.has(card.id)); + state.lastStatus = new Map(cards.map(c => [c.id, c.status])); + state.cards = cards; + render(); + if (!state.receivedOnce) { + state.receivedOnce = true; + toggleDock(cards.length === 0); + } else if (hasNewCompletion || hasNewCard) { + toggleDock(false); + } + } catch (error) { + if (!state.receivedOnce) contents.replaceChildren(el('p', 'empty', 'Connexion aux cartes indisponible.')); + } finally { fetching = false; } + } + refresh(); + setInterval(refresh, 10000); // safety fallback; Socket.IO wakes immediately + if (typeof window.io === 'function') { + const socket = window.io(); + socket.on('alfred_cards_changed', refresh); + socket.on('alfred_task', refresh); + socket.on('connect', refresh); + } +})(); diff --git a/alfred/app/static/js/alfred-hide-hermes-activity.js b/alfred/app/static/js/alfred-hide-hermes-activity.js new file mode 100644 index 0000000..cdb8617 --- /dev/null +++ b/alfred/app/static/js/alfred-hide-hermes-activity.js @@ -0,0 +1,132 @@ +/* ALFRED_HIDE_HERMES_ACTIVITY_V2 + Supprime uniquement l'ancienne vue "Activité Hermes" du DOM visuel. + N'interrompt ni les taches de fond, ni le MCP, ni les evenements Socket.IO. +*/ +(() => { + 'use strict'; + + const TITLE = /\bactivit[eé]\s+hermes\b/i; + const EXACT_TITLE = /^\s*[⌄˅∨▾▿▼v]?\s*activit[eé]\s+hermes\s*(?:\d+)?\s*$/i; + const SPECIFIC_SELECTOR = [ + '#hermesActivityPopup', '#hermesActivityModal', + '[aria-label="Activité Hermes"]', '[aria-label="Activite Hermes"]', + '[data-component="hermes-activity"]', '[data-widget="hermes-activity"]', + '.hermes-activity-panel', '.hermes-activity-popup', + '.hermes-activity-widget', '.hermes-activity-dock', + '.hermes-task-panel', '.hermes-tasks-panel' + ].join(','); + const CLASS_HINT = /(?:hermes.{0,30}(?:activit|activity|tasks?|jobs?)|(?:activit|activity|tasks?|jobs?).{0,30}hermes)/i; + const PANEL_HINT = /(?:activit|activity|tasks?|jobs?|queue|history|panel|widget|dock|drawer|popover|sidebar)/i; + + function markHidden(element) { + if (!element || !element.isConnected || element === document.body) return; + if (!element.hasAttribute('data-alfred-hermes-hidden')) { + element.setAttribute('data-alfred-hermes-hidden', 'true'); + } + // Important : certains widgets reinitialisent leur attribut hidden ou display. + if (!element.hidden) element.hidden = true; + if (element.style.getPropertyValue('display') !== 'none' || + element.style.getPropertyPriority('display') !== 'important') { + element.style.setProperty('display', 'none', 'important'); + } + } + + function isTitle(text) { + return EXACT_TITLE.test(String(text || '').replace(/\u00a0/g, ' ').trim()); + } + + function isSafeCandidate(el) { + if (!el || !el.isConnected) return false; + if (el.matches('html,body,main,.alfred-shell,#stage,#content')) return false; + if (el.contains(document.querySelector('#stage')) && document.querySelector('#stage')) return false; + if (el.contains(document.querySelector('#alfredTimersDock')) && document.querySelector('#alfredTimersDock')) return false; + return true; + } + + function choosePanel(titleNode) { + // La capture montre un dock : le titre est accompagne d'un compteur et + // d'une carte de tache (badge "Termine", bouton "Lire"). + const ancestors = []; + let el = titleNode.nodeType === 1 ? titleNode : titleNode.parentElement; + for (let depth = 0; el && depth < 10; depth++, el = el.parentElement) { + if (!isSafeCandidate(el)) break; + ancestors.push(el); + } + if (!ancestors.length) return null; + + // Quand l'identifiant ou la classe indiquent explicitement le widget, + // ces conteneurs sont preferes au contenu de la carte. + for (const candidate of ancestors) { + const identity = `${candidate.id || ''} ${typeof candidate.className === 'string' ? candidate.className : ''}`; + if (CLASS_HINT.test(identity)) return candidate; + if (candidate.matches('[role="dialog"],[role="complementary"]') && TITLE.test(candidate.textContent || '')) return candidate; + } + + // Cas general (et cas de la capture) : choisir le plus petit ancetre + // suffisamment grand pour contenir l'entete ET la liste des taches. + for (const candidate of ancestors) { + const allText = (candidate.innerText || candidate.textContent || '').trim(); + if (allText.length > 15000) continue; // l'ancetre contient deja le titre detecte + const hasTask = /\b(?:termin[eé]|en\s+cours|en\s+attente|erreur|lire|annuler|rapport)\b/i.test(allText); + const childCount = candidate.children ? candidate.children.length : 0; + if (!hasTask || childCount < 2) continue; + const rect = candidate.getBoundingClientRect(); + if (rect.width >= 180 && rect.height >= 100 && + rect.width <= Math.max(window.innerWidth * 0.82, 600) && + rect.height <= window.innerHeight * 1.5) { + return candidate; + } + } + + // Panneau vide : se baser sur la bordure/classe du conteneur, sans cacher + // un parent generic (page entiere, scene, panneau de transcription). + for (const candidate of ancestors) { + const identity = `${candidate.id || ''} ${typeof candidate.className === 'string' ? candidate.className : ''}`; + if (!PANEL_HINT.test(identity)) continue; + const rect = candidate.getBoundingClientRect(); + if (rect.width >= 150 && rect.width <= Math.max(window.innerWidth * 0.82, 600)) return candidate; + } + return null; + } + + function hideActivity() { + for (const el of document.querySelectorAll(SPECIFIC_SELECTOR)) { + if (isSafeCandidate(el)) markHidden(el); + } + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + let textNode; + while ((textNode = walker.nextNode())) { + if (!textNode.parentElement || textNode.parentElement.closest('[data-alfred-hermes-hidden]')) continue; + if (!isTitle(textNode.nodeValue)) continue; + const panel = choosePanel(textNode); + if (panel) markHidden(panel); + } + } + + let queued = false; + function scheduleHide() { + if (queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + if (document.body) hideActivity(); + }); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + hideActivity(); + observe(); + }, {once:true}); + } else { + hideActivity(); + observe(); + } + function observe() { + const observer = new MutationObserver(scheduleHide); + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true + }); + } +})(); diff --git a/alfred/app/static/js/alfred-timers-context.js b/alfred/app/static/js/alfred-timers-context.js new file mode 100644 index 0000000..00d88fb --- /dev/null +++ b/alfred/app/static/js/alfred-timers-context.js @@ -0,0 +1,157 @@ +/* ALFRED_TIMERS_CONTEXT_V1 — chronos/minuteurs dans un coin, illustrations en popup. */ +(() => { + 'use strict'; + const root = document.getElementById('alfredTimersDock'); + const list = document.getElementById('alfredTimersList'); + const context = document.getElementById('alfredContextPopup'); + const contextImage = document.getElementById('alfredContextImage'); + const contextTitle = document.getElementById('alfredContextTitle'); + const contextText = document.getElementById('alfredContextText'); + const contextSource = document.getElementById('alfredContextSource'); + const contextClose = document.getElementById('alfredContextClose'); + const toast = document.getElementById('alfredTimerToast'); + if (!root || !list) return; + let items = []; + let anchorMs = Date.now(); + let lastSnapshotClientMs = Date.now(); + let toastTimeout = null; + + function format(seconds) { + const s = Math.max(0, Math.floor(seconds)); + const h = Math.floor(s / 3600); + const m = Math.floor(s % 3600 / 60); + const sec = s % 60; + return h > 0 ? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}` : + `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`; + } + function textNode(tag, cls, text) { + const el = document.createElement(tag); + if (cls) el.className = cls; + if (text !== undefined) el.textContent = text; + return el; + } + async function action(op, id, seconds) { + const payload = {action:op, id}; + if (seconds !== undefined) payload.seconds = seconds; + const r = await fetch('/api/timers/action', {method:'POST', + headers:{'Content-Type':'application/json', 'X-Alfred-Request':'1'}, + body:JSON.stringify(payload)}); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`); + update(data.items || []); + } + function update(next) { + items = Array.isArray(next) ? next : []; + lastSnapshotClientMs = Date.now(); + list.replaceChildren(); + root.hidden = !items.length; + for (const item of items) { + const card = textNode('div','alfred-time-card'); + card.dataset.itemId = item.id; + const label = textNode('span','alfred-time-label', (item.kind === 'timer' ? '⌛ ' : '⏱ ') + item.label); + const counter = textNode('strong','alfred-time-value','00:00'); + counter.dataset.kind = item.kind; + card.append(label,counter); + const actions = textNode('div','alfred-time-actions'); + const pause = textNode('button','alfred-time-icon', item.state === 'paused' ? '▶' : 'Ⅱ'); + pause.type='button'; pause.title=item.state === 'paused' ? 'Reprendre':'Mettre en pause'; + pause.setAttribute('aria-label',pause.title); + pause.onclick=()=>action(item.state==='paused'?'resume':'pause',item.id).catch(showError); + const stop=textNode('button','alfred-time-icon','×'); + stop.type='button'; stop.title='Annuler'; stop.setAttribute('aria-label','Annuler '+item.label); + stop.onclick=()=>action('cancel',item.id).catch(showError); + actions.append(pause,stop);card.append(actions);list.append(card); + } + redraw(); + } + function redraw() { + const delta = Math.max(0, (Date.now()-lastSnapshotClientMs)/1000); + for (const item of items) { + const node=[...list.querySelectorAll('[data-item-id]')].find(el=>el.dataset.itemId===item.id); + if (!node) continue; + let elapsed=Number(item.elapsed)||0; + if(item.state==='running') elapsed+=delta; + const seconds = item.kind==='timer' ? Math.max(0,(Number(item.duration)||0)-elapsed) : + elapsed; + const counter=node.querySelector('.alfred-time-value'); + counter.textContent=format(seconds); + counter.classList.toggle('is-paused',item.state==='paused'); + if(item.kind==='stopwatch' && item.duration!==null) + counter.title='Limite '+format(item.duration); + } + } + function notify(message) { + if (!toast) return; + toast.textContent=message;toast.hidden=false; + clearTimeout(toastTimeout); + toastTimeout=setTimeout(()=>{toast.hidden=true;},9000); + } + function showError(error) {notify(error.message || String(error));} + async function refresh() { + try { + const r=await fetch('/api/timers',{cache:'no-store'}); + if(!r.ok) throw new Error(`HTTP ${r.status}`); + const data=await r.json(); update(data.items || []); + } catch (err) { console.warn('[ALFRED TIMERS]',err); } + } + const socket = window.alfredSocket; + socket?.on('alfred_timers_changed', data => update(data.items || [])); + socket?.on('alfred_timer_finished', data => { + notify(`⏰ ${data.label} : terminé !`); + refresh(); + }); + socket?.on('connect', refresh); + refresh(); + setInterval(redraw, 250); + setInterval(()=>{if(!document.hidden)refresh();}, 15000); + + // Illustration des demandes « qui est ... ? » : source = Wikipédia, réponse = Hermes. + function closeContext() { + if (context) context.hidden=true; + if (contextImage) contextImage.removeAttribute('src'); + } + if (context && contextImage && contextTitle) { + socket?.on('alfred_context_image', data => { + try { + const image = new URL(data.image_url); + const source = new URL(data.source_url); + if (image.protocol !== 'https:' || !['upload.wikimedia.org','commons.wikimedia.org'].includes(image.hostname))return; + if (source.protocol !== 'https:' || source.hostname !== 'fr.wikipedia.org')return; + contextTitle.textContent = String(data.title || 'Illustration'); + contextText.textContent = String(data.text || '').slice(0,500); + contextImage.src = image.href; + contextSource.href = source.href; + contextSource.textContent = String(data.credit || 'Source : Wikipédia'); + context.hidden=false; + } catch (err) { console.warn('[ALFRED CONTEXT]',err); } + }); + contextClose?.addEventListener('click', closeContext); + context.addEventListener('pointerdown', e => {if(e.target === context)closeContext();}); + document.addEventListener('keydown', e=>{if(e.key === 'Escape' && !context.hidden)closeContext();}); + } + + // Masque uniquement les anciennes fenêtres intitulées exactement « Activité Hermes ». + // N'interfère ni avec la session Hermes ni avec ses réponses dans la scène. + function hideLegacyHermesActivity() { + const titlePattern=/^activit[eé]\s+hermes$/i; + for (const el of document.querySelectorAll('h1,h2,h3,h4,[role="heading"],.popup-title,.modal-title')) { + if (!titlePattern.test((el.textContent || '').trim()))continue; + const popup = el.closest('[role="dialog"],.modal,.popup,.popover,.overlay,.hermes-activity-panel,.hermes-activity-popup'); + if (popup && popup !== document.body) { + popup.hidden = true; + popup.style.display = 'none'; + } + } + for (const el of document.querySelectorAll('[aria-label="Activité Hermes"],[aria-label="Activite Hermes"],#hermesActivityPopup,#hermesActivityModal')) { + el.hidden=true;el.style.display='none'; + } + } + hideLegacyHermesActivity(); + let pending=false; + const observer = new MutationObserver(() => { + if(pending)return; + pending=true; + queueMicrotask(()=>{pending=false;hideLegacyHermesActivity();}); + }); + observer.observe(document.body,{childList:true,subtree:true}); +})(); diff --git a/alfred/app/templates/index.html b/alfred/app/templates/index.html index 517b3d8..4422045 100644 --- a/alfred/app/templates/index.html +++ b/alfred/app/templates/index.html @@ -10,6 +10,8 @@ + +
@@ -156,5 +158,30 @@ + + + + + + + + + + diff --git a/alfred/app/templates/index.html.bak.hide_hermes.20261027_145452 b/alfred/app/templates/index.html.bak.hide_hermes.20261027_145452 new file mode 100644 index 0000000..6fc0409 --- /dev/null +++ b/alfred/app/templates/index.html.bak.hide_hermes.20261027_145452 @@ -0,0 +1,185 @@ + + + + + + + {{ name }} + + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + diff --git a/alfred/app/templates/index.html.bak.timers_context.20261027_123527 b/alfred/app/templates/index.html.bak.timers_context.20261027_123527 new file mode 100644 index 0000000..2095c86 --- /dev/null +++ b/alfred/app/templates/index.html.bak.timers_context.20261027_123527 @@ -0,0 +1,162 @@ + + + + + + + {{ name }} + + + + + + + + +
+
+ +
+ + En attente +
+ +
+ + ⚙ + ▤ + + + + + + + + + + + +
+
+ + + + + + +
+

Bonjour.

À votre écoute.

+
+ + + + + + + +
+ + + + + + + + + + diff --git a/hermes/MCP_alfred_timers/timers_mcp.py b/hermes/MCP_alfred_timers/timers_mcp.py new file mode 100755 index 0000000..b55b0e6 --- /dev/null +++ b/hermes/MCP_alfred_timers/timers_mcp.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Serveur MCP stdio autonome pour Hermes, sans installation pip. + +Protocole JSON-RPC 2.0 (MCP legacy 2025-03/2025-06), messages JSONL. +Le serveur ne gère pas l'horloge ; il délègue à Alfred sur 127.0.0.1:54321. +""" +from __future__ import annotations +import json +import os +import sys +from urllib import request, error + +URL = os.getenv('ALFRED_TIMERS_API', 'http://127.0.0.1:54321').rstrip('/') + + +def tool(name, desc, props=None, required=None): + return {'name': name, 'description': desc, + 'inputSchema': {'type': 'object', 'properties': props or {}, 'required': required or [], 'additionalProperties': False}} + + +def seconds_prop(desc): + return {'type': 'number', 'exclusiveMinimum': 0, 'maximum': 2592000, 'description': desc} + + +def label_prop(): + return {'type': 'string', 'maxLength': 80, 'description': 'Nom facultatif pour reconnaître le minuteur (ex. pâtes, cuisson).'} + + +def id_prop(): + return {'type': 'string', 'description': "Identifiant ou nom unique renvoyé par list_timers ou create_timer."} + + +TOOLS = [ + tool('create_timer', 'Crée un minuteur qui compte à rebours. Convertis les minutes/heures en secondes. Ex : 5 minutes = 300 secondes. Une alerte vocale est déclenchée à zéro.', {'seconds': seconds_prop('Durée demandée en secondes'), 'label': label_prop()}, ['seconds']), + tool('create_stopwatch', 'Démarre un chronomètre qui compte à partir de zéro ; si l’utilisateur dit « chrono de 10 minutes », fournir 600 secondes pour l’arrêter avec une alerte après 10 minutes. Sinon omettre seconds pour un chrono libre.', {'seconds': seconds_prop('Durée limite facultative, en secondes'), 'label': label_prop()}), + tool('list_timers', 'Liste minuteurs/chronomètres en cours et le temps restant ou écoulé.'), + tool('cancel_all_timers', 'Annule TOUS les minuteurs et chronomètres en cours sur demande explicite de l’utilisateur.'), + tool('pause_timer', 'Met en pause le minuteur ou le chronomètre sélectionné.', {'id': id_prop()}, ['id']), + tool('resume_timer', 'Reprend un minuteur ou chronomètre en pause.', {'id': id_prop()}, ['id']), + tool('cancel_timer', 'Annule le minuteur ou le chronomètre sélectionné.', {'id': id_prop()}, ['id']), + tool('add_time', 'Ajoute du temps à un minuteur ou un chrono avec durée limite. Ex : ajoute 2 minutes = 120 secondes.', {'id': id_prop(), 'seconds': seconds_prop('Temps supplémentaire en secondes')}, ['id','seconds']), +] + + +def api_call(action=None, args=None): + if action is None: + req = request.Request(URL + '/api/timers', headers={'Accept': 'application/json'}) + else: + data = json.dumps({'action': action, **(args or {})}).encode('utf-8') + req = request.Request(URL + '/api/timers/action', data=data, method='POST', headers={'Content-Type': 'application/json', 'X-Alfred-MCP-Local': 'timers'}) + try: + with request.urlopen(req, timeout=7) as response: + return json.loads(response.read()) + except error.HTTPError as exc: + try: + detail = json.loads(exc.read()).get('error', '') + except Exception: + detail = '' + raise ValueError(detail or f'HTTP {exc.code}') from exc + except (error.URLError, TimeoutError) as exc: + raise ValueError(f'Alfred indisponible sur {URL} : {exc}') from exc + + +def invoke(name, arguments): + if not isinstance(arguments, dict): + raise ValueError('Arguments JSON attendus.') + if name == 'list_timers': + return api_call() + if name == 'cancel_all_timers': + return api_call('cancel_all') + if name in ('create_timer', 'create_stopwatch'): + return api_call(name, {k: arguments[k] for k in ('seconds','label') if k in arguments}) + action = {'pause_timer':'pause','resume_timer':'resume','cancel_timer':'cancel','add_time':'add_time'}.get(name) + if action: + return api_call(action, {k: arguments[k] for k in ('id','seconds') if k in arguments}) + raise ValueError('Outil inconnu : '+str(name)) + + +def respond(msg): + sys.stdout.write(json.dumps(msg, ensure_ascii=False, separators=(',', ':'))+'\n') + sys.stdout.flush() + + +def handle(obj): + method = obj.get('method') + if not isinstance(method, str) or 'id' not in obj: + return + req_id = obj['id'] + try: + if method == 'initialize': + requested = str((obj.get('params') or {}).get('protocolVersion', '2025-03-26')) + version = requested if requested in ('2025-03-26', '2025-06-18', '2024-11-05') else '2025-06-18' + result = {'protocolVersion': version, 'capabilities': {'tools': {'listChanged': False}}, + 'serverInfo': {'name': 'alfred-timers', 'version': '1.0.0'}, + 'instructions': 'Use these tools for all timers, chronometers and time adjustments on Alfred. Never claim a timer was set without tool success.'} + elif method == 'tools/list': + result = {'tools': TOOLS} + elif method == 'tools/call': + params = obj.get('params') or {} + try: + answer = invoke(str(params.get('name','')), params.get('arguments') or {}) + result = {'content': [{'type': 'text', 'text': json.dumps(answer, ensure_ascii=False)}], 'isError': False} + except Exception as exc: + result = {'content': [{'type': 'text', 'text': str(exc)}], 'isError': True} + elif method == 'ping': + result = {} + else: + respond({'jsonrpc':'2.0','id':req_id,'error':{'code':-32601,'message':'Method not found'}}) + return + respond({'jsonrpc': '2.0', 'id': req_id, 'result': result}) + except Exception as exc: + respond({'jsonrpc':'2.0','id':req_id,'error':{'code':-32603,'message':str(exc)}}) + + +if __name__ == '__main__': + for line in sys.stdin: + if not line.strip(): + continue + try: + obj = json.loads(line) + if isinstance(obj, dict): + handle(obj) + except json.JSONDecodeError as exc: + print('Erreur JSON MCP :', exc, file=sys.stderr) diff --git a/hermes/MCP_autres/__pycache__/time_logic.cpython-313.pyc b/hermes/MCP_autres/__pycache__/time_logic.cpython-313.pyc new file mode 100644 index 0000000..9610a49 Binary files /dev/null and b/hermes/MCP_autres/__pycache__/time_logic.cpython-313.pyc differ diff --git a/hermes/MCP_autres/server.py b/hermes/MCP_autres/server.py new file mode 100644 index 0000000..e5e049e --- /dev/null +++ b/hermes/MCP_autres/server.py @@ -0,0 +1,26 @@ +"""Serveur MCP stdio de l'assistant Alfred : heure et date locales. + +N'écrit rien sur stdout : ce flux est réservé au protocole MCP. +""" + +from mcp.server import MCPServer +from time_logic import get_current_datetime as read_clock + +mcp = MCPServer("Alfred Autres") + + +@mcp.tool() +def get_current_datetime(timezone: str = "") -> dict: + """Donne la date, le jour et l'heure ACTUELS exacts de l'horloge système. + + À utiliser pour : « Quelle heure est-il ? », « On est quel jour ? », + « Quelle est la date d'aujourd'hui ? » et les questions similaires. + Laisse timezone vide pour l'heure locale d'Alfred (Europe/Paris par défaut). + Pour un autre endroit, utilise un fuseau IANA : Europe/London, Asia/Tokyo... + Ne devine jamais la date ou l'heure sans appeler cet outil. + """ + return read_clock(timezone) + + +if __name__ == "__main__": + mcp.run() # stdio : Hermes lance ce processus à la demande. diff --git a/hermes/MCP_autres/time_logic.py b/hermes/MCP_autres/time_logic.py new file mode 100644 index 0000000..2bcabde --- /dev/null +++ b/hermes/MCP_autres/time_logic.py @@ -0,0 +1,37 @@ +"""Calculs de temps purs pour le MCP Alfred Autres (Python 3.11+).""" + +from datetime import datetime +from os import environ +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +WEEKDAYS_FR = ( + "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi", "dimanche" +) +MONTHS_FR = ( + "janvier", "février", "mars", "avril", "mai", "juin", + "juillet", "août", "septembre", "octobre", "novembre", "décembre" +) + + +def get_current_datetime(timezone: str = "") -> dict[str, str | int]: + """Obtient l'heure réelle de l'horloge système dans un fuseau IANA.""" + selected = timezone.strip() if timezone else environ.get("ALFRED_TIMEZONE", "Europe/Paris") + try: + zone = ZoneInfo(selected) + except (ZoneInfoNotFoundError, ValueError) as exc: + raise ValueError(f"Fuseau horaire non reconnu : {selected!r}") from exc + + now = datetime.now(zone) + offset = now.strftime("%z") + return { + "iso_datetime": now.isoformat(timespec="seconds"), + "time_24h": now.strftime("%H:%M:%S"), + "hour": now.hour, + "minute": now.minute, + "date_iso": now.date().isoformat(), + "weekday_fr": WEEKDAYS_FR[now.weekday()], + "date_fr": f"{WEEKDAYS_FR[now.weekday()]} {now.day} {MONTHS_FR[now.month - 1]} {now.year}", + "timezone": selected, + "utc_offset": (offset[:3] + ":" + offset[3:]) if offset else "", + "timestamp_unix": int(now.timestamp()), + } diff --git a/hermes/MCP_wazuh/vikunja_mcp.py.bak b/hermes/MCP_wazuh/vikunja_mcp.py.bak deleted file mode 100644 index d4b671c..0000000 --- a/hermes/MCP_wazuh/vikunja_mcp.py.bak +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 - -import os -from pathlib import Path - -import httpx -from dotenv import load_dotenv -from mcp.server.fastmcp import FastMCP - - -# ============================================================================= -# Configuration -# ============================================================================= - -BASE_DIR = Path(__file__).resolve().parent - -load_dotenv(BASE_DIR / ".env") - -VIKUNJA_URL = os.getenv("VIKUNJA_URL", "").rstrip("/") -VIKUNJA_TOKEN = os.getenv("VIKUNJA_TOKEN", "") -VIKUNJA_VERIFY_SSL = ( - os.getenv("VIKUNJA_VERIFY_SSL", "true").lower() - in ("true", "1", "yes") -) - -if not VIKUNJA_URL: - raise RuntimeError("VIKUNJA_URL n'est pas défini dans .env") - -if not VIKUNJA_TOKEN: - raise RuntimeError("VIKUNJA_TOKEN n'est pas défini dans .env") - - -API_URL = f"{VIKUNJA_URL}/api/v2" - -HEADERS = { - "Authorization": f"Bearer {VIKUNJA_TOKEN}", - "Accept": "application/json", -} - - -# ============================================================================= -# MCP -# ============================================================================= - -mcp = FastMCP("vikunja") - - -# ============================================================================= -# Client Vikunja -# ============================================================================= - -async def vikunja_request( - method: str, - endpoint: str, - params: dict | None = None, - json: dict | None = None, -): - """ - Effectue une requête vers l'API v2 de Vikunja. - """ - - url = f"{API_URL}{endpoint}" - - async with httpx.AsyncClient( - headers=HEADERS, - verify=VIKUNJA_VERIFY_SSL, - timeout=30.0, - ) as client: - - response = await client.request( - method, - url, - params=params, - json=json, - ) - - response.raise_for_status() - - if not response.content: - return None - - return response.json() - - -async def vikunja_get( - endpoint: str, - params: dict | None = None, -): - return await vikunja_request( - "GET", - endpoint, - params=params, - ) - - -async def vikunja_post( - endpoint: str, - data: dict, -): - return await vikunja_request( - "POST", - endpoint, - json=data, - ) - - -# ============================================================================= -# MCP Tools -# ============================================================================= - -@mcp.tool() -async def vikunja_get_projects() -> dict: - """ - Retourne les projets Vikunja disponibles pour l'utilisateur. - - À utiliser notamment avant de créer une tâche lorsque l'utilisateur - ne donne pas directement l'identifiant du projet. - """ - - projects = await vikunja_get( - "/projects", - params={ - "format": "markdown", - }, - ) - - result = [] - - for project in projects or []: - result.append( - { - "id": project.get("id"), - "title": project.get("title"), - "description": project.get("description"), - "parent_project_id": project.get("parent_project_id"), - "is_archived": project.get("is_archived"), - "created": project.get("created"), - "updated": project.get("updated"), - } - ) - - return { - "count": len(result), - "projects": result, - } - - -@mcp.tool() -async def vikunja_get_project( - project_id: int, -) -> dict: - """ - Retourne les informations détaillées d'un projet Vikunja. - - Args: - project_id: - ID du projet. - """ - - project = await vikunja_get( - f"/projects/{project_id}", - params={ - "format": "markdown", - }, - ) - - return { - "id": project.get("id"), - "title": project.get("title"), - "description": project.get("description"), - "parent_project_id": project.get("parent_project_id"), - "is_archived": project.get("is_archived"), - "created": project.get("created"), - "updated": project.get("updated"), - } - - -@mcp.tool() -async def vikunja_get_tasks( - project_id: int, -) -> dict: - """ - Retourne les tâches d'un projet Vikunja. - - Args: - project_id: - ID du projet Vikunja. - """ - - tasks = await vikunja_get( - f"/projects/{project_id}/tasks", - params={ - "format": "markdown", - }, - ) - - result = [] - - for task in tasks or []: - result.append( - { - "id": task.get("id"), - "title": task.get("title"), - "description": task.get("description"), - "done": task.get("done"), - "priority": task.get("priority"), - "due_date": task.get("due_date"), - "start_date": task.get("start_date"), - "end_date": task.get("end_date"), - "project_id": task.get("project_id"), - "labels": task.get("labels", []), - "created": task.get("created"), - "updated": task.get("updated"), - } - ) - - return { - "project_id": project_id, - "count": len(result), - "tasks": result, - } - - -@mcp.tool() -async def vikunja_get_task( - task_id: int, -) -> dict: - """ - Retourne le détail d'une tâche Vikunja. - - Args: - task_id: - ID de la tâche. - """ - - task = await vikunja_get( - f"/tasks/{task_id}", - params={ - "format": "markdown", - }, - ) - - return { - "id": task.get("id"), - "title": task.get("title"), - "description": task.get("description"), - "done": task.get("done"), - "priority": task.get("priority"), - "due_date": task.get("due_date"), - "start_date": task.get("start_date"), - "end_date": task.get("end_date"), - "project_id": task.get("project_id"), - "labels": task.get("labels", []), - "assignees": task.get("assignees", []), - "created": task.get("created"), - "updated": task.get("updated"), - } - - -@mcp.tool() -async def vikunja_create_task( - project_id: int, - title: str, - description: str = "", - due_date: str | None = None, - priority: int = 0, -) -> dict: - """ - Crée une nouvelle tâche dans Vikunja. - - Args: - project_id: - ID du projet dans lequel créer la tâche. - - title: - Titre de la tâche. - - description: - Description facultative. - - due_date: - Date d'échéance facultative au format ISO 8601. - Exemple : 2026-09-15T18:00:00+02:00 - - priority: - Priorité Vikunja. - 0 signifie aucune priorité. - """ - - data = { - "title": title, - "description": description, - "priority": priority, - } - - if due_date: - data["due_date"] = due_date - - task = await vikunja_request( - "POST", - f"/projects/{project_id}/tasks", - params={ - "format": "markdown", - }, - json=data, - ) - - return { - "success": True, - "task": { - "id": task.get("id"), - "title": task.get("title"), - "description": task.get("description"), - "project_id": task.get("project_id"), - "priority": task.get("priority"), - "due_date": task.get("due_date"), - "done": task.get("done"), - }, - } - - -# ============================================================================= -# Main -# ============================================================================= - -if __name__ == "__main__": - mcp.run() diff --git a/hermes/MCP_wazuh/wazuh_mcp.py b/hermes/MCP_wazuh/wazuh_mcp.py new file mode 100644 index 0000000..1520774 --- /dev/null +++ b/hermes/MCP_wazuh/wazuh_mcp.py @@ -0,0 +1,1006 @@ +#!/usr/bin/env python3 +import os +from pathlib import Path +from datetime import datetime, timedelta, timezone + +import requests +import urllib3 +from dotenv import load_dotenv +from mcp.server.fastmcp import FastMCP + +BASE_DIR = Path(__file__).resolve().parent +load_dotenv(BASE_DIR / ".env") + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +WAZUH_URL = os.getenv("WAZUH_URL", "https://192.168.1.52:55000").rstrip("/") +WAZUH_USER = os.getenv("WAZUH_USER", "wazuh-wui") +WAZUH_PASSWORD = os.getenv("WAZUH_PASSWORD") + +INDEXER_URL = os.getenv("INDEXER_URL", "https://192.168.1.52:9200").rstrip("/") +INDEXER_USER = os.getenv("INDEXER_USER") +INDEXER_PASSWORD = os.getenv("INDEXER_PASSWORD") +INDEXER_ALERT_INDEX = os.getenv("INDEXER_ALERT_INDEX", "wazuh-alerts-*") +INDEXER_VULN_INDEX = os.getenv("INDEXER_VULN_INDEX", "wazuh-states-vulnerabilities-*") + +mcp = FastMCP("wazuh") + +_token = None + + +def utc_since(hours: int) -> str: + return (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat() + + +def get_token() -> str: + global _token + + if _token: + return _token + + if not WAZUH_PASSWORD: + raise RuntimeError("WAZUH_PASSWORD manquant dans .env") + + r = requests.post( + f"{WAZUH_URL}/security/user/authenticate?raw=true", + auth=(WAZUH_USER, WAZUH_PASSWORD), + verify=False, + timeout=15, + ) + r.raise_for_status() + _token = r.text.strip() + return _token + + +def wazuh_get(endpoint: str) -> dict: + token = get_token() + r = requests.get( + f"{WAZUH_URL}{endpoint}", + headers={"Authorization": f"Bearer {token}"}, + verify=False, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def indexer_search(index: str, body: dict) -> dict: + if not INDEXER_USER or not INDEXER_PASSWORD: + raise RuntimeError("INDEXER_USER ou INDEXER_PASSWORD manquant dans .env") + + r = requests.post( + f"{INDEXER_URL}/{index}/_search", + auth=(INDEXER_USER, INDEXER_PASSWORD), + json=body, + verify=False, + timeout=30, + ) + r.raise_for_status() + return r.json() + + +def simple_time_query(hours: int, size: int = 20, extra_filters=None) -> dict: + filters = [ + {"range": {"@timestamp": {"gte": utc_since(hours)}}} + ] + + if extra_filters: + filters.extend(extra_filters) + + return { + "size": size, + "sort": [{"@timestamp": {"order": "desc"}}], + "query": { + "bool": { + "filter": filters + } + } + } + + +# ------------------------- +# Wazuh Manager API +# ------------------------- + +@mcp.tool() +def manager_status() -> dict: + """Retourne l'état du Wazuh Manager.""" + return wazuh_get("/manager/status") + + +@mcp.tool() +def manager_info() -> dict: + """Retourne les informations du Wazuh Manager.""" + return wazuh_get("/manager/info") + + +@mcp.tool() +def list_agents(limit: int = 100) -> dict: + """Liste les agents Wazuh.""" + return wazuh_get(f"/agents?limit={limit}") + + +@mcp.tool() +def list_active_agents(limit: int = 100) -> dict: + """Liste les agents actifs.""" + return wazuh_get(f"/agents?status=active&limit={limit}") + + +@mcp.tool() +def list_disconnected_agents(limit: int = 100) -> dict: + """Liste les agents déconnectés.""" + return wazuh_get(f"/agents?status=disconnected&limit={limit}") + + +@mcp.tool() +def list_never_connected_agents(limit: int = 100) -> dict: + """Liste les agents jamais connectés.""" + return wazuh_get(f"/agents?status=never_connected&limit={limit}") + + +@mcp.tool() +def get_agent(agent_id: str) -> dict: + """Retourne le détail d'un agent Wazuh par son ID.""" + return wazuh_get(f"/agents?agents_list={agent_id}") + + +@mcp.tool() +def get_agent_config(agent_id: str) -> dict: + """Retourne la configuration effective d'un agent.""" + return wazuh_get(f"/agents/{agent_id}/config") + + +@mcp.tool() +def search_agents(query: str, limit: int = 100) -> dict: + """Recherche des agents par nom, IP ou information associée.""" + return wazuh_get(f"/agents?q={query}&limit={limit}") + + +# ------------------------- +# Wazuh Indexer / Alerts +# ------------------------- + +@mcp.tool() +def list_alerts(hours: int = 24, limit: int = 20) -> dict: + """Liste les dernières alertes Wazuh sur une période donnée.""" + body = simple_time_query(hours=hours, size=limit) + return indexer_search(INDEXER_ALERT_INDEX, body) + + +@mcp.tool() +def list_critical_alerts(hours: int = 24, limit: int = 20) -> dict: + """Liste les alertes critiques, niveau 12 ou supérieur.""" + body = simple_time_query( + hours=hours, + size=limit, + extra_filters=[ + {"range": {"rule.level": {"gte": 12}}} + ], + ) + return indexer_search(INDEXER_ALERT_INDEX, body) + + +@mcp.tool() +def top_alerting_agents(hours: int = 24, limit: int = 10) -> dict: + """Retourne les agents générant le plus d'alertes.""" + body = { + "size": 0, + "query": { + "range": { + "@timestamp": { + "gte": utc_since(hours) + } + } + }, + "aggs": { + "agents": { + "terms": { + "field": "agent.name", + "size": limit + } + } + } + } + return indexer_search(INDEXER_ALERT_INDEX, body) + + +@mcp.tool() +def search_alerts(query: str, hours: int = 24, limit: int = 20) -> dict: + """Recherche libre dans les alertes Wazuh.""" + body = { + "size": limit, + "sort": [{"@timestamp": {"order": "desc"}}], + "query": { + "bool": { + "filter": [ + {"range": {"@timestamp": {"gte": utc_since(hours)}}} + ], + "must": [ + { + "query_string": { + "query": query + } + } + ] + } + } + } + return indexer_search(INDEXER_ALERT_INDEX, body) + + +# ------------------------- +# Auth / SSH / sudo +# ------------------------- + +@mcp.tool() +def get_auth_failures(hours: int = 24, limit: int = 30) -> dict: + """Liste les échecs d'authentification récents.""" + body = simple_time_query( + hours=hours, + size=limit, + extra_filters=[ + { + "bool": { + "should": [ + {"match_phrase": {"rule.groups": "authentication_failed"}}, + {"match_phrase": {"rule.groups": "sshd"}}, + {"match_phrase": {"full_log": "Failed password"}}, + {"match_phrase": {"full_log": "authentication failure"}} + ], + "minimum_should_match": 1 + } + } + ], + ) + return indexer_search(INDEXER_ALERT_INDEX, body) + + +@mcp.tool() +def get_sudo_events(hours: int = 24, limit: int = 30) -> dict: + """Liste les événements sudo récents.""" + body = simple_time_query( + hours=hours, + size=limit, + extra_filters=[ + { + "bool": { + "should": [ + {"match_phrase": {"rule.groups": "sudo"}}, + {"match_phrase": {"full_log": "sudo"}} + ], + "minimum_should_match": 1 + } + } + ], + ) + return indexer_search(INDEXER_ALERT_INDEX, body) + + +# ------------------------- +# FIM +# ------------------------- + +@mcp.tool() +def get_fim_events(hours: int = 24, limit: int = 30) -> dict: + """Liste les événements d'intégrité fichiers FIM/syscheck.""" + body = simple_time_query( + hours=hours, + size=limit, + extra_filters=[ + { + "bool": { + "should": [ + {"match_phrase": {"rule.groups": "syscheck"}}, + {"exists": {"field": "syscheck.path"}} + ], + "minimum_should_match": 1 + } + } + ], + ) + return indexer_search(INDEXER_ALERT_INDEX, body) + + +@mcp.tool() +def get_fim_events_for_path(path: str, hours: int = 24, limit: int = 30) -> dict: + """Liste les modifications FIM pour un chemin donné.""" + body = simple_time_query( + hours=hours, + size=limit, + extra_filters=[ + {"wildcard": {"syscheck.path": f"*{path}*"}} + ], + ) + return indexer_search(INDEXER_ALERT_INDEX, body) + + +# ------------------------- +# SCA / compliance +# ------------------------- + +@mcp.tool() +def get_sca_failures(hours: int = 24, limit: int = 30) -> dict: + """Liste les contrôles SCA en échec.""" + body = simple_time_query( + hours=hours, + size=limit, + extra_filters=[ + { + "bool": { + "should": [ + {"match_phrase": {"rule.groups": "sca"}}, + {"exists": {"field": "data.sca"}} + ], + "minimum_should_match": 1 + } + }, + { + "bool": { + "should": [ + {"match_phrase": {"data.sca.check.result": "failed"}}, + {"match_phrase": {"full_log": "failed"}} + ], + "minimum_should_match": 1 + } + } + ], + ) + return indexer_search(INDEXER_ALERT_INDEX, body) + + +# ------------------------- +# Vulnerabilities +# ------------------------- + +@mcp.tool() +def list_vulnerabilities(limit: int = 50) -> dict: + """Liste les vulnérabilités présentes dans l'index Wazuh.""" + body = { + "size": limit, + "sort": [{"@timestamp": {"order": "desc"}}], + "query": { + "match_all": {} + } + } + return indexer_search(INDEXER_VULN_INDEX, body) + + +@mcp.tool() +def list_critical_vulnerabilities(limit: int = 50) -> dict: + """Liste les vulnérabilités critiques.""" + body = { + "size": limit, + "sort": [{"@timestamp": {"order": "desc"}}], + "query": { + "bool": { + "should": [ + {"match_phrase": {"vulnerability.severity": "Critical"}}, + {"match_phrase": {"vulnerability.severity": "CRITICAL"}}, + {"range": {"vulnerability.score.base": {"gte": 9}}} + ], + "minimum_should_match": 1 + } + } + } + return indexer_search(INDEXER_VULN_INDEX, body) + + +@mcp.tool() +def get_agent_vulnerabilities(agent_name: str, limit: int = 50) -> dict: + """Liste les vulnérabilités d'un agent par nom.""" + body = { + "size": limit, + "sort": [{"@timestamp": {"order": "desc"}}], + "query": { + "bool": { + "filter": [ + {"match_phrase": {"agent.name": agent_name}} + ] + } + } + } + return indexer_search(INDEXER_VULN_INDEX, body) + + +@mcp.tool() +def top_vulnerable_agents(limit: int = 10) -> dict: + """Retourne les agents avec le plus de vulnérabilités.""" + body = { + "size": 0, + "query": {"match_all": {}}, + "aggs": { + "agents": { + "terms": { + "field": "agent.name", + "size": limit + } + } + } + } + return indexer_search(INDEXER_VULN_INDEX, body) + + +# ------------------------- +# Résumé sécurité +# ------------------------- + +@mcp.tool() +def security_summary(hours: int = 24) -> dict: + """Produit un résumé sécurité global sur les dernières heures.""" + summary = {} + + try: + summary["manager_status"] = manager_status() + except Exception as e: + summary["manager_status_error"] = str(e) + + try: + summary["disconnected_agents"] = list_disconnected_agents(limit=100) + except Exception as e: + summary["disconnected_agents_error"] = str(e) + + try: + summary["critical_alerts"] = list_critical_alerts(hours=hours, limit=10) + except Exception as e: + summary["critical_alerts_error"] = str(e) + + try: + summary["top_alerting_agents"] = top_alerting_agents(hours=hours, limit=10) + except Exception as e: + summary["top_alerting_agents_error"] = str(e) + + try: + summary["auth_failures"] = get_auth_failures(hours=hours, limit=10) + except Exception as e: + summary["auth_failures_error"] = str(e) + + try: + summary["critical_vulnerabilities"] = list_critical_vulnerabilities(limit=10) + except Exception as e: + summary["critical_vulnerabilities_error"] = str(e) + + try: + summary["fim_events"] = get_fim_events(hours=hours, limit=10) + except Exception as e: + summary["fim_events_error"] = str(e) + + return summary + +@mcp.tool() +def vulnerability_report(limit_hosts: int = 100) -> dict: + """Rapport des vulnérabilités par host et par criticité.""" + body = { + "size": 0, + "query": { + "match_all": {} + }, + "aggs": { + "hosts": { + "terms": { + "field": "agent.name", + "size": limit_hosts + }, + "aggs": { + "severity": { + "terms": { + "field": "vulnerability.severity", + "size": 10 + } + } + } + } + } + } + + result = indexer_search(INDEXER_VULN_INDEX, body) + + report = [] + buckets = result.get("aggregations", {}).get("hosts", {}).get("buckets", []) + + for host in buckets: + counts = { + "host": host.get("key"), + "critical": 0, + "high": 0, + "medium": 0, + "low": 0, + "total": host.get("doc_count", 0), + } + + for sev in host.get("severity", {}).get("buckets", []): + severity = str(sev.get("key", "")).lower() + count = sev.get("doc_count", 0) + + if severity == "critical": + counts["critical"] = count + elif severity == "high": + counts["high"] = count + elif severity == "medium": + counts["medium"] = count + elif severity == "low": + counts["low"] = count + + report.append(counts) + + report.sort( + key=lambda x: ( + x["critical"], + x["high"], + x["medium"], + x["low"], + x["total"], + ), + reverse=True, + ) + + return { + "summary": { + "hosts": len(report), + "total_vulnerabilities": sum(h["total"] for h in report), + "critical": sum(h["critical"] for h in report), + "high": sum(h["high"] for h in report), + "medium": sum(h["medium"] for h in report), + "low": sum(h["low"] for h in report), + }, + "hosts": report, + } + +@mcp.tool() +def cve_report_by_host(limit_hosts: int = 100, limit_cves_per_host: int = 50) -> dict: + """ + Rapport détaillé des CVE par host. + + Exemple de sortie : + host1: + CVE-2024-XXXX : faille sur le paquet openssl - Critical + CVE-2025-YYYY : faille sur le paquet curl - High + """ + + body = { + "size": 0, + "query": { + "match_all": {} + }, + "aggs": { + "hosts": { + "terms": { + "field": "agent.name", + "size": limit_hosts + }, + "aggs": { + "cves": { + "terms": { + "field": "vulnerability.id", + "size": limit_cves_per_host + }, + "aggs": { + "sample": { + "top_hits": { + "size": 1, + "_source": { + "includes": [ + "agent.name", + "vulnerability.id", + "vulnerability.title", + "vulnerability.description", + "vulnerability.severity", + "vulnerability.score.base", + "package.name", + "package.version", + "package.architecture" + ] + } + } + } + } + } + } + } + } + } + + result = indexer_search(INDEXER_VULN_INDEX, body) + + report = [] + hosts = result.get("aggregations", {}).get("hosts", {}).get("buckets", []) + + severity_order = { + "critical": 4, + "high": 3, + "medium": 2, + "low": 1, + "unknown": 0, + } + + for host_bucket in hosts: + host_name = host_bucket.get("key") + cve_entries = [] + + cve_buckets = host_bucket.get("cves", {}).get("buckets", []) + + for cve_bucket in cve_buckets: + sample_hits = ( + cve_bucket + .get("sample", {}) + .get("hits", {}) + .get("hits", []) + ) + + if not sample_hits: + continue + + src = sample_hits[0].get("_source", {}) + + vulnerability = src.get("vulnerability", {}) + package = src.get("package", {}) + + cve_id = vulnerability.get("id", cve_bucket.get("key", "CVE inconnue")) + title = vulnerability.get("title") or vulnerability.get("description") or "faille détectée" + severity = vulnerability.get("severity", "Unknown") + score = vulnerability.get("score", {}).get("base") + package_name = package.get("name", "paquet inconnu") + package_version = package.get("version", "version inconnue") + package_arch = package.get("architecture", "") + + cve_entries.append({ + "cve": cve_id, + "package": package_name, + "version": package_version, + "architecture": package_arch, + "severity": severity, + "score": score, + "title": title, + "text": f"{cve_id} : faille sur le paquet {package_name} {package_version} - {severity}", + }) + + cve_entries.sort( + key=lambda x: ( + severity_order.get(str(x["severity"]).lower(), 0), + x["score"] or 0, + x["cve"] + ), + reverse=True + ) + + report.append({ + "host": host_name, + "total_cves": len(cve_entries), + "cves": cve_entries, + }) + + report.sort( + key=lambda x: x["total_cves"], + reverse=True + ) + + return { + "summary": { + "hosts": len(report), + "total_cves": sum(h["total_cves"] for h in report), + }, + "hosts": report, + } + +@mcp.tool() +def cve_report_by_severity(severity: str = "Critical", limit_hosts: int = 100, limit_cves_per_host: int = 50) -> dict: + """ + Rapport des CVE par host filtré par criticité. + severity peut être : Critical, High, Medium, Low + """ + + body = { + "size": 0, + "query": { + "match_phrase": { + "vulnerability.severity": severity + } + }, + "aggs": { + "hosts": { + "terms": { + "field": "agent.name", + "size": limit_hosts + }, + "aggs": { + "cves": { + "terms": { + "field": "vulnerability.id", + "size": limit_cves_per_host + }, + "aggs": { + "sample": { + "top_hits": { + "size": 1, + "_source": { + "includes": [ + "agent.name", + "vulnerability.id", + "vulnerability.title", + "vulnerability.description", + "vulnerability.severity", + "vulnerability.score.base", + "package.name", + "package.version", + "package.architecture" + ] + } + } + } + } + } + } + } + } + } + + result = indexer_search(INDEXER_VULN_INDEX, body) + + report = [] + hosts = result.get("aggregations", {}).get("hosts", {}).get("buckets", []) + + for host_bucket in hosts: + host_name = host_bucket.get("key") + cve_entries = [] + + for cve_bucket in host_bucket.get("cves", {}).get("buckets", []): + sample_hits = ( + cve_bucket + .get("sample", {}) + .get("hits", {}) + .get("hits", []) + ) + + if not sample_hits: + continue + + src = sample_hits[0].get("_source", {}) + vulnerability = src.get("vulnerability", {}) + package = src.get("package", {}) + + cve_id = vulnerability.get("id", cve_bucket.get("key", "CVE inconnue")) + package_name = package.get("name", "paquet inconnu") + package_version = package.get("version", "version inconnue") + title = vulnerability.get("title") or vulnerability.get("description") or "faille détectée" + score = vulnerability.get("score", {}).get("base") + + cve_entries.append({ + "cve": cve_id, + "package": package_name, + "version": package_version, + "severity": vulnerability.get("severity", severity), + "score": score, + "title": title, + "text": f"{cve_id} : faille sur le paquet {package_name} {package_version} - {severity}", + }) + + report.append({ + "host": host_name, + "severity": severity, + "total_cves": len(cve_entries), + "cves": cve_entries, + }) + + report.sort(key=lambda x: x["total_cves"], reverse=True) + + return { + "summary": { + "severity": severity, + "hosts": len(report), + "total_cves": sum(h["total_cves"] for h in report), + }, + "hosts": report, + } +@mcp.tool() +def sca_summary_by_host(hours: int = 168, limit_hosts: int = 100) -> dict: + """ + Résumé de l'évaluation des configurations SCA par host. + Donne le nombre de checks passés, échoués, non applicables, etc. + """ + body = { + "size": 0, + "query": { + "bool": { + "filter": [ + {"range": {"@timestamp": {"gte": utc_since(hours)}}}, + { + "bool": { + "should": [ + {"match_phrase": {"rule.groups": "sca"}}, + {"exists": {"field": "data.sca.check.result"}} + ], + "minimum_should_match": 1 + } + } + ] + } + }, + "aggs": { + "hosts": { + "terms": { + "field": "agent.name", + "size": limit_hosts + }, + "aggs": { + "results": { + "terms": { + "field": "data.sca.check.result", + "size": 10 + } + }, + "policies": { + "terms": { + "field": "data.sca.policy", + "size": 10 + } + } + } + } + } + } + + result = indexer_search(INDEXER_ALERT_INDEX, body) + hosts = result.get("aggregations", {}).get("hosts", {}).get("buckets", []) + + report = [] + + for host in hosts: + counts = { + "host": host.get("key"), + "passed": 0, + "failed": 0, + "not_applicable": 0, + "unknown": 0, + "total": host.get("doc_count", 0), + "policies": [] + } + + for bucket in host.get("results", {}).get("buckets", []): + key = str(bucket.get("key", "")).lower() + count = bucket.get("doc_count", 0) + + if key in ["passed", "pass"]: + counts["passed"] += count + elif key in ["failed", "fail"]: + counts["failed"] += count + elif key in ["not applicable", "not_applicable", "n/a"]: + counts["not_applicable"] += count + else: + counts["unknown"] += count + + counts["policies"] = [ + { + "policy": p.get("key"), + "count": p.get("doc_count", 0) + } + for p in host.get("policies", {}).get("buckets", []) + ] + + effective_total = counts["passed"] + counts["failed"] + counts["compliance_percent"] = round( + (counts["passed"] / effective_total) * 100, 2 + ) if effective_total > 0 else None + + report.append(counts) + + report.sort(key=lambda x: (x["failed"], x["total"]), reverse=True) + + return { + "summary": { + "hosts": len(report), + "total_checks": sum(h["total"] for h in report), + "passed": sum(h["passed"] for h in report), + "failed": sum(h["failed"] for h in report), + "not_applicable": sum(h["not_applicable"] for h in report), + }, + "hosts": report + } + +@mcp.tool() +def sca_failed_checks_by_host(hours: int = 168, limit_hosts: int = 50, limit_checks_per_host: int = 20) -> dict: + """ + Liste les contrôles SCA échoués par host. + Utile pour demander : résumé de l'évaluation des configurations. + """ + body = { + "size": 0, + "query": { + "bool": { + "filter": [ + {"range": {"@timestamp": {"gte": utc_since(hours)}}}, + {"match_phrase": {"data.sca.check.result": "failed"}} + ] + } + }, + "aggs": { + "hosts": { + "terms": { + "field": "agent.name", + "size": limit_hosts + }, + "aggs": { + "failed_checks": { + "terms": { + "field": "data.sca.check.title", + "size": limit_checks_per_host + }, + "aggs": { + "sample": { + "top_hits": { + "size": 1, + "_source": { + "includes": [ + "@timestamp", + "agent.name", + "data.sca.policy", + "data.sca.check.id", + "data.sca.check.title", + "data.sca.check.result", + "data.sca.check.description", + "data.sca.check.rationale", + "data.sca.check.remediation", + "rule.level", + "rule.description" + ] + } + } + } + } + } + } + } + } + } + + result = indexer_search(INDEXER_ALERT_INDEX, body) + hosts = result.get("aggregations", {}).get("hosts", {}).get("buckets", []) + + report = [] + + for host_bucket in hosts: + checks = [] + + for check_bucket in host_bucket.get("failed_checks", {}).get("buckets", []): + hits = ( + check_bucket + .get("sample", {}) + .get("hits", {}) + .get("hits", []) + ) + + if not hits: + continue + + src = hits[0].get("_source", {}) + sca = src.get("data", {}).get("sca", {}) + check = sca.get("check", {}) + + checks.append({ + "id": check.get("id"), + "title": check.get("title", check_bucket.get("key")), + "policy": sca.get("policy"), + "description": check.get("description"), + "rationale": check.get("rationale"), + "remediation": check.get("remediation"), + "rule_level": src.get("rule", {}).get("level"), + "count": check_bucket.get("doc_count", 0), + }) + + report.append({ + "host": host_bucket.get("key"), + "failed_total": host_bucket.get("doc_count", 0), + "failed_checks": checks, + }) + + report.sort(key=lambda x: x["failed_total"], reverse=True) + + return { + "summary": { + "hosts": len(report), + "total_failed_checks": sum(h["failed_total"] for h in report), + }, + "hosts": report + } + + +if __name__ == "__main__": + mcp.run()