v3
This commit is contained in:
@@ -0,0 +1,651 @@
|
||||
|
||||
import os, threading, hmac
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from flask import Flask, render_template, request, jsonify, Response
|
||||
from flask_socketio import SocketIO
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Charger la configuration AVANT les imports des services
|
||||
load_dotenv("/opt/alfred/.env")
|
||||
load_dotenv(
|
||||
"/opt/alfred/app/config/control_center.env",
|
||||
override=True
|
||||
)
|
||||
|
||||
from services.audio import listen_once, speak_async, tts_backend_detect
|
||||
from services.wakeword import WakeWordListener
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from services.alfred_duplex import DuplexMonitor # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
from services.piper_tts import stop_playback as duplex_stop_piper
|
||||
from services import admin_console # ALFRED_ADMIN_PAGES_V1
|
||||
from services.hermes_client import ask_hermes
|
||||
from services import alfred_timers, alfred_context_images # ALFRED_TIMERS_CONTEXT_V1
|
||||
from services import alfred_features # ALFRED_FEATURES_V45
|
||||
from services import audio_settings # ALFRED_AUDIO_SETTINGS_V1
|
||||
from services.camera import video_stream
|
||||
from services import ha_views, alfred_views_controller
|
||||
from services import camera_settings, ha_weather, weather_outfits
|
||||
|
||||
load_dotenv()
|
||||
|
||||
load_dotenv(os.path.join(os.path.dirname(__file__), "config", "control_center.env"), override=True) # ALFRED_ADMIN_PAGES_V1
|
||||
##
|
||||
from services.audio import get_vosk_model
|
||||
print("[ALFRED] Préchargement des modèles vocaux...", flush=True)
|
||||
get_vosk_model(os.environ["WAKE_VOSK_MODEL_PATH"])
|
||||
get_vosk_model(os.environ["VOSK_MODEL_PATH"])
|
||||
print("[ALFRED] Modèles vocaux prêts.", flush=True)
|
||||
##
|
||||
app=Flask(__name__); socketio=SocketIO(app,cors_allowed_origins="*")
|
||||
STATE={"mode":"idle","title":"Alfred","content":{"type":"text","text":"À votre écoute."},"partial":""}
|
||||
STATE_LOCK = threading.RLock()
|
||||
IMAGE_REVISION = 0 # Incrémenté à chaque image envoyée par ComfyUI.
|
||||
conversation_lock=threading.Lock()
|
||||
mic_stop_event=threading.Event()
|
||||
# Service temps autonome (survit aux conversations Hermes)
|
||||
alfred_timer_store = alfred_timers.register(app, socketio, speak_async, audio_settings) # ALFRED_TIMERS_CONTEXT_V1
|
||||
audio_settings.initialize()
|
||||
audio_settings.update_settings({"mic_gain": 30})
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: interruption audio sans interruption des tâches Hermes
|
||||
duplex_monitor = DuplexMonitor(muted=audio_settings.mic_muted,
|
||||
on_interrupt=duplex_stop_piper,
|
||||
on_status=lambda event: socketio.emit("alfred_duplex_interrupted" if event.get("interrupted") else "alfred_duplex_activity", event))
|
||||
|
||||
# Recharge les réglages V4L2 enregistrés, sans interrompre Alfred si la webcam est absente.
|
||||
camera_settings.restore_saved_settings()
|
||||
|
||||
def emit_state(mode=None, **extra):
|
||||
with STATE_LOCK:
|
||||
if mode: STATE["mode"] = mode
|
||||
STATE.update(extra)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0, injected_text=None): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return
|
||||
pending_duplex_text = None # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
|
||||
try:
|
||||
emit_state("listening", partial="", transcript="", wake_score=score)
|
||||
|
||||
print("[ALFRED] Début écoute STT", flush=True)
|
||||
|
||||
if injected_text is not None: # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
from services.audio import STTResult
|
||||
r = STTResult(text=injected_text)
|
||||
else:
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
|
||||
print(f"[ALFRED] Résultat STT : {r.text!r}, erreur : {r.error!r}", flush=True)
|
||||
|
||||
if audio_settings.mic_muted():
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
if r.error:
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": r.error}
|
||||
)
|
||||
return
|
||||
|
||||
if not r.text:
|
||||
emit_state("idle", partial="")
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_START
|
||||
# Commandes simples garanties locales ; jamais de confirmation inventée par Hermes.
|
||||
try:
|
||||
_lists_reply = _alfred_handle_list_voice(
|
||||
r.text, notify=lambda: socketio.emit('alfred_lists_changed', {'source':'voice'}))
|
||||
except Exception as _lists_error:
|
||||
print('[ALFRED LISTES] Erreur :', _lists_error, flush=True)
|
||||
_lists_reply = 'Je n’ai pas pu modifier la liste. Vérifie le stockage d’Alfred.'
|
||||
if _lists_reply is not None:
|
||||
_lists_display = {'type':'text', 'text':_lists_reply}
|
||||
with STATE_LOCK:
|
||||
STATE['content'] = _lists_display
|
||||
socketio.emit('alfred_render', _lists_display)
|
||||
if _lists_reply and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_lists_done = threading.Event()
|
||||
speak_async(_lists_reply, on_end=_lists_done.set)
|
||||
_lists_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
return
|
||||
# ALFRED_LISTES_GLASS_V1_VOICE_END
|
||||
|
||||
print("[ALFRED] Envoi à Hermes", flush=True)
|
||||
# ComfyUI publie l'image pendant cet appel Hermes.
|
||||
with STATE_LOCK:
|
||||
image_revision_before = IMAGE_REVISION
|
||||
# ALFRED_FEATURES_V45 voice opt-in background mode
|
||||
if alfred_features.should_run_in_background(r.text):
|
||||
try:
|
||||
alfred_features.submit(r.text, source='voice')
|
||||
emit_state('idle', partial='', content={
|
||||
'type': 'text',
|
||||
'text': 'Hermes travaille sur la demande. Le résultat apparaîtra dans les cartes.'
|
||||
})
|
||||
except ValueError as exc:
|
||||
emit_state('error', content={'type': 'text', 'text': str(exc)})
|
||||
return
|
||||
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}
|
||||
|
||||
# Une illustration contextuelle (Wikipédia) peut compléter la réponse Hermes.
|
||||
# Elle n'interrompt pas la lecture TTS et ne remplace pas la scène centrale.
|
||||
alfred_context_images.schedule_context_image(
|
||||
r.text, speech, socketio,
|
||||
lambda: STATE.get("transcript") == r.text,
|
||||
) # ALFRED_TIMERS_CONTEXT_V1
|
||||
|
||||
# Si le MCP ComfyUI a affiché une image pendant la requête,
|
||||
# ne pas l'écraser par la réponse textuelle d'Hermes.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == image_revision_before:
|
||||
STATE["content"] = display
|
||||
alfred_features.publish_voice_display(display)
|
||||
socketio.emit("alfred_render", display)
|
||||
else:
|
||||
print("[ALFRED] Image ComfyUI conservée à l'écran", flush=True)
|
||||
|
||||
if speech and not audio_settings.speaker_muted():
|
||||
emit_state("speaking")
|
||||
done = threading.Event()
|
||||
duplex_monitor.start(speech) # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
try:
|
||||
started = speak_async(speech, on_end=done.set)
|
||||
if started:
|
||||
done.wait(timeout=120)
|
||||
finally:
|
||||
if not duplex_monitor.interrupted():
|
||||
duplex_monitor.stop()
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: stop-only or keep the rest of the spoken follow-up.
|
||||
if duplex_monitor.interrupted():
|
||||
pending_duplex_text = duplex_monitor.wait_for_followup(timeout=30)
|
||||
duplex_monitor.stop()
|
||||
else:
|
||||
# A few wake-free seconds after the assistant finishes speaking.
|
||||
pending_duplex_text = duplex_monitor.listen_conversation()
|
||||
if pending_duplex_text:
|
||||
print('[ALFRED DUPLEX V2] Question enchaînée :', repr(pending_duplex_text), flush=True)
|
||||
|
||||
emit_state("idle", partial="")
|
||||
|
||||
except Exception as exc:
|
||||
print(f"[ALFRED] Erreur conversation : {exc}", flush=True)
|
||||
emit_state(
|
||||
"error",
|
||||
content={"type": "text", "text": str(exc)}
|
||||
)
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if pending_duplex_text and not audio_settings.mic_muted(): # ALFRED_DUPLEX_CONVERSATION_V2
|
||||
# Only the voice is cancelled; the original Hermes job is untouched.
|
||||
wake.pause()
|
||||
threading.Thread(target=conversation, kwargs={'injected_text': pending_duplex_text},
|
||||
daemon=True, name='dona-duplex-followup').start()
|
||||
elif not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
print("[ALFRED] Wake word réactivé", flush=True)
|
||||
|
||||
|
||||
|
||||
def on_wake(score):
|
||||
if audio_settings.mic_muted():
|
||||
return
|
||||
socketio.emit("wake_detected",{"word":os.getenv("WAKE_WORD","alfred"),"score":score})
|
||||
threading.Thread(target=conversation,args=(score,),daemon=True).start()
|
||||
|
||||
wake=WakeWordListener(on_wake=on_wake,on_level=lambda l: audio_level(l if STATE["mode"]!="idle" else l*.18))
|
||||
|
||||
admin_console.register_admin_console(app, wake, audio_settings) # ALFRED_ADMIN_PAGES_V1
|
||||
@app.get('/api/duplex/status')
|
||||
def duplex_status():
|
||||
return jsonify(duplex_monitor.status())
|
||||
|
||||
@app.get('/api/audio/settings')
|
||||
def get_audio_settings():
|
||||
return jsonify(audio_settings.get_settings())
|
||||
|
||||
@app.post('/api/audio/settings')
|
||||
def set_audio_settings():
|
||||
data = request.get_json(silent=True)
|
||||
try:
|
||||
old = audio_settings.get_settings()
|
||||
new = audio_settings.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
return jsonify({'error': f'ALSA : {exc}'}), 500
|
||||
if new['mic_muted'] != old['mic_muted']:
|
||||
if new['mic_muted']:
|
||||
mic_stop_event.set()
|
||||
wake.pause()
|
||||
else:
|
||||
mic_stop_event.clear()
|
||||
if not conversation_lock.locked():
|
||||
wake.resume()
|
||||
socketio.emit('audio_settings', new)
|
||||
return jsonify(new)
|
||||
|
||||
@app.get('/api/camera/settings')
|
||||
def get_camera_settings():
|
||||
try:
|
||||
return jsonify(camera_settings.get_settings())
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/settings')
|
||||
def set_camera_settings():
|
||||
# Même garde-fou que les commandes calendrier existantes.
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'error': 'JSON invalide.'}), 400
|
||||
try:
|
||||
result = camera_settings.update_setting(data.get('control'), data.get('value'))
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.post('/api/camera/reset')
|
||||
def reset_camera_settings():
|
||||
if not _same_origin_json():
|
||||
return jsonify({'error': 'Requête non autorisée.'}), 403
|
||||
try:
|
||||
result = camera_settings.reset_defaults()
|
||||
socketio.emit('camera_settings', result)
|
||||
return jsonify(result)
|
||||
except camera_settings.CameraSettingsError as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
|
||||
@app.get('/api/camera/stream')
|
||||
def camera_stream():
|
||||
# Flux MJPEG à la demande ; ne démarre jamais la webcam en arrière-plan.
|
||||
try:
|
||||
frames = video_stream(index=int(os.getenv('CAMERA_INDEX', '0')))
|
||||
except (ImportError, ValueError, RuntimeError, OSError) as exc:
|
||||
return jsonify({'error': str(exc)}), 503
|
||||
return Response(
|
||||
frames,
|
||||
mimetype='multipart/x-mixed-replace; boundary=frame',
|
||||
headers={'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'}
|
||||
)
|
||||
|
||||
# ALFRED_FEATURES_V45 init
|
||||
alfred_features.init_app(app, ask_hermes, socketio=socketio)
|
||||
|
||||
@app.get("/")
|
||||
def index(): return render_template("index.html",name=os.getenv("ALFRED_NAME","Alfred"))
|
||||
@app.get("/api/state")
|
||||
def state():
|
||||
with STATE_LOCK:
|
||||
snapshot = dict(STATE)
|
||||
return jsonify({**snapshot, "wake": wake.status(), "tts": tts_backend_detect()})
|
||||
|
||||
@app.post("/api/state")
|
||||
def set_state():
|
||||
data = request.get_json(force=True)
|
||||
with STATE_LOCK:
|
||||
STATE.update(data)
|
||||
socketio.emit("alfred_state", dict(STATE))
|
||||
return jsonify({"ok": True})
|
||||
@app.post("/api/render")
|
||||
def render_payload():
|
||||
global IMAGE_REVISION
|
||||
token = os.getenv("ALFRED_RENDER_TOKEN", "").strip()
|
||||
if token and not hmac.compare_digest(
|
||||
request.headers.get("X-Alfred-Render-Token", ""), token
|
||||
):
|
||||
return jsonify({"ok": False, "error": "Non autorisé"}), 401
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"ok": False, "error": "JSON objet attendu"}), 400
|
||||
if data.get("type") == "image":
|
||||
image_url = data.get("url")
|
||||
if not isinstance(image_url, str) or not (
|
||||
image_url.startswith(("http://", "https://", "/"))
|
||||
and not image_url.startswith("//")
|
||||
):
|
||||
return jsonify({"ok": False, "error": "URL image invalide"}), 400
|
||||
|
||||
with STATE_LOCK:
|
||||
if data.get("type") == "image":
|
||||
IMAGE_REVISION += 1
|
||||
STATE["content"] = data
|
||||
socketio.emit("alfred_render", data)
|
||||
return jsonify({"ok": True, "image_revision": IMAGE_REVISION})
|
||||
# --- Agenda / capteurs / caméras Home Assistant ---
|
||||
def _calendar_range():
|
||||
tz = ZoneInfo(os.getenv('ALFRED_TIMEZONE', 'Europe/Paris'))
|
||||
now = datetime.now(tz)
|
||||
def parse(name, default):
|
||||
raw = request.args.get(name)
|
||||
if not raw:return default
|
||||
value = datetime.fromisoformat(raw.replace('Z','+00:00'))
|
||||
if value.tzinfo is None: value = value.replace(tzinfo=tz)
|
||||
return value.astimezone(tz)
|
||||
start = parse('start',now.replace(hour=0,minute=0,second=0,microsecond=0))
|
||||
end = parse('end',start+timedelta(days=7))
|
||||
return start,end
|
||||
|
||||
def _json_error(exc):
|
||||
code = 400 if isinstance(exc,ValueError) else 502
|
||||
return jsonify({'error':str(exc)}),code
|
||||
|
||||
def _writes_enabled():
|
||||
return os.getenv('ALFRED_ALLOW_CALENDAR_WRITES','false').lower() in ('1','true','yes')
|
||||
|
||||
def _same_origin_json():
|
||||
# POSTs require a custom header and same-origin browser requests.
|
||||
if request.headers.get('X-Alfred-Request') != '1':return False
|
||||
origin=request.headers.get('Origin')
|
||||
if origin and origin.rstrip('/') != request.host_url.rstrip('/'):return False
|
||||
return True
|
||||
|
||||
@app.get('/api/calendar/calendars')
|
||||
def calendars_list():
|
||||
try:return jsonify({'calendars':ha_views.list_calendars(),'writes_enabled':_writes_enabled()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/calendar/events')
|
||||
def calendar_events():
|
||||
try:
|
||||
start,end=_calendar_range()
|
||||
selected=request.args.getlist('calendar_id')
|
||||
return jsonify(ha_views.get_events(start,end,selected or None))
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/create')
|
||||
def calendar_create():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée (ALFRED_ALLOW_CALENDAR_WRITES).'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
tz=ZoneInfo(os.getenv('ALFRED_TIMEZONE','Europe/Paris'))
|
||||
def read_dt(key):
|
||||
dt=datetime.fromisoformat(str(data[key]).replace('Z','+00:00'))
|
||||
return dt.replace(tzinfo=tz) if dt.tzinfo is None else dt.astimezone(tz)
|
||||
result=ha_views.create_event(str(data.get('calendar_id','')),str(data.get('title','')),
|
||||
read_dt('start'),read_dt('end'),str(data.get('description') or ''),
|
||||
str(data.get('location') or ''),bool(data.get('all_day',False)))
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_create()
|
||||
socketio.emit('calendar_changed', {'type':'created'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/calendar/delete')
|
||||
def calendar_delete():
|
||||
if not _same_origin_json():return jsonify({'error':'Requête non autorisée.'}),403
|
||||
if not _writes_enabled():return jsonify({'error':'Écriture calendrier désactivée.'}),403
|
||||
data=request.get_json(silent=True)
|
||||
if not isinstance(data,dict):return jsonify({'error':'JSON invalide.'}),400
|
||||
try:
|
||||
eid=str(data.get('calendar_id',''));uid=str(data.get('uid',''))
|
||||
start=datetime.fromisoformat(str(data['start']))
|
||||
end=datetime.fromisoformat(str(data['end']))
|
||||
if start.tzinfo is None or end.tzinfo is None:raise ValueError('Date avec fuseau requise.')
|
||||
matches=ha_views.get_events(start-timedelta(days=1),end+timedelta(days=1),[eid])['events']
|
||||
rid=str(data.get('recurrence_id') or '')
|
||||
if not any(e['id']==uid and e['start']==start.isoformat() and e['recurrence_id']==rid for e in matches):
|
||||
return jsonify({'error':'Événement introuvable ou modifié ; actualise le planning.'}),409
|
||||
result=ha_views.delete_event(eid,uid,rid or None)
|
||||
from services import alfred_intents
|
||||
alfred_intents.clear_delete()
|
||||
socketio.emit('calendar_changed',{'type':'deleted'})
|
||||
return jsonify(result)
|
||||
except (ha_views.HAError,ValueError,KeyError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather')
|
||||
def ha_weather_data():
|
||||
# Read-only route, token always stays on the Alfred server.
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
return jsonify(ha_weather.get_weather(q))
|
||||
except (ha_views.HAError, ValueError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/weather/outfits')
|
||||
def ha_weather_outfits_data():
|
||||
try:
|
||||
q = request.args.get('q', '')
|
||||
refresh = request.args.get('refresh_illustration', '').lower() in ('1','true','yes','on')
|
||||
return jsonify(weather_outfits.build_payload(q=q, date=request.args.get('date', ''), label=request.args.get('label', ''), refresh=refresh))
|
||||
except (ha_views.HAError, ValueError, RuntimeError) as exc:
|
||||
return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperatures')
|
||||
def ha_temperatures():
|
||||
try:return jsonify({'items':ha_views.get_temperatures()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/temperature/history')
|
||||
def ha_temperature_history():
|
||||
try:
|
||||
eid=request.args.get('entity_id','')
|
||||
hours=int(request.args.get('hours','24'))
|
||||
return jsonify({'items':ha_views.temperature_history(eid,hours)})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/cameras')
|
||||
def ha_cameras():
|
||||
try:return jsonify({'items':ha_views.list_cameras()})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.get('/api/ha/camera/<path:entity_id>/snapshot')
|
||||
def ha_camera_snapshot(entity_id):
|
||||
try:
|
||||
binary,mime=ha_views.camera_snapshot(entity_id)
|
||||
return Response(binary,mimetype=mime,headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'})
|
||||
except (ha_views.HAError,ValueError) as exc:return _json_error(exc)
|
||||
|
||||
@app.post('/api/ui/home')
|
||||
def ui_home():
|
||||
with STATE_LOCK:
|
||||
STATE['content']={'type':'home','text':'À ton écoute.'}
|
||||
socketio.emit('alfred_render',STATE['content'])
|
||||
return jsonify({'ok':True})
|
||||
|
||||
@app.post("/api/audio-level")
|
||||
def api_audio_level(): audio_level((request.get_json(force=True) or {}).get("level",0)); return jsonify({"ok":True})
|
||||
@app.get("/api/wake/status")
|
||||
def wake_status(): return jsonify(wake.status())
|
||||
@app.post("/api/wake/start")
|
||||
def wake_start(): wake.start(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/wake/stop")
|
||||
def wake_stop(): wake.stop(); return jsonify({"ok":True,**wake.status()})
|
||||
@app.post("/api/tts/say")
|
||||
def tts_say():
|
||||
text=(request.get_json(silent=True) or {}).get("text",""); return jsonify({"ok":speak_async(text)})
|
||||
|
||||
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_START
|
||||
# Raccourcis visuels : même Hermes, même verrou de conversation et même moteur Piper.
|
||||
def _alfred_dashboard_quick_command(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
|
||||
def _run():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_image_revision_before = IMAGE_REVISION
|
||||
emit_state('thinking', transcript=prompt, partial='',
|
||||
content={'type':'text', 'text':prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
# Une image ComfyUI publiee pendant Hermes ne doit pas etre effacee.
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _image_revision_before:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
if _speech and not audio_settings.speaker_muted():
|
||||
emit_state('speaking')
|
||||
_done = threading.Event()
|
||||
speak_async(_speech, on_end=_done.set)
|
||||
_done.wait(timeout=120)
|
||||
emit_state('idle', partial='')
|
||||
except Exception as _exc:
|
||||
print('[ALFRED DASHBOARD] Erreur raccourci :', _exc, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':str(_exc)})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name='alfred-dashboard-command').start()
|
||||
return True
|
||||
|
||||
from services.alfred_dashboard import register_dashboard as _register_dashboard_visual_v1
|
||||
_register_dashboard_visual_v1(app, quick_command=_alfred_dashboard_quick_command)
|
||||
# ALFRED_MAIN_VISUAL_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_START
|
||||
from services.alfred_home_glass import register_home_glass as _alfred_register_home_glass
|
||||
_alfred_register_home_glass(app)
|
||||
# ALFRED_ADMIN_GLASS_MAISON_V1_END
|
||||
|
||||
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_START
|
||||
from services.alfred_lists import register_lists as _alfred_register_lists
|
||||
from services.alfred_lists import handle_voice as _alfred_handle_list_voice
|
||||
_alfred_register_lists(app, socketio)
|
||||
# ALFRED_LISTES_GLASS_V1_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_GLASS_V2_BACKEND_START
|
||||
from services.alfred_logs_page import register_logs_page as _alfred_register_logs_v2
|
||||
_alfred_register_logs_v2(app)
|
||||
# ALFRED_GLASS_V2_BACKEND_END
|
||||
|
||||
|
||||
# ALFRED_IDENTITY_GLASS_V1: page /identity et synchronisation du nom.
|
||||
from services.alfred_identity_page import register_identity_page as _alfred_identity_register
|
||||
_alfred_identity_register(app)
|
||||
|
||||
|
||||
# ALFRED_DEVELOPER_V1 : interface /maintenance (administrateur).
|
||||
from services.alfred_developer_page import register_developer_page as _alfred_developer_register
|
||||
_alfred_developer_register(app)
|
||||
|
||||
|
||||
# ALFRED_HERMES_NEW_SESSION_V1 : session Hermes neuve via /config admin.
|
||||
from services.alfred_hermes_session_page import register_hermes_session_page as _register_hermes_new_session
|
||||
_register_hermes_new_session(app)
|
||||
|
||||
|
||||
# DONA_FAMILY_UI_V1_START
|
||||
# Text entered in the adult UI uses the existing Hermes session, not microphone/TTS.
|
||||
def _dona_submit_typed(prompt):
|
||||
if not conversation_lock.acquire(blocking=False):
|
||||
return False
|
||||
def _dona_run_typed():
|
||||
try:
|
||||
wake.pause()
|
||||
with STATE_LOCK:
|
||||
_before_image = IMAGE_REVISION
|
||||
emit_state('thinking', partial='', transcript=prompt,
|
||||
content={'type': 'text', 'text': prompt})
|
||||
_answer = ask_hermes(prompt)
|
||||
if isinstance(_answer, dict):
|
||||
_speech = str(_answer.get('speech') or _answer.get('text') or '')
|
||||
_display = _answer.get('display') or {'type':'text','text':_speech}
|
||||
else:
|
||||
_speech = str(_answer or '')
|
||||
_display = {'type':'text','text':_speech}
|
||||
with STATE_LOCK:
|
||||
if IMAGE_REVISION == _before_image:
|
||||
STATE['content'] = _display
|
||||
socketio.emit('alfred_render', _display)
|
||||
# Deliberately no speak_async(): typed interaction is silent.
|
||||
emit_state('idle', partial='')
|
||||
except Exception as exc:
|
||||
print('[DONA TEXT] Echec de la demande:', type(exc).__name__, flush=True)
|
||||
emit_state('error', content={'type':'text', 'text':'Hermes n’a pas pu répondre.'})
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
wake.resume()
|
||||
threading.Thread(target=_dona_run_typed, daemon=True, name='dona-typed-hermes').start()
|
||||
return True
|
||||
|
||||
from services.dona_family_ui import register_dona_family as _dona_register_family
|
||||
_dona_register_family(app, adult_command=_dona_submit_typed)
|
||||
# DONA_FAMILY_UI_V1_END
|
||||
|
||||
# DONA_SPOTIFY_V6 — player read/control API, reuses Hermes OAuth (no LLM polling).
|
||||
from services.dona_spotify import register_spotify_panel as _register_dona_spotify
|
||||
_register_dona_spotify(app)
|
||||
|
||||
|
||||
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,16 @@
|
||||
{
|
||||
"app": "/opt/alfred/app",
|
||||
"existing": [
|
||||
"app.py",
|
||||
"services/hermes_client.py",
|
||||
"templates/index.html",
|
||||
"templates/config.html"
|
||||
],
|
||||
"created": [
|
||||
"services/dona_face.py",
|
||||
"static/js/dona-face-v7.js",
|
||||
"static/js/dona-config-accordion-v7.js",
|
||||
"static/js/dona-reply-auto-close-v7.js",
|
||||
"static/css/dona-v7.css"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
|
||||
"""Pont Alfred -> Hermes avec session persistante."""
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION
|
||||
# --------------------------------------------------
|
||||
|
||||
DEFAULT_IDENTITY_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "identity.md"
|
||||
)
|
||||
|
||||
DEFAULT_SESSION_FILE = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "config"
|
||||
/ "hermes_session.json"
|
||||
)
|
||||
|
||||
_REQUEST_LOCK = threading.Lock()
|
||||
|
||||
_ID_PATTERN = re.compile(
|
||||
r"^[A-Za-z0-9_:.\-]{1,128}$"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# IDENTITÉ D'ALFRED
|
||||
# --------------------------------------------------
|
||||
|
||||
def _build_prompt(question: str) -> str:
|
||||
"""Send only the real user utterance. Personality is configured in Hermes SOUL.md.
|
||||
|
||||
The previous identity.md wrapper caused absent/unknown-tool decisions in
|
||||
both Spotify and Home Assistant. Never inject it into each user turn.
|
||||
"""
|
||||
return str(question or '').strip()
|
||||
|
||||
|
||||
def _toolsets_for(question: str) -> str | None:
|
||||
"""Conservative domain routing to avoid huge MCP schemas on simple turns.
|
||||
|
||||
Cross-domain, ambiguous, or generic requests retain Hermes' full toolset.
|
||||
Set HERMES_TOOLSET_ROUTING=off for instant rollback without code changes.
|
||||
"""
|
||||
if os.getenv('HERMES_TOOLSET_ROUTING', 'on').lower() not in ('on','1','true','yes'):
|
||||
return None
|
||||
q = str(question or '').casefold()
|
||||
domains = {
|
||||
'spotify': (r'\bspotify\b|\bplaylist\b|\bmusique\b|\bmorceau\b|\bchanson\b|\balbum\b|\bspotify connect\b'),
|
||||
'homeassistant': (r'\blumi[eè]res?\b|\blampes?\b|\bvolets?\b|\bdomotique\b|\bthermostat\b|\bchauffage\b|\bprises?\b|\bhome assistant\b|\ballume\b|\b[eé]teins?\b'),
|
||||
}
|
||||
import re as _re
|
||||
matching = [domain for domain, pattern in domains.items() if _re.search(pattern, q)]
|
||||
# Combined or potentially compound queries keep full access to every MCP.
|
||||
if len(matching) != 1:
|
||||
return None
|
||||
if _re.search(r'\b(et aussi|en m[eê]me temps|puis v[eé]rifie|compare avec|cherche sur|envoie|par mail|gmail|calendrier|agenda|wazuh|proxmox|comfyui|document|paperless)\b', q):
|
||||
return None
|
||||
return {'spotify':'spotify', 'homeassistant':'homeassistant,home_assistant'}[matching[0]]
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FICHIER DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _session_file() -> Path:
|
||||
|
||||
return Path(
|
||||
os.getenv(
|
||||
"HERMES_SESSION_FILE",
|
||||
str(DEFAULT_SESSION_FILE)
|
||||
)
|
||||
).expanduser()
|
||||
|
||||
|
||||
def _read_session(path: Path) -> str | None:
|
||||
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
obj = json.loads(
|
||||
path.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
sid = obj["session_id"]
|
||||
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError
|
||||
) as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"État de session Hermes invalide "
|
||||
f"({path}) : {exc}"
|
||||
) from exc
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Identifiant de session Hermes "
|
||||
f"invalide dans {path}"
|
||||
)
|
||||
|
||||
return sid
|
||||
|
||||
|
||||
def _save_session(path: Path, sid: str):
|
||||
|
||||
if not _ID_PATTERN.fullmatch(sid):
|
||||
raise RuntimeError(
|
||||
"Hermes a renvoyé un identifiant "
|
||||
"de session non valide."
|
||||
)
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
fd, temp_name = tempfile.mkstemp(
|
||||
prefix=".hermes_session_",
|
||||
suffix=".tmp",
|
||||
dir=path.parent
|
||||
)
|
||||
|
||||
temp_path = Path(temp_name)
|
||||
|
||||
with os.fdopen(
|
||||
fd,
|
||||
"w",
|
||||
encoding="utf-8"
|
||||
) as handle:
|
||||
|
||||
json.dump(
|
||||
{"session_id": sid},
|
||||
handle,
|
||||
indent=2
|
||||
)
|
||||
|
||||
handle.write("\n")
|
||||
|
||||
os.replace(temp_path, path)
|
||||
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# VERROUILLAGE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def _session_lock(path: Path):
|
||||
|
||||
path.parent.mkdir(
|
||||
parents=True,
|
||||
exist_ok=True
|
||||
)
|
||||
|
||||
lock_path = path.with_name(
|
||||
path.name + ".lock"
|
||||
)
|
||||
|
||||
fd = os.open(
|
||||
lock_path,
|
||||
os.O_CREAT | os.O_RDWR,
|
||||
0o600
|
||||
)
|
||||
|
||||
with os.fdopen(fd, "w") as handle:
|
||||
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_EX
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
|
||||
finally:
|
||||
fcntl.flock(
|
||||
handle.fileno(),
|
||||
fcntl.LOCK_UN
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# CONFIGURATION HERMES
|
||||
# --------------------------------------------------
|
||||
|
||||
def _hermes_binary() -> str:
|
||||
|
||||
return os.getenv(
|
||||
"HERMES_CLI",
|
||||
"/root/.local/bin/hermes"
|
||||
)
|
||||
|
||||
|
||||
def _hermes_timeout() -> float:
|
||||
|
||||
return max(
|
||||
10.0,
|
||||
float(
|
||||
os.getenv("HERMES_TIMEOUT", "180")
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# ANCIEN MODE SANS SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _oneshot_legacy(prompt: str) -> str:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_hermes_binary(),
|
||||
"--oneshot",
|
||||
prompt
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=True
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
details = (
|
||||
exc.stderr
|
||||
or exc.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {exc.returncode}): {details}"
|
||||
) from exc
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# INTERPRÉTATION RÉPONSE JSONL
|
||||
# --------------------------------------------------
|
||||
|
||||
def _parse_jsonl(stdout: str) -> tuple[str, str]:
|
||||
|
||||
final = None
|
||||
initial_session_id = None
|
||||
|
||||
for line in stdout.splitlines():
|
||||
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
event = json.loads(line)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
|
||||
if (
|
||||
event.get("type") == "system"
|
||||
and event.get("subtype") == "init"
|
||||
):
|
||||
initial_session_id = event.get(
|
||||
"session_id"
|
||||
)
|
||||
|
||||
if event.get("type") == "result":
|
||||
final = event
|
||||
|
||||
if final is None:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas renvoyé de résultat "
|
||||
"JSONL. Vérifie la prise en charge "
|
||||
"de --format stream-json."
|
||||
)
|
||||
|
||||
if final.get("exit_code", 0) != 0:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes : "
|
||||
f"{final.get('error') or 'échec'}"
|
||||
)
|
||||
|
||||
sid = (
|
||||
final.get("session_id")
|
||||
or initial_session_id
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(sid, str)
|
||||
or not _ID_PATTERN.fullmatch(sid)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas fourni "
|
||||
"d'identifiant de session utilisable."
|
||||
)
|
||||
|
||||
answer = final.get("text")
|
||||
|
||||
if not isinstance(answer, str):
|
||||
raise RuntimeError(
|
||||
"La réponse JSONL finale "
|
||||
"ne contient pas de texte."
|
||||
)
|
||||
|
||||
return answer.strip(), sid
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# APPEL HERMES AVEC REPRISE DE SESSION
|
||||
# --------------------------------------------------
|
||||
|
||||
def _call_hermes(
|
||||
prompt: str,
|
||||
session_id: str | None
|
||||
) -> tuple[str, str]:
|
||||
|
||||
cmd = [
|
||||
_hermes_binary(),
|
||||
"chat",
|
||||
"--oneshot",
|
||||
"--query-file",
|
||||
"-",
|
||||
"--format",
|
||||
"stream-json",
|
||||
"--source",
|
||||
"cli"
|
||||
]
|
||||
|
||||
route = _toolsets_for(prompt)
|
||||
if route:
|
||||
cmd.extend(["--toolsets", route])
|
||||
|
||||
if session_id:
|
||||
cmd.extend([
|
||||
"--resume",
|
||||
session_id
|
||||
])
|
||||
|
||||
try:
|
||||
process = subprocess.run(
|
||||
cmd,
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_hermes_timeout(),
|
||||
check=False
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
"Hermes n'a pas répondu "
|
||||
"dans le délai imparti."
|
||||
) from exc
|
||||
|
||||
except FileNotFoundError as exc:
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes introuvable : "
|
||||
f"{_hermes_binary()}"
|
||||
) from exc
|
||||
|
||||
if process.returncode != 0:
|
||||
|
||||
msg = (
|
||||
process.stderr
|
||||
or process.stdout
|
||||
or "aucun détail"
|
||||
).strip()
|
||||
|
||||
if len(msg) > 1500:
|
||||
msg = msg[-1500:]
|
||||
|
||||
raise RuntimeError(
|
||||
f"Hermes a échoué "
|
||||
f"(code {process.returncode}) : "
|
||||
f"{msg}"
|
||||
)
|
||||
|
||||
return _parse_jsonl(process.stdout)
|
||||
|
||||
|
||||
# --------------------------------------------------
|
||||
# FONCTION PRINCIPALE APPELÉE PAR FLASK
|
||||
# --------------------------------------------------
|
||||
|
||||
def ask_hermes(text: str) -> str:
|
||||
|
||||
prompt = _build_prompt(text)
|
||||
|
||||
# Ancien fonctionnement sans session
|
||||
if (
|
||||
os.getenv(
|
||||
"HERMES_SESSION_MODE",
|
||||
"persistent"
|
||||
).lower() == "legacy"
|
||||
):
|
||||
return _oneshot_legacy(prompt)
|
||||
|
||||
path = _session_file()
|
||||
|
||||
# Empêche les appels concurrents
|
||||
with _REQUEST_LOCK, _session_lock(path):
|
||||
|
||||
# Recherche la session précédente
|
||||
current_sid = _read_session(path)
|
||||
|
||||
# Envoie la question à Hermes
|
||||
answer, new_sid = _call_hermes(
|
||||
prompt,
|
||||
current_sid
|
||||
)
|
||||
|
||||
# Sauvegarde l'identifiant
|
||||
_save_session(
|
||||
path,
|
||||
new_sid
|
||||
)
|
||||
|
||||
print(
|
||||
f"[ALFRED] Session Hermes : "
|
||||
f"{new_sid} "
|
||||
f"({'reprise' if current_sid else 'création'})",
|
||||
flush=True
|
||||
)
|
||||
|
||||
return answer
|
||||
@@ -0,0 +1,120 @@
|
||||
<!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="#121b25"><title>Alfred · Configuration</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin.css">
|
||||
<script defer src="/static/js/alfred-admin.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin-v2.css?v=2">
|
||||
<script defer src="/static/js/alfred-admin-v2.js?v=2"></script>
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 -->
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-admin-glass.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
</head>
|
||||
<body data-page="config">
|
||||
<div class="admin-layout">
|
||||
<header class="admin-header">
|
||||
<div class="brand"><span class="brand-orb">A</span><div><strong>Alfred</strong><small>Centre de contrôle</small></div></div>
|
||||
<nav aria-label="Navigation"><a href="/">Accueil</a><a class="selected" href="/config">Configuration</a><a href="/surveillance">Surveillance</a><button class="text-button" type="button" id="logoutButton">Déconnexion</button></nav>
|
||||
</header>
|
||||
<main class="admin-main" id="mainArea" hidden>
|
||||
<div class="page-top"><div><p class="eyebrow">PERSONNALISATION</p><h1>Configuration</h1><p class="muted">Paramètres de l’assistant, voix et services. Les modifications prennent effet après redémarrage.</p></div><div class="page-actions"><span id="saveState" class="tag">Aucun changement</span><button id="saveConfig" class="button primary" type="button">Enregistrer</button></div></div>
|
||||
<section class="notice" id="pendingNotice" hidden>Réglages enregistrés. <strong>Redémarre Alfred</strong> pour les appliquer.</section>
|
||||
<div class="two-col">
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">✦</span><div><h2>Identité et voix</h2><p>Nom affiché, mot d'activation et voix de synthèse</p></div></div>
|
||||
<div class="field"><label for="ALFRED_NAME">Nom de l’assistant</label><input id="ALFRED_NAME" data-setting placeholder="Alfred" maxlength="30"><small>Visible dans l’interface après redémarrage.</small></div>
|
||||
<div class="field"><label for="WAKE_WORD">Mot de réveil (Vosk)</label><input id="WAKE_WORD" data-setting placeholder="alfred" maxlength="22"><small>Un seul mot. Le modèle Vosk doit pouvoir le reconnaître ; le mode OpenWakeWord nécessite un modèle entraîné.</small></div>
|
||||
<div class="field"><label for="PIPER_VOICE">Voix Wyoming Piper</label><input id="PIPER_VOICE" data-setting placeholder="fr_FR-tom-medium"><small>Exemple : fr_FR-tom-medium. La voix doit exister sur ton serveur Wyoming ; pour Piper local, c’est le fichier modèle qui détermine la voix.</small></div>
|
||||
<div class="inline"><button class="button ghost" type="button" id="testVoice">Tester la voix actuelle</button><span class="muted microtext">Utilise les réglages déjà actifs, sans redémarrage</span></div>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">☁</span><div><h2>Illustrations météo</h2><p>Qualité, visages de référence et accessoires ComfyUI</p></div></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS">Essais par personnage</label><select id="ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS" data-setting><option value="1">1 — Rapide</option><option value="2">2 — Équilibré</option><option value="3">3 — Qualité</option></select></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE">Score qualité cible</label><div class="range-field"><input type="range" min="5" max="10" step="0.1" id="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" data-setting><output for="ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE" id="scoreOut">8.6 / 10</output></div></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_DISPLAY_SIZE">Taille d’affichage</label><select id="ALFRED_WEATHER_DISPLAY_SIZE" data-setting><option value="250">250 px</option><option value="300">300 px</option><option value="350">350 px</option><option value="400">400 px</option><option value="450">450 px</option><option value="500">500 px</option></select></div>
|
||||
<div class="field switch-field"><label for="ALFRED_WEATHER_REVIEW">Analyse automatique des candidats <small>Nécessite un workflow vision compatible</small></label><input id="ALFRED_WEATHER_REVIEW" type="checkbox" data-setting></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_MODE">Références des visages</label><select id="ALFRED_WEATHER_REFERENCE_MODE" data-setting><option value="auto">Auto — avec repli</option><option value="required">Obligatoires</option><option value="off">Désactivées</option></select></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_REFERENCE_DENOISE">Liberté de modification des vêtements</label><div class="range-field"><input id="ALFRED_WEATHER_REFERENCE_DENOISE" data-setting type="range" min="0.25" max="0.85" step="0.01"><output id="denoiseOut">0.56</output></div><small>Une valeur élevée modifie davantage la tenue, mais risque de changer le visage.</small></div>
|
||||
<div class="field switch-field"><label for="ALFRED_WEATHER_PRESERVE_FACE">Préserver les visages <small>Lorsque l’image de référence est utilisable</small></label><input id="ALFRED_WEATHER_PRESERVE_FACE" type="checkbox" data-setting></div>
|
||||
<div class="field"><label for="ALFRED_WEATHER_ACCESSORY_LIMIT">Accessoires visibles par personnage</label><select id="ALFRED_WEATHER_ACCESSORY_LIMIT" data-setting><option value="0">Aucun</option><option value="1">1 — Recommandé</option><option value="2">2</option></select></div>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">♫</span><div><h2>Audio en direct</h2><p>Volume, sensibilité et coupures immédiates</p></div></div>
|
||||
<div class="field"><label for="liveVolume">Volume de sortie</label><div class="range-field"><input id="liveVolume" type="range" min="0" max="100" step="1"><output id="liveVolumeOut">—</output></div></div>
|
||||
<div class="field"><label for="liveMic">Sensibilité micro</label><div class="range-field"><input id="liveMic" type="range" min="0" max="100" step="1"><output id="liveMicOut">—</output></div></div>
|
||||
<div class="field switch-field"><label for="muteMicro">Microphone coupé</label><input id="muteMicro" type="checkbox"></div>
|
||||
<div class="field switch-field"><label for="muteSpeaker">Haut-parleur coupé</label><input id="muteSpeaker" type="checkbox"></div>
|
||||
<p class="muted microtext" id="audioResult">Les réglages audio sont appliqués sans redémarrage.</p>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">⌘</span><div><h2>Connexions et météo</h2><p>Adresses des serveurs et source météo</p></div></div>
|
||||
<div class="field"><label for="ALFRED_COMFYUI_URL">Serveur ComfyUI</label><input data-setting id="ALFRED_COMFYUI_URL" placeholder="http://192.168.1.13:8188"><small>Laisse vide pour conserver la configuration Hermes existante.</small></div>
|
||||
<div class="field"><label for="ALFRED_OLLAMA_URL">Serveur Ollama (surveillance)</label><input data-setting id="ALFRED_OLLAMA_URL" placeholder="http://192.168.1.13:11434"><small>Cette adresse sert au contrôle de disponibilité ; Hermes garde sa propre configuration LLM.</small></div>
|
||||
<div class="field"><label for="HA_WEATHER_ENTITY">Entité météo Home Assistant</label><input data-setting id="HA_WEATHER_ENTITY" placeholder="weather.forecast_home"><small>Exemple : weather.forecast_home. Le jeton Home Assistant n’est pas exposé ici.</small></div>
|
||||
<a href="/surveillance" class="button ghost inline-link">Voir la surveillance des services</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : reconnaissance vocale distante -->
|
||||
<section class="panel admin-v2-stt-panel">
|
||||
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2>Reconnaissance vocale (STT)</h2><p>Vosk local ou Whisper Wyoming distant · configuration effective après redémarrage</p></div></div>
|
||||
<div class="field"><label for="STT_ENGINE">Moteur de transcription final</label>
|
||||
<select id="STT_ENGINE" data-setting><option value="vosk">Vosk local (mode historique)</option><option value="wyoming">Whisper Wyoming distant (conseillé)</option></select>
|
||||
<small>Le mot d’activation et le texte provisoire restent gérés par Vosk. Whisper améliore la transcription finale envoyée à Hermes.</small></div>
|
||||
<div class="admin-v2-form-grid">
|
||||
<div class="field"><label for="WYOMING_WHISPER_HOST">Serveur Wyoming Whisper</label><input id="WYOMING_WHISPER_HOST" data-setting placeholder="192.168.1.13" autocomplete="off"><small>Adresse IP ou nom DNS, sans http://</small></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_PORT">Port TCP Wyoming</label><input type="number" min="1" max="65535" id="WYOMING_WHISPER_PORT" data-setting placeholder="10300"></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_LANGUAGE">Langue</label><input id="WYOMING_WHISPER_LANGUAGE" data-setting maxlength="2" placeholder="fr"></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_TIMEOUT">Délai maximum transcription (s)</label><input id="WYOMING_WHISPER_TIMEOUT" type="number" min="5" max="90" step="0.5" data-setting></div>
|
||||
<div class="field"><label for="WYOMING_WHISPER_CONNECT_TIMEOUT">Délai de connexion (s)</label><input id="WYOMING_WHISPER_CONNECT_TIMEOUT" type="number" min="1" max="15" step="0.5" data-setting></div>
|
||||
<div class="field"><label for="STT_MAX_AUDIO_SECONDS">Durée audio maximale (s)</label><input id="STT_MAX_AUDIO_SECONDS" type="number" min="5" max="60" step="1" data-setting></div>
|
||||
</div>
|
||||
<p class="admin-v2-help">Le modèle Whisper (<strong>small</strong> actuellement) et son vocabulaire technique (<code>--initial-prompt</code> pour Proxmox, Wazuh…) se configurent <strong>dans le conteneur Docker distant</strong>, pas sur Alfred. En cas de panne Whisper, retour automatique à Vosk si le correctif STT V1 est installé.</p>
|
||||
</section>
|
||||
|
||||
<!-- ALFRED_DUPLEX_BARGE_IN_V1 -->
|
||||
<section class="panel" id="alfredDuplexPanel">
|
||||
<div class="panel-title"><span class="panel-symbol">≋</span><div>
|
||||
<h2>Interruption vocale (duplex)</h2>
|
||||
<p>Couper la parole à l'assistant pendant la lecture Piper</p>
|
||||
</div></div>
|
||||
<div class="field"><label for="DUPLEX_MODE">Mode d'interruption</label>
|
||||
<select id="DUPLEX_MODE" data-setting>
|
||||
<option value="off">Désactivé (comportement actuel)</option>
|
||||
<option value="wake">Avec le nom d'activation — recommandé</option>
|
||||
<option value="natural">Naturelle sans le nom — expérimental</option>
|
||||
</select>
|
||||
<small>En mode « avec le nom », prononce le mot de réveil configuré (Dona, si c'est ton WAKE_WORD). En mode naturel, un début de parole distinct du TTS peut interrompre la réponse.</small>
|
||||
</div>
|
||||
<div class="field"><label for="DUPLEX_MIC_THRESHOLD">Seuil micro (sensibilité)</label>
|
||||
<input type="number" id="DUPLEX_MIC_THRESHOLD" data-setting min="0.008" max="0.30" step="0.005" placeholder="0.025">
|
||||
<small>Augmenter pour limiter les déclenchements intempestifs ; valeur initiale 0.025.</small>
|
||||
</div>
|
||||
<!-- ALFRED_DUPLEX_CONVERSATION_V2 -->
|
||||
<div class="field switch-field"><label for="DUPLEX_STOP_COMMANDS">Commandes « Stop », « Silence », « Arrête »
|
||||
<small>Coupe uniquement Piper, sans nom d’activation ni annulation des tâches Hermes.</small></label>
|
||||
<input id="DUPLEX_STOP_COMMANDS" type="checkbox" data-setting></div>
|
||||
<div class="field switch-field"><label for="DUPLEX_HANDOFF">Nouvelle question pendant la réponse
|
||||
<small>Interromps Dona et continue avec une question sans devoir répéter le nom.</small></label>
|
||||
<input id="DUPLEX_HANDOFF" type="checkbox" data-setting></div>
|
||||
<div class="field switch-field"><label for="DUPLEX_CONVERSATION">Mode Conversation
|
||||
<small>Après une réponse Piper, une courte fenêtre permet de poser une autre question.</small></label>
|
||||
<input id="DUPLEX_CONVERSATION" type="checkbox" data-setting></div>
|
||||
<div class="field"><label for="DUPLEX_CONVERSATION_SECONDS">Fenêtre de conversation (secondes)</label>
|
||||
<input id="DUPLEX_CONVERSATION_SECONDS" type="number" min="2" max="15" step="1" data-setting>
|
||||
<small>6 s recommandées. Pour fermer cette fenêtre, attends simplement quelques secondes.</small></div>
|
||||
<p class="muted microtext">Anti-écho : confirmation acoustique, comparaison avec les paroles Piper et délai de garde. Ceci ne remplace pas un vrai AEC ; le mode naturel reste expérimental.</p>
|
||||
<p class="muted" style="margin-top:12px">⚠ Le mode naturel exige idéalement un microphone avec annulation d'écho acoustique. Le simple filtrage logiciel ne remplace pas un AEC. Le retour à l'écoute prend effet après l'interruption ; attends que l'assistant soit prêt avant la nouvelle question. Les réglages nécessitent un redémarrage.</p>
|
||||
</section>
|
||||
<section class="panel restart-panel"><div><h2>Maintenance</h2><p class="muted">Redémarrer Alfred pour appliquer les paramètres enregistrés. Action protégée par mot de passe administrateur.</p><span class="microtext muted">Le bouton requiert une autorisation systemd explicitement activée.</span></div><button class="button danger" type="button" id="restartButton">Redémarrer Alfred</button></section>
|
||||
<p role="status" aria-live="polite" class="message" id="message"></p>
|
||||
</main>
|
||||
<div class="auth-screen" id="authScreen"><div class="auth-box"><span class="brand-orb big">A</span><h1>Accès administrateur</h1><p>Connecte-toi pour modifier la configuration d'Alfred.</p><form id="loginForm"><label for="adminPassword">Mot de passe</label><input type="password" id="adminPassword" autocomplete="current-password" required><button class="button primary full" type="submit">Se connecter</button></form><p class="message" id="loginMessage" role="status"></p><small>Accès local / réseau privé uniquement.</small></div></div>
|
||||
</div>
|
||||
<script src="/static/js/alfred-admin-glass.js?v=1"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,225 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color" content="#202326">
|
||||
<title>{{ name }}</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-controls.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-views.css">
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/css/alfred-camera-popup.css">
|
||||
<link rel="stylesheet" href="/static/css/alfred-features.css?v=45">
|
||||
<link rel="stylesheet" href="/static/css/alfred-timers-context.css">
|
||||
<!-- ALFRED_MAIN_VISUAL_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v1.css?v=1">
|
||||
<!-- ALFRED_LISTES_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-listes-glass.css?v=1">
|
||||
<!-- ALFRED_GLASS_V2 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-main-v2.css?v=2">
|
||||
<!-- ALFRED_STT_INLINE_GLASS_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-stt-inline-v1.css?v=1">
|
||||
<!-- ALFRED_REPONSE_CLOSE_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-reponse-close-v1.css?v=1">
|
||||
<!-- ALFRED_HERMES_NEW_SESSION_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/alfred-hermes-new-session.css?v=1">
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=md1">
|
||||
<!-- DONA_FAMILY_UI_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-accueil-ecrit-v1.css?v=1">
|
||||
<link rel="stylesheet" href="/static/css/dona-v6.css?v=6">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
<header class="topbar">
|
||||
<!-- Le statut remplace l'ancien titre « Alfred ». -->
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span id="statusDot" class="status-dot" data-mode="idle" aria-hidden="true"></span>
|
||||
<span id="mode" data-mode="idle">En attente</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" aria-label="Navigation et commandes audio">
|
||||
<!-- ALFRED_ADMIN_NAV_V1 : pages d’administration séparées -->
|
||||
<a class="icon-button" style="text-decoration:none" href="/config" title="Configuration complète" aria-label="Configuration complète">⚙</a>
|
||||
<a class="icon-button" style="text-decoration:none" href="/surveillance" title="Surveillance des services" aria-label="Surveillance des services">▤</a>
|
||||
<button id="viewHome" class="icon-button" type="button" title="Accueil" aria-label="Accueil">⌂</button>
|
||||
<button id="viewCalendar" class="icon-button" type="button" title="Agenda" aria-label="Agenda">▦</button>
|
||||
<button id="viewWeather" class="icon-button" type="button" title="Météo Home Assistant" aria-label="Météo Home Assistant">☀</button>
|
||||
<button id="viewTemperatures" class="icon-button" type="button" title="Températures" aria-label="Températures">°</button>
|
||||
<button id="viewCameras" class="icon-button" type="button" title="Caméras Home Assistant" aria-label="Caméras Home Assistant">▣</button>
|
||||
|
||||
<!-- ALFRED_CAMERA_POPUP_V1 -->
|
||||
<button id="cameraPopupButton" class="icon-button" type="button"
|
||||
title="Afficher la caméra" aria-label="Afficher la caméra"
|
||||
aria-haspopup="dialog" aria-controls="cameraPopup" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/>
|
||||
<path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="audioSettingsButton" class="icon-button" type="button"
|
||||
aria-label="Réglages audio et caméra" aria-haspopup="dialog" aria-expanded="false"
|
||||
aria-controls="audioSettingsPanel" title="Réglages audio et caméra">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="2.5" fill="#272b30"/><circle cx="16" cy="17" r="2.5" fill="#272b30"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="micMute" class="icon-button" type="button"
|
||||
aria-label="Couper le microphone" aria-pressed="false" title="Couper le microphone">
|
||||
<svg class="mic-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="9" y="2.5" width="6" height="12" rx="3"/>
|
||||
<path d="M5.5 10.5v1.5a6.5 6.5 0 0 0 13 0v-1.5M12 18.5v3M8 21.5h8"/>
|
||||
</svg>
|
||||
<svg class="mic-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9.2 5.3V5.5a3 3 0 0 1 5.5-1.4M15 10.6v.9c0 .5-.1 1-.3 1.5M9 9.5v2A3 3 0 0 0 13.6 14"/>
|
||||
<path d="M5.5 10.5V12a6.5 6.5 0 0 0 11.5 4.1M12 18.5v3M8 21.5h8M3 3l18 18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="speakerMuteQuick" class="icon-button" type="button"
|
||||
aria-label="Couper le son" aria-pressed="false" title="Couper le son">
|
||||
<svg class="speaker-on-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="M15 9a4 4 0 0 1 0 6M18 6a8 8 0 0 1 0 12"/>
|
||||
</svg>
|
||||
<svg class="speaker-off-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m11 5-5 4H3v6h3l5 4V5Z"/><path d="m16 9 5 6m0-6-5 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Pop-up : commandes connectées à /api/audio/settings -->
|
||||
<div id="audioSettingsPanel" class="audio-popover" role="dialog"
|
||||
aria-label="Réglages audio et caméra" aria-modal="false" hidden>
|
||||
<div class="popover-heading">
|
||||
<strong>Réglages</strong>
|
||||
<button id="closeAudioSettings" class="icon-button mini" type="button" aria-label="Fermer les réglages">×</button>
|
||||
</div>
|
||||
<div class="settings-tabs" role="tablist" aria-label="Catégories de réglages">
|
||||
<button id="audioTab" class="settings-tab" type="button" role="tab" aria-selected="true" aria-controls="audioSettingsSection">Audio</button>
|
||||
<button id="cameraTab" class="settings-tab" type="button" role="tab" aria-selected="false" aria-controls="cameraSettingsSection">Caméra</button>
|
||||
</div>
|
||||
<div id="audioSettingsSection" role="tabpanel" aria-labelledby="audioTab">
|
||||
<div class="setting-row">
|
||||
<label for="volumeSlider">Volume du haut-parleur</label>
|
||||
<output id="volumeValue" for="volumeSlider">70 %</output>
|
||||
</div>
|
||||
<input id="volumeSlider" type="range" min="0" max="100" value="70" step="1">
|
||||
<div class="setting-row">
|
||||
<label for="micGainSlider">Sensibilité du microphone</label>
|
||||
<output id="micGainValue" for="micGainSlider">—</output>
|
||||
</div>
|
||||
<input id="micGainSlider" type="range" min="0" max="100" value="50" step="1">
|
||||
<p id="audioSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<div id="cameraSettingsSection" role="tabpanel" aria-labelledby="cameraTab" hidden>
|
||||
<p class="camera-settings-note">Webcam locale · réglages matériels V4L2.<br>Tu peux voir le résultat via le bouton Caméra en haut de l’écran.</p>
|
||||
<div id="cameraSettingsItems" class="camera-settings-items" aria-live="off"></div>
|
||||
<div class="camera-settings-actions">
|
||||
<button id="cameraSettingsReload" class="camera-settings-action" type="button">Actualiser</button>
|
||||
<button id="cameraSettingsReset" class="camera-settings-action reset" type="button">Valeurs d’usine</button>
|
||||
</div>
|
||||
<p id="cameraSettingsMessage" class="settings-message" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cameraPopup" class="camera-popup-overlay" role="dialog"
|
||||
aria-label="Prévisualisation caméra" aria-modal="true" hidden>
|
||||
<div class="camera-popup-frame">
|
||||
<div class="camera-popup-titlebar">
|
||||
<strong class="camera-popup-title">Caméra en direct</strong>
|
||||
<button id="cameraPopupClose" class="camera-popup-close" type="button"
|
||||
aria-label="Fermer la caméra" title="Fermer">×</button>
|
||||
</div>
|
||||
<aside class="camera-panel camera-popup-body" aria-label="Prévisualisation caméra">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator"></span> CAMÉRA</span>
|
||||
<button id="cameraToggle" class="panel-action" type="button" aria-pressed="false">Activer</button>
|
||||
</div>
|
||||
<div class="camera-viewport">
|
||||
<img id="cameraFeed" alt="Vidéo en direct de la caméra d'Alfred" hidden>
|
||||
<div id="cameraPlaceholder" class="camera-placeholder">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true">
|
||||
<rect x="2.5" y="5" width="14" height="14" rx="3"/><path d="m16.5 10 5-3v10l-5-3"/>
|
||||
</svg>
|
||||
<span id="cameraMessage">Caméra désactivée</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<p class="camera-popup-footnote">Le flux est interrompu à la fermeture pour laisser la webcam disponible aux autres fonctions d’Alfred.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="stage" aria-label="Réponse d'Alfred">
|
||||
<div id="content"><h1>Bonjour.</h1><p>À votre écoute.</p></div>
|
||||
</section>
|
||||
|
||||
<section id="wave" aria-hidden="true"><canvas id="waveCanvas"></canvas></section>
|
||||
|
||||
|
||||
|
||||
<!-- Affiche les résultats partiels ET définitifs de la reconnaissance Vosk. -->
|
||||
<aside class="transcript-panel" aria-label="Transcription vocale">
|
||||
<div class="panel-heading">
|
||||
<span class="panel-title"><span class="panel-indicator stt-indicator"></span> RECONNAISSANCE VOCALE · VOSK</span>
|
||||
<span id="transcriptState" class="transcript-state">En attente</span>
|
||||
</div>
|
||||
<p id="transcriptText" class="transcript-text" aria-live="polite">La dernière phrase reconnue s'affichera ici.</p>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<script src="/static/js/alfred.js"></script>
|
||||
<script src="/static/js/alfred-views.js"></script>
|
||||
<script src="/static/js/alfred-controls.js"></script>
|
||||
<script src="/static/js/alfred-camera.js"></script>
|
||||
<script src="/static/js/alfred-camera-settings.js"></script>
|
||||
<script src="/static/js/alfred-camera-popup.js"></script>
|
||||
<script defer src="/static/js/alfred-features.js?v=45"></script>
|
||||
|
||||
<!-- ALFRED_TIMERS_CONTEXT_V1 : chronos/minuteurs dans le coin inférieur gauche -->
|
||||
<aside id="alfredTimersDock" role="region" aria-label="Minuteurs et chronomètres" hidden>
|
||||
<div class="alfred-time-head">⏱ MINUTEURS ET CHRONOMÈTRES</div>
|
||||
<div id="alfredTimersList"></div>
|
||||
</aside>
|
||||
<div id="alfredTimerToast" role="alert" aria-live="assertive" hidden></div>
|
||||
|
||||
<div id="alfredContextPopup" role="dialog" aria-label="Illustration de la réponse"
|
||||
aria-modal="true" hidden>
|
||||
<article class="alfred-context-card">
|
||||
<button id="alfredContextClose" class="alfred-context-close" type="button"
|
||||
aria-label="Fermer l'illustration" title="Fermer">×</button>
|
||||
<img id="alfredContextImage" alt="Illustration contextuelle de la réponse" />
|
||||
<div class="alfred-context-details">
|
||||
<h2 id="alfredContextTitle"></h2>
|
||||
<p id="alfredContextText"></p>
|
||||
<a id="alfredContextSource" href="#" target="_blank" rel="noopener noreferrer">Source Wikipédia</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<script src="/static/js/alfred-timers-context.js"></script>
|
||||
<!-- ALFRED_HIDE_HERMES_ACTIVITY_V2 -->
|
||||
<script src="/static/js/alfred-hide-hermes-activity.js?v=2" defer></script>
|
||||
<script src="/static/js/alfred-main-v1.js?v=1"></script>
|
||||
<!-- ALFRED_ADMIN_GLASS_MAISON_V1 -->
|
||||
<script src="/static/js/alfred-maison-card.js?v=1"></script>
|
||||
<script src="/static/js/alfred-listes-glass.js?v=1"></script>
|
||||
<script src="/static/js/alfred-main-v2.js?v=2"></script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-stt-inline-v1.js?v=1"></script>
|
||||
<script src="/static/js/alfred-reponse-close-v1.js?v=1"></script>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
<script src="/static/js/alfred-hermes-new-session.js?v=1"></script>
|
||||
<script defer src="/static/js/dona-markdown-renderer.js?v=md1"></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=md1"></script>
|
||||
<!-- DONA_REPLY_AUTO_CLOSE_V1 -->
|
||||
<script defer src="/static/js/alfred-reply-auto-close.js?v=1"></script>
|
||||
<script src="/static/js/dona-accueil-ecrit-v1.js?v=6"></script>
|
||||
<script defer src="/static/js/dona-spotify.js?v=6"></script>
|
||||
<!-- dona-ui-fix start -->
|
||||
<script defer src="{{ url_for('static', filename='js/dona-ui-fix.js') }}?v=6"></script>
|
||||
<!-- dona-ui-fix end -->
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user