v2 - STT via Whisper for more precision and if nor available, twist to Vox
@@ -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
|
||||
|
||||
@@ -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/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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")
|
||||
|
||||
@@ -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/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.getenv("WAKE_ENABLED","true").lower() in ("1","true","yes","on") and not audio_settings.mic_muted(): wake.start()
|
||||
socketio.run(app,host=os.getenv("FLASK_HOST","0.0.0.0"),port=int(os.getenv("FLASK_PORT","54321")),allow_unsafe_werkzeug=True)
|
||||
|
||||
@@ -0,0 +1,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 []
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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=
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599"
|
||||
"session_id": "local-f6c54762e29b37dc1b98da779a3690e4ddf8cb585d11705c6ee88e828ac59737"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"session_id": "local-65f11130ed816902a867736ef80b3af0ea255048cde912fb31b26267c8d1d599"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"session_id": "local-0dc2466df78900d9f7fd572cb26be3c88dbfcd31ee2e66056be44f5e3f966595"
|
||||
}
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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/<task_id>")
|
||||
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")
|
||||
@@ -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
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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
|
||||
@@ -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); } }
|
||||
@@ -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}}
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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});
|
||||
})();
|
||||
@@ -10,6 +10,8 @@
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
@@ -156,5 +158,30 @@
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,162 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
</body>
|
||||
</html>
|
||||