v2.2 - Small improvement Interface change
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"installed": true,
|
||||
"files": {
|
||||
"app.py": {
|
||||
"existed": true,
|
||||
"old_sha": "b552034de304ef5203d26ed9ba5d52928974d7a001cd326f2dde3be057941631",
|
||||
"new_sha": "8652f012f05fd6437f5b53fc9d3b76492b1e1b29669750fda200b00d3f078735",
|
||||
"mode": 420
|
||||
},
|
||||
"mcp/alfred_developer_mcp.py": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "f8ac5aac221f6762746af469a3ed04175101ad1ce02ed74dfbbaebd3ab140268",
|
||||
"mode": 420
|
||||
},
|
||||
"services/alfred_developer_core.py": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "339171cc1461b9bbea6265167ab8bfb08bcd300bddf489dfa44fd17143af32dd",
|
||||
"mode": 420
|
||||
},
|
||||
"services/alfred_developer_page.py": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "97006796328692616dac54bec416a5bda71ace5da9260e32e4c7695086138d76",
|
||||
"mode": 420
|
||||
},
|
||||
"skills/alfred-developer/SKILL.md": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "95ea0ceccb617a842958bae176d2d91a1e8c6a41c9b420d018bbd50547476943",
|
||||
"mode": 420
|
||||
},
|
||||
"static/css/alfred-developer-glass.css": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "e894cfc0bc18a5e941785a2bf2b5959277c691c5d9f82c64271413bd45ff4e5e",
|
||||
"mode": 420
|
||||
},
|
||||
"static/js/alfred-developer-glass.js": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "360590f0652888f13433b6dda4dd5ef13f495790114bcb376c9d5107fe03e12a",
|
||||
"mode": 420
|
||||
},
|
||||
"static/js/alfred-maintenance-link.js": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "d489ef6dbf19f842bb1ce5edc3986c1b52f5285ae352352482104f6d368e602e",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/alfred_identity_glass.html": {
|
||||
"existed": true,
|
||||
"old_sha": "2c912c7d23499f217e4eff4a5c7020344a9f443279443c81a875d394949de154",
|
||||
"new_sha": "bd43ead3b3be510f16084f44121bf2d23b192539eab832bbc0ca60d9f040759c",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/alfred_logs_v2.html": {
|
||||
"existed": true,
|
||||
"old_sha": "eb3fe9bb05766e5868ab84956ee4660f0ef0d5990c499f710829bd5a40eb035e",
|
||||
"new_sha": "c5b41ff1258fe9f3c2169678071673daca4b24223b541b532cb9d348529e1f6c",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/alfred_maintenance.html": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "509f82fb16040eb65f174be45a5f0dc8048856980f7604e8d9ca6aedd00d4670",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/config.html": {
|
||||
"existed": true,
|
||||
"old_sha": "e29cba3bee6e702856f602ca48ceeb554fd4347c96dab7311999fb20fcebb2d7",
|
||||
"new_sha": "22823b16a4c93b9a3fb9b69538db755509fceb8e2048097caa7965bbcb2ff44b",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/index.html": {
|
||||
"existed": true,
|
||||
"old_sha": "60636d8b9cbf0f08f25eb0d5e4c1f5729f8bd009c1a4dc1742939b58a0e27c75",
|
||||
"new_sha": "afab6eea4bb25ba1066d5b9f0939d8e76b3daa014fd0ff029e511bebed39ce17",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/surveillance.html": {
|
||||
"existed": true,
|
||||
"old_sha": "9d271013722038be7ef924c4cdc032f3b9e50094a6278d46335eedba7c392bdc",
|
||||
"new_sha": "8f5184d6b888f6a533e3a059ea59bf5e5bfdca06fea5e947faab2d5bcea0750a",
|
||||
"mode": 420
|
||||
},
|
||||
"tools/alfred_deploy.py": {
|
||||
"existed": false,
|
||||
"old_sha": null,
|
||||
"new_sha": "33fb7609545cc5634250c1361aa92ea8a99e4169ec88a8cb66f9ebb886f50f58",
|
||||
"mode": 420
|
||||
}
|
||||
}
|
||||
}
|
||||
+580
@@ -0,0 +1,580 @@
|
||||
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.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)
|
||||
|
||||
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)
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Identité · Assistant</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-identity-glass.css?v=1">
|
||||
<script src="/static/js/alfred-identity-glass.js?v=1" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="id-head">
|
||||
<a class="id-brand" href="/"><span class="id-letter" id="idInitial">A</span><span><strong id="idName">Assistant</strong><small>Identité et personnalité</small></span></a>
|
||||
<nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity" class="active" aria-current="page">Identité</a></nav>
|
||||
</header>
|
||||
<main class="id-main">
|
||||
<div class="id-title"><div><p class="id-eyebrow">PERSONNALISATION · HERMES</p><h1>Identité de l'assistant</h1><p>Personnalise la personnalité, le ton et les instructions permanentes transmises à Hermes.</p></div><span class="id-pill" id="idBadge">Vérification…</span></div>
|
||||
<section class="id-panel" id="idLoginPanel" hidden>
|
||||
<h2>Accès administrateur</h2><p>Utilise le même mot de passe que la page Configuration.</p>
|
||||
<form id="idLoginForm"><label for="idPassword">Mot de passe</label><input type="password" id="idPassword" autocomplete="current-password" required><button type="submit" class="id-button primary">Se connecter</button></form>
|
||||
<p id="idLoginMessage" role="alert"></p>
|
||||
</section>
|
||||
<section id="idEditor" hidden>
|
||||
<div class="id-layout">
|
||||
<div class="id-panel id-editor-card">
|
||||
<div class="id-editor-title"><div><h2>Document d'identité</h2><p>Édition directe de <code>config/identity.md</code> · format Markdown</p></div><span id="idCount">0 caractère</span></div>
|
||||
<label class="id-sr-only" for="idText">Instructions Hermes</label>
|
||||
<textarea id="idText" spellcheck="false" autocomplete="off" aria-describedby="idEditorHelp"></textarea>
|
||||
<p class="id-tip" id="idEditorHelp">Les changements du texte s'appliquent aux prochaines demandes vocales. Les sessions Hermes anciennes peuvent conserver leur propre historique.</p>
|
||||
<div class="id-buttons"><button type="button" class="id-button" id="idReload">Recharger</button><button type="button" class="id-button primary" id="idSave">Enregistrer l'identité</button></div>
|
||||
<p id="idMessage" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<aside class="id-side">
|
||||
<section class="id-panel"><p class="id-kicker">IDENTITÉ ACTIVE</p><div class="id-current"><span id="idAvatar">A</span><div><strong id="idActiveName">—</strong><small>Nom configuré dans /config</small></div></div><p>Modifier le nom dans <a href="/config">Configuration</a> synchronise automatiquement les mentions de l'assistant dans ce document.</p></section>
|
||||
<section class="id-panel"><h2>Conseils de rédaction</h2><p>Décris les règles de conversation et les préférences durables. Pour les outils spécialisés, utilise les Skills Hermes.</p><div class="id-example">## Style<br>Tu réponds en français, avec des phrases naturelles.<br><br>## Outils<br>Tu confirmes une action seulement après le succès de son outil.</div></section>
|
||||
<section class="id-panel"><h2>Sauvegardes</h2><p>Une copie est créée avant chaque modification dans <code>config/identity_history/</code>. Les fichiers existants et les noms techniques ne sont pas renommés.</p></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Journaux · Alfred</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-logs-v2.css?v=2">
|
||||
<script src="/static/js/alfred-logs-v2.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="lg-header">
|
||||
<a href="/" class="lg-brand"><span class="lg-letter" id="lgLetter">A</span><span><strong id="lgName">Alfred</strong><small>Journaux de l'assistant</small></span></a>
|
||||
<nav class="lg-nav" aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs" class="active" aria-current="page">Journaux</a></nav>
|
||||
</header>
|
||||
<main class="lg-shell">
|
||||
<div class="lg-title"><div><p class="lg-eyebrow">OBSERVABILITÉ · ALFRED</p><h1>Journaux</h1><p>Activité du service Alfred, reconnaissance vocale, Hermes et appels d'outils lorsqu'ils sont journalisés.</p></div><div class="lg-actions"><button id="lgRefresh" type="button">Actualiser</button><button id="lgExport" type="button">Exporter en JSON</button></div></div>
|
||||
<section class="lg-filters" aria-label="Filtres des événements">
|
||||
<label>Recherche<input id="lgSearch" type="search" placeholder="Proxmox, Whisper, erreur…" autocomplete="off"></label>
|
||||
<label>Composant<select id="lgComponent"><option value="">Tous</option><option>Vocal</option><option>Hermes</option><option>MCP</option><option>Piper</option><option>Système</option></select></label>
|
||||
<label>Niveau<select id="lgLevel"><option value="">Tous</option><option>Information</option><option>Avertissement</option><option>Erreur</option></select></label>
|
||||
<label>Affichage<select id="lgLimit"><option value="100">100 lignes</option><option value="220" selected>220 lignes</option><option value="400">400 lignes</option></select></label>
|
||||
<label class="lg-check"><input id="lgAuto" type="checkbox" checked> Actualisation automatique (10 s)</label>
|
||||
</section>
|
||||
<section class="lg-table-wrap" aria-label="Événements">
|
||||
<div class="lg-table-top"><span id="lgStatus" role="status" aria-live="polite">Connexion…</span><span id="lgCount"></span></div>
|
||||
<div id="lgEvents" class="lg-events" aria-live="off"></div>
|
||||
<p class="lg-note">Source : journal systemd <code>alfred.service</code>. Les commandes Hermes exécutées dans un autre processus ne sont visibles ici que si Alfred les journalise. Les secrets courants sont masqués, mais vérifie les données avant tout partage.</p>
|
||||
</section>
|
||||
</main>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
<!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">
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
<!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">
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<!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 · Surveillance</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">
|
||||
</head>
|
||||
<body data-page="surveillance">
|
||||
<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 href="/config">Configuration</a><a href="/surveillance" class="selected">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">DIAGNOSTIC</p><h1>Surveillance</h1><p class="muted">État des périphériques, de l’assistant et des services locaux.</p></div><div class="page-actions"><span class="tag"><span class="pulse-dot"></span><span id="updatedAt">Chargement…</span></span><button class="button ghost" type="button" id="refreshHealth">Actualiser</button></div></div>
|
||||
<section class="monitor-grid">
|
||||
<article class="panel metric-card" id="cameraCard"><div class="metric-icon">▣</div><div class="metric-info"><span class="microtext muted">CAMÉRA</span><strong>Webcam</strong><span class="status-pill" id="cameraStatus">—</span><p id="cameraDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card" id="microCard"><div class="metric-icon">◉</div><div class="metric-info"><span class="microtext muted">MICROPHONE</span><strong>Capture audio</strong><span class="status-pill" id="microStatus">—</span><p id="microDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card" id="speakerCard"><div class="metric-icon">♫</div><div class="metric-info"><span class="microtext muted">HAUT-PARLEUR</span><strong>Sortie audio</strong><span class="status-pill" id="speakerStatus">—</span><p id="speakerDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card"><div class="metric-icon">◌</div><div class="metric-info"><span class="microtext muted">MOT DE RÉVEIL</span><strong id="wakeLabel">Vosk</strong><span class="status-pill" id="wakeStatus">—</span><p id="wakeDetail" class="muted">Vérification…</p></div></article>
|
||||
</section>
|
||||
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
|
||||
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : services vocaux et minuteurs -->
|
||||
<div class="two-col admin-v2-monitor">
|
||||
<section class="panel" aria-labelledby="v2-stt-title">
|
||||
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2 id="v2-stt-title">Transcription vocale</h2><p>Mode réellement actif et disponibilité du serveur Wyoming</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Mode STT</span><strong id="v2SttActive">—</strong><span class="status-pill" id="v2SttState">—</span></div>
|
||||
<p class="muted microtext" id="v2SttDetail">Vérification…</p>
|
||||
<div class="admin-v2-summary"><span class="muted">Serveur Whisper</span><strong id="v2WhisperAddress">—</strong><span class="status-pill" id="v2WhisperState">—</span></div>
|
||||
<p class="muted microtext" id="v2WhisperDetail">Vérification…</p>
|
||||
<div class="info-note">Wyoming est interrogé avec <code>describe</code> sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.</div>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="v2-timers-title">
|
||||
<div class="panel-title"><span class="panel-symbol">◷</span><div><h2 id="v2-timers-title">Minuteurs et chronomètres</h2><p>État enregistré localement, indépendant des appels Hermes</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Gestionnaire Alfred</span><strong id="v2TimerCount">—</strong><span class="status-pill" id="v2TimerState">—</span></div>
|
||||
<div id="v2TimerList" class="admin-v2-timer-list" aria-live="polite"><p class="muted microtext">Chargement…</p></div>
|
||||
<div class="info-note">La présence d’un MCP dans la liste ci-dessous ne prouve pas qu’Hermes l’a appelé : consulter ses logs pour vérifier les outils exécutés.</div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel admin-v2-maintenance"><div class="panel-title"><span class="panel-symbol">✧</span><div><h2>Maintenance et intégrations</h2><p>Vérifications complémentaires des nouveautés</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Redémarrage depuis /config</span><strong id="v2RestartLabel">—</strong><span class="status-pill" id="v2RestartState">—</span></div>
|
||||
<p class="muted microtext">MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans <code>/opt/hermes</code>. La validation fonctionnelle se fait avec <code>hermes mcp test</code>.</p>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></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 consulter l'état 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>
|
||||
</body></html>
|
||||
@@ -0,0 +1,544 @@
|
||||
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_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})
|
||||
# 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
|
||||
|
||||
# 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()
|
||||
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)})
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
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,24 @@
|
||||
{
|
||||
"version": "ALFRED_DUPLEX_BARGE_IN_V1",
|
||||
"files": {
|
||||
"app.py": {
|
||||
"before": "a96d4d7c5a78d5076934db6033f087cccc32c99f2493cadbffbff6c26cdf0602",
|
||||
"after": "f40d87d07e4b85b35bd87a681c804389b14598991b434f367445e2e575f7799a"
|
||||
},
|
||||
"services/piper_tts.py": {
|
||||
"before": "e087c9809730b619d05104fe0f6354affad1f4c8f22430ac34acab5b3ec0b43e",
|
||||
"after": "2bc8fb780da8630c64e6281ff6bd21357f5d7c20ff46cd6bbd2c2b9887a0a8b1"
|
||||
},
|
||||
"services/admin_console.py": {
|
||||
"before": "27eef32a894d9cecc9a77e70708ae58912355c2f7cc48bd7074068950dfc6c4e",
|
||||
"after": "168409185f801a9a492f2eafd2ff13c628415a57b81395d8cbc2155a7bb2f264"
|
||||
},
|
||||
"templates/config.html": {
|
||||
"before": "c98c0314923d054e8b56251657a33bb4c949684a94faed0a9a979dfd5ed6c8db",
|
||||
"after": "e45f60b54b028422a78809ba8d0b6194beb8f64c74abb562a6cc9e03db029fff"
|
||||
}
|
||||
},
|
||||
"added": {
|
||||
"services/alfred_duplex.py": "5c477fe854a84790c8929b0b30b4fa0f8e0be7cf5a3cb7f097fa790105156728"
|
||||
}
|
||||
}
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
"""Authenticated local configuration and health UI for Alfred.
|
||||
|
||||
Registration is additive and doesn't replace the existing audio, camera, HA,
|
||||
weather or Hermes endpoints. Passwords are provisioned locally via CLI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from dotenv import dotenv_values
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request, session
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
AUTH_FILE = ROOT / 'config' / 'admin_auth.json'
|
||||
GENERAL_ENV = ROOT / 'config' / 'control_center.env'
|
||||
WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env'
|
||||
CONFIG_LOCK = threading.RLock()
|
||||
LOGIN_LOCK = threading.Lock()
|
||||
LOGIN_ATTEMPTS = {}
|
||||
|
||||
# Only these keys are writable; no token, command, system file or shell input.
|
||||
FIELDS = {
|
||||
'ALFRED_NAME': ('general', 'text'),
|
||||
'WAKE_WORD': ('general', 'wake'),
|
||||
'PIPER_VOICE': ('general', 'voice'),
|
||||
'ALFRED_OLLAMA_URL': ('general', 'url'),
|
||||
'ALFRED_COMFYUI_URL': ('weather', 'url'),
|
||||
'HA_WEATHER_ENTITY': ('general', 'entity'),
|
||||
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'),
|
||||
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'),
|
||||
'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'),
|
||||
'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'),
|
||||
'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'),
|
||||
'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'),
|
||||
'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'),
|
||||
'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'),
|
||||
}
|
||||
DEFAULTS = {
|
||||
'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred',
|
||||
'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '',
|
||||
'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '',
|
||||
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2',
|
||||
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6',
|
||||
'ALFRED_WEATHER_DISPLAY_SIZE': '350',
|
||||
'ALFRED_WEATHER_REVIEW': 'true',
|
||||
'ALFRED_WEATHER_REFERENCE_MODE': 'auto',
|
||||
'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56',
|
||||
'ALFRED_WEATHER_PRESERVE_FACE': 'true',
|
||||
'ALFRED_WEATHER_ACCESSORY_LIMIT': '1',
|
||||
}
|
||||
|
||||
|
||||
# ALFRED_ADMIN_SURVEILLANCE_V2 : champs du moteur STT existant.
|
||||
FIELDS.update({
|
||||
'STT_ENGINE': ('general', 'stt_engine'),
|
||||
'WYOMING_WHISPER_HOST': ('general', 'stt_host'),
|
||||
'WYOMING_WHISPER_PORT': ('general', 'stt_port'),
|
||||
'WYOMING_WHISPER_LANGUAGE': ('general', 'stt_language'),
|
||||
'WYOMING_WHISPER_TIMEOUT': ('general', 'stt_timeout'),
|
||||
'WYOMING_WHISPER_CONNECT_TIMEOUT': ('general', 'stt_connect_timeout'),
|
||||
'STT_MAX_AUDIO_SECONDS': ('general', 'stt_max_audio'),
|
||||
})
|
||||
DEFAULTS.update({
|
||||
'STT_ENGINE': 'vosk',
|
||||
'WYOMING_WHISPER_HOST': '192.168.1.13',
|
||||
'WYOMING_WHISPER_PORT': '10300',
|
||||
'WYOMING_WHISPER_LANGUAGE': 'fr',
|
||||
'WYOMING_WHISPER_TIMEOUT': '20',
|
||||
'WYOMING_WHISPER_CONNECT_TIMEOUT': '3',
|
||||
'STT_MAX_AUDIO_SECONDS': '30',
|
||||
})
|
||||
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
|
||||
|
||||
|
||||
def _auth_config():
|
||||
try:
|
||||
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _authenticated():
|
||||
config = _auth_config()
|
||||
password_hash = config.get('password_hash', '')
|
||||
if not password_hash:
|
||||
return False
|
||||
# Changing the admin password instantly invalidates old signed sessions.
|
||||
expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
|
||||
return bool(session.get('alfred_admin') and
|
||||
hmac.compare_digest(str(session.get('auth_version', '')), expected_version))
|
||||
|
||||
|
||||
def _valid_csrf():
|
||||
token = request.headers.get('X-Alfred-CSRF', '')
|
||||
expected = session.get('alfred_csrf', '')
|
||||
return bool(token and expected and hmac.compare_digest(token, expected))
|
||||
|
||||
|
||||
def _trusted_request():
|
||||
origin = request.headers.get('Origin', '')
|
||||
if origin:
|
||||
parsed = urlsplit(origin)
|
||||
if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'):
|
||||
return False
|
||||
if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site':
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _require_admin(handler):
|
||||
@functools.wraps(handler)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not _authenticated():
|
||||
return jsonify(error='Authentification administrateur nécessaire.'), 401
|
||||
if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()):
|
||||
return jsonify(error='Protection anti-CSRF : requête refusée.'), 403
|
||||
return handler(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate(key, value):
|
||||
if key not in FIELDS:
|
||||
raise ValueError(f'Paramètre non autorisé : {key}.')
|
||||
kind = FIELDS[key][1]
|
||||
if kind == 'boolean':
|
||||
if value not in (True, False, 'true', 'false'):
|
||||
raise ValueError(f'{key} doit être vrai ou faux.')
|
||||
return 'true' if value in (True, 'true') else 'false'
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f'Valeur invalide pour {key}.')
|
||||
value = str(value).strip()
|
||||
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
|
||||
raise ValueError(f'Caractères interdits pour {key}.')
|
||||
# ALFRED_ADMIN_SURVEILLANCE_V2: stricte validation des paramètres Whisper.
|
||||
if kind == 'stt_engine':
|
||||
if value not in ('vosk', 'wyoming'):
|
||||
raise ValueError('Moteur STT attendu : vosk ou wyoming.')
|
||||
elif kind == 'stt_host':
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-]{0,252}', value):
|
||||
raise ValueError('Hôte Whisper invalide : indiquer une IP ou un nom DNS, sans protocole.')
|
||||
elif kind == 'stt_port':
|
||||
if not value.isdecimal() or not 1 <= int(value) <= 65535:
|
||||
raise ValueError('Port Whisper invalide (1 à 65535).')
|
||||
value = str(int(value))
|
||||
elif kind == 'stt_language':
|
||||
if not re.fullmatch(r'[a-z]{2}', value.lower()):
|
||||
raise ValueError('Langue Whisper invalide (exemple : fr).')
|
||||
value = value.lower()
|
||||
elif kind in ('stt_timeout', 'stt_connect_timeout'):
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
raise ValueError('Délai Whisper non numérique.')
|
||||
bounds = (5, 90) if kind == 'stt_timeout' else (1, 15)
|
||||
if not bounds[0] <= number <= bounds[1]:
|
||||
raise ValueError(f'Délai Whisper entre {bounds[0]} et {bounds[1]} secondes.')
|
||||
value = str(number).rstrip('0').rstrip('.') if '.' in str(number) else str(number)
|
||||
elif kind == 'stt_max_audio':
|
||||
if not value.isdecimal() or not 5 <= int(value) <= 60:
|
||||
raise ValueError('Enregistrement entre 5 et 60 secondes.')
|
||||
value = str(int(value))
|
||||
elif kind == 'text':
|
||||
if not 2 <= len(value) <= 30:
|
||||
raise ValueError('Le nom doit contenir entre 2 et 30 caractères.')
|
||||
elif kind == 'wake':
|
||||
if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value):
|
||||
raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.')
|
||||
value = value.lower()
|
||||
elif kind == 'voice':
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value):
|
||||
raise ValueError('Identifiant de voix Piper invalide.')
|
||||
elif kind == 'url':
|
||||
if not value:
|
||||
return ''
|
||||
parsed = urlsplit(value)
|
||||
if (parsed.scheme not in ('http', 'https') or not parsed.hostname or
|
||||
parsed.username or parsed.password or parsed.path not in ('', '/') or
|
||||
parsed.query or parsed.fragment):
|
||||
raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.')
|
||||
if parsed.port and not 1 <= parsed.port <= 65535:
|
||||
raise ValueError('Port réseau invalide.')
|
||||
value = value.rstrip('/')
|
||||
elif kind == 'entity':
|
||||
if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value):
|
||||
raise ValueError('Attendu : weather.identifiant ou une valeur vide.')
|
||||
elif kind == 'attempts':
|
||||
if value not in ('1', '2', '3'):
|
||||
raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.')
|
||||
elif kind == 'score':
|
||||
value = str(float(value))
|
||||
if not 1 <= float(value) <= 10:
|
||||
raise ValueError('Score entre 1 et 10.')
|
||||
elif kind == 'size':
|
||||
if value not in ('250', '300', '350', '400', '450', '500'):
|
||||
raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.')
|
||||
elif kind == 'denoise':
|
||||
value = str(float(value))
|
||||
if not 0.25 <= float(value) <= 0.85:
|
||||
raise ValueError('Denoise entre 0.25 et 0.85.')
|
||||
elif kind == 'reference_mode':
|
||||
if value not in ('auto', 'required', 'off'):
|
||||
raise ValueError('Mode référence : auto, required ou off.')
|
||||
elif kind == 'accessory':
|
||||
if value not in ('0', '1', '2'):
|
||||
raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.')
|
||||
return value
|
||||
|
||||
|
||||
def _read_config():
|
||||
local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {}
|
||||
weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
|
||||
effective = {}
|
||||
for key, (group, _) in FIELDS.items():
|
||||
env_file = local if group == 'general' else weather
|
||||
# File values take precedence on the next restart. Show saved values.
|
||||
effective[key] = str(env_file.get(key) if env_file.get(key) is not None
|
||||
else os.getenv(key, DEFAULTS[key]))
|
||||
return effective
|
||||
|
||||
|
||||
def _save_env(path: Path, changes: dict):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = path.read_text(encoding='utf-8') if path.exists() else ''
|
||||
lines = existing.splitlines()
|
||||
updated = set()
|
||||
result = []
|
||||
for line in lines:
|
||||
match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line)
|
||||
if match and match.group(1) in changes:
|
||||
name = match.group(1)
|
||||
if name not in updated:
|
||||
result.append(f'{name}={changes[name]}')
|
||||
updated.add(name)
|
||||
else:
|
||||
result.append(line)
|
||||
for key, value in changes.items():
|
||||
if key not in updated:
|
||||
result.append(f'{key}={value}')
|
||||
tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp')
|
||||
try:
|
||||
with tmp.open('w', encoding='utf-8') as fp:
|
||||
fp.write('\n'.join(result).rstrip() + '\n')
|
||||
fp.flush()
|
||||
os.fsync(fp.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _probe_http(url, *, token=None, timeout=2):
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {},
|
||||
timeout=timeout, allow_redirects=False)
|
||||
if 200 <= response.status_code < 300:
|
||||
state = 'ok'
|
||||
detail = f'HTTP {response.status_code}'
|
||||
elif response.status_code in (401, 403):
|
||||
state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})'
|
||||
else:
|
||||
state, detail = 'warning', f'HTTP {response.status_code}'
|
||||
except requests.RequestException:
|
||||
state, detail = 'down', 'Aucune réponse HTTP'
|
||||
return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)}
|
||||
|
||||
|
||||
def _probe_tcp(host, port, timeout=1.5):
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||||
return {'state': 'ok', 'detail': f'Port {port} accessible'}
|
||||
except (OSError, ValueError):
|
||||
return {'state': 'down', 'detail': f'Port {port} inaccessible'}
|
||||
|
||||
|
||||
def _weather_comfy_url():
|
||||
# Respect the V5 precedence if possible, without reaching into credentials.
|
||||
settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
|
||||
return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or
|
||||
os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or
|
||||
dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '')
|
||||
|
||||
|
||||
def _health(wake, audio_settings):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
audio = audio_settings.get_settings()
|
||||
try:
|
||||
wake_state = wake.status()
|
||||
except Exception:
|
||||
wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'}
|
||||
cam_index = int(os.getenv('CAMERA_INDEX', '0'))
|
||||
camera_path = Path(f'/dev/video{cam_index}')
|
||||
camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK)
|
||||
# Checking /dev/videoN doesn't open the device: no conflict with facial recognition.
|
||||
camera = {'state':'ok' if camera_ok else 'warning',
|
||||
'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'}
|
||||
mute_micro = audio.get('mic_muted', False)
|
||||
microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'),
|
||||
'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')}
|
||||
loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok',
|
||||
'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"}
|
||||
|
||||
config = _read_config()
|
||||
comfy = _weather_comfy_url()
|
||||
ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL')
|
||||
ha_base = os.getenv('HA_URL', '').rstrip('/')
|
||||
tts_host = os.getenv('PIPER_HOST', '').strip()
|
||||
tts_port = os.getenv('PIPER_PORT', '10200')
|
||||
services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'},
|
||||
'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning',
|
||||
'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}}
|
||||
services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119)
|
||||
services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)'
|
||||
services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'}
|
||||
|
||||
mcp_path = Path('/opt/hermes')
|
||||
mcps = {}
|
||||
for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]:
|
||||
found = (mcp_path / slug).exists()
|
||||
mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label}
|
||||
|
||||
disk = shutil.disk_usage(ROOT)
|
||||
mem = {}
|
||||
try:
|
||||
info = {}
|
||||
for line in Path('/proc/meminfo').read_text().splitlines():
|
||||
k, _, v = line.partition(':')
|
||||
if k in ('MemTotal','MemAvailable'):
|
||||
info[k] = int(v.strip().split()[0]) * 1024
|
||||
if info.get('MemTotal'):
|
||||
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
result = {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
|
||||
'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps,
|
||||
'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem,
|
||||
'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}}
|
||||
return _admin_v2_extend_health(result, _read_config)
|
||||
|
||||
|
||||
def register_admin_console(app, wake, audio_settings):
|
||||
"""Register pages/API on the existing Flask app. Call exactly once."""
|
||||
secret_file = ROOT / 'config' / 'admin_secret.key'
|
||||
if not secret_file.exists():
|
||||
raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.')
|
||||
app.secret_key = secret_file.read_bytes()
|
||||
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict',
|
||||
SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true',
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=8))
|
||||
|
||||
bp = Blueprint('alfred_admin', __name__)
|
||||
|
||||
@bp.get('/config')
|
||||
@bp.get('/config.html')
|
||||
def config_page():
|
||||
return render_template('config.html')
|
||||
|
||||
@bp.get('/surveillance')
|
||||
@bp.get('/surveillance.html')
|
||||
def surveillance_page():
|
||||
return render_template('surveillance.html')
|
||||
|
||||
@bp.get('/api/control/auth')
|
||||
def auth_status():
|
||||
authed = _authenticated()
|
||||
return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')),
|
||||
csrf=session.get('alfred_csrf') if authed else None)
|
||||
|
||||
@bp.post('/api/control/login')
|
||||
def login():
|
||||
if not _trusted_request() or not request.is_json:
|
||||
return jsonify(error='Requête refusée.'), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or len(password) > 500:
|
||||
return jsonify(error='Mot de passe incorrect.'), 400
|
||||
now = time.monotonic()
|
||||
client = request.remote_addr or 'unknown'
|
||||
with LOGIN_LOCK:
|
||||
events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900]
|
||||
LOGIN_ATTEMPTS[client] = events
|
||||
if len(events) >= 5:
|
||||
return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429
|
||||
config = _auth_config()
|
||||
password_hash = config.get('password_hash')
|
||||
if not password_hash:
|
||||
return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503
|
||||
if not check_password_hash(password_hash, password):
|
||||
with LOGIN_LOCK:
|
||||
LOGIN_ATTEMPTS[client].append(now)
|
||||
return jsonify(error='Mot de passe incorrect.'), 401
|
||||
with LOGIN_LOCK:
|
||||
LOGIN_ATTEMPTS.pop(client, None)
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session['alfred_admin'] = True
|
||||
session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
|
||||
session['alfred_csrf'] = secrets.token_urlsafe(32)
|
||||
return jsonify(authenticated=True, csrf=session['alfred_csrf'])
|
||||
|
||||
@bp.post('/api/control/logout')
|
||||
@_require_admin
|
||||
def logout():
|
||||
session.clear()
|
||||
return jsonify(ok=True)
|
||||
|
||||
@bp.get('/api/control/config')
|
||||
@_require_admin
|
||||
def config_get():
|
||||
return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.')
|
||||
|
||||
@bp.post('/api/control/config')
|
||||
@_require_admin
|
||||
def config_update():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict) or len(data) > len(FIELDS):
|
||||
return jsonify(error='JSON invalide.'), 400
|
||||
try:
|
||||
normalized = {key: _validate(key, value) for key, value in data.items()}
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'}
|
||||
weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'}
|
||||
try:
|
||||
with CONFIG_LOCK:
|
||||
if general: _save_env(GENERAL_ENV, general)
|
||||
if weather: _save_env(WEATHER_ENV, weather)
|
||||
except OSError as exc:
|
||||
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
|
||||
# ALFRED_IDENTITY_GLASS_V1 : synchroniser le nom après sauvegarde.
|
||||
_identity_sync_warning = None
|
||||
if 'ALFRED_NAME' in normalized:
|
||||
from services.alfred_identity_page import sync_identity_name as _sync_identity_name
|
||||
os.environ['ALFRED_NAME'] = normalized['ALFRED_NAME']
|
||||
try:
|
||||
_sync_identity_name(normalized['ALFRED_NAME'])
|
||||
except (OSError, ValueError) as _identity_error:
|
||||
_identity_sync_warning = str(_identity_error)
|
||||
current_app.logger.warning('Identité non synchronisée: %s', _identity_error)
|
||||
return jsonify(ok=True, restart_required=True, identity_sync_warning=_identity_sync_warning, values=_read_config())
|
||||
|
||||
@bp.get('/api/control/health')
|
||||
@_require_admin
|
||||
def health():
|
||||
return jsonify(_health(wake, audio_settings))
|
||||
|
||||
@bp.post('/api/control/restart')
|
||||
@_require_admin
|
||||
def restart():
|
||||
if os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() not in ('true', '1', 'yes'):
|
||||
return jsonify(error='Redémarrage Web désactivé. Activer ALFRED_ALLOW_WEB_RESTART=true (voir README).'), 403
|
||||
systemctl = shutil.which('systemctl')
|
||||
runner = shutil.which('systemd-run')
|
||||
if not systemctl or not runner:
|
||||
return jsonify(error='systemd-run ou systemctl absent. Redémarrer depuis le terminal.'), 503
|
||||
# Schedule restart in a DIFFERENT systemd unit/cgroup so stopping Alfred
|
||||
# cannot kill the restart command mid-flight.
|
||||
command = [runner, '--unit=alfred-web-restart', '--on-active=2s', '--collect',
|
||||
systemctl, 'restart', 'alfred.service']
|
||||
if os.geteuid() != 0:
|
||||
command = ['sudo', '-n', *command]
|
||||
def do_restart():
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False)
|
||||
if result.returncode:
|
||||
app.logger.error('Planification du redémarrage refusée (%s): %s',
|
||||
result.returncode, result.stderr[:350])
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
app.logger.error('Planification du redémarrage échouée: %s', exc)
|
||||
threading.Thread(target=do_restart, daemon=True).start()
|
||||
return jsonify(accepted=True, message='Demande de redémarrage transmise ; contrôler le retour du service.') , 202
|
||||
|
||||
app.register_blueprint(bp)
|
||||
@@ -0,0 +1,109 @@
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import wave
|
||||
|
||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||
from wyoming.client import AsyncTcpClient
|
||||
from wyoming.tts import Synthesize, SynthesizeVoice
|
||||
|
||||
|
||||
PIPER_HOST = os.getenv("PIPER_HOST", "127.0.0.1")
|
||||
PIPER_PORT = int(os.getenv("PIPER_PORT", "10200"))
|
||||
AUDIO_DEVICE = os.getenv("AUDIO_OUTPUT_DEVICE", "plughw:1,0")
|
||||
|
||||
|
||||
async def synthesize(text, wav_path):
|
||||
client = AsyncTcpClient(PIPER_HOST, PIPER_PORT)
|
||||
|
||||
await client.connect()
|
||||
|
||||
try:
|
||||
await client.write_event(
|
||||
Synthesize(
|
||||
text=text,
|
||||
voice=SynthesizeVoice(
|
||||
name=os.getenv("PIPER_VOICE", "fr_FR-tom-medium")
|
||||
)).event()
|
||||
)
|
||||
|
||||
wav_file = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
event = await asyncio.wait_for(
|
||||
client.read_event(),
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if event is None:
|
||||
raise RuntimeError("Connexion Piper interrompue")
|
||||
|
||||
if AudioStart.is_type(event.type):
|
||||
start = AudioStart.from_event(event)
|
||||
|
||||
wav_file = wave.open(wav_path, "wb")
|
||||
wav_file.setnchannels(start.channels)
|
||||
wav_file.setsampwidth(start.width)
|
||||
wav_file.setframerate(start.rate)
|
||||
|
||||
elif AudioChunk.is_type(event.type):
|
||||
chunk = AudioChunk.from_event(event)
|
||||
|
||||
if wav_file is None:
|
||||
wav_file = wave.open(wav_path, "wb")
|
||||
wav_file.setnchannels(chunk.channels)
|
||||
wav_file.setsampwidth(chunk.width)
|
||||
wav_file.setframerate(chunk.rate)
|
||||
|
||||
wav_file.writeframes(chunk.audio)
|
||||
|
||||
elif AudioStop.is_type(event.type):
|
||||
break
|
||||
|
||||
finally:
|
||||
if wav_file is not None:
|
||||
wav_file.close()
|
||||
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
def speak(text):
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
fd, wav_path = tempfile.mkstemp(
|
||||
prefix="alfred_piper_",
|
||||
suffix=".wav"
|
||||
)
|
||||
os.close(fd)
|
||||
|
||||
start = time.monotonic()
|
||||
|
||||
try:
|
||||
print("[ALFRED PIPER] Synthèse...", flush=True)
|
||||
|
||||
asyncio.run(synthesize(text, wav_path))
|
||||
|
||||
print("[ALFRED PIPER] Lecture...", flush=True)
|
||||
|
||||
subprocess.run(
|
||||
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
|
||||
check=True,
|
||||
timeout=120
|
||||
)
|
||||
|
||||
print(
|
||||
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
|
||||
flush=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ALFRED PIPER] Erreur : {e}", flush=True)
|
||||
raise
|
||||
finally:
|
||||
if os.path.exists(wav_path):
|
||||
os.remove(wav_path)
|
||||
@@ -0,0 +1,80 @@
|
||||
<!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">
|
||||
</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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,560 @@
|
||||
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.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", 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):
|
||||
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
|
||||
|
||||
# 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:
|
||||
duplex_monitor.stop()
|
||||
|
||||
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/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)
|
||||
|
||||
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,21 @@
|
||||
{
|
||||
"version": "ALFRED_DUPLEX_CONVERSATION_V2",
|
||||
"files": {
|
||||
"app.py": {
|
||||
"before": "f40d87d07e4b85b35bd87a681c804389b14598991b434f367445e2e575f7799a",
|
||||
"after": "b552034de304ef5203d26ed9ba5d52928974d7a001cd326f2dde3be057941631"
|
||||
},
|
||||
"services/alfred_duplex.py": {
|
||||
"before": "5c477fe854a84790c8929b0b30b4fa0f8e0be7cf5a3cb7f097fa790105156728",
|
||||
"after": "86b7a00ae5476c6b65e983d35f704e4bd6584dc88bb676ea876f4cc7c8c2736f"
|
||||
},
|
||||
"services/admin_console.py": {
|
||||
"before": "168409185f801a9a492f2eafd2ff13c628415a57b81395d8cbc2155a7bb2f264",
|
||||
"after": "f878ea04e564910c4d6f6b9d60d1048263b66d29c819bc442de19cb02eef08ee"
|
||||
},
|
||||
"templates/config.html": {
|
||||
"before": "e45f60b54b028422a78809ba8d0b6194beb8f64c74abb562a6cc9e03db029fff",
|
||||
"after": "e29cba3bee6e702856f602ca48ceeb554fd4347c96dab7311999fb20fcebb2d7"
|
||||
}
|
||||
}
|
||||
}
|
||||
+514
@@ -0,0 +1,514 @@
|
||||
"""Authenticated local configuration and health UI for Alfred.
|
||||
|
||||
Registration is additive and doesn't replace the existing audio, camera, HA,
|
||||
weather or Hermes endpoints. Passwords are provisioned locally via CLI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from dotenv import dotenv_values
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request, session
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
AUTH_FILE = ROOT / 'config' / 'admin_auth.json'
|
||||
GENERAL_ENV = ROOT / 'config' / 'control_center.env'
|
||||
WEATHER_ENV = ROOT / 'config' / 'weather_outfits.env'
|
||||
CONFIG_LOCK = threading.RLock()
|
||||
LOGIN_LOCK = threading.Lock()
|
||||
LOGIN_ATTEMPTS = {}
|
||||
|
||||
# Only these keys are writable; no token, command, system file or shell input.
|
||||
FIELDS = {
|
||||
'ALFRED_NAME': ('general', 'text'),
|
||||
'WAKE_WORD': ('general', 'wake'),
|
||||
'PIPER_VOICE': ('general', 'voice'),
|
||||
'ALFRED_OLLAMA_URL': ('general', 'url'),
|
||||
'ALFRED_COMFYUI_URL': ('weather', 'url'),
|
||||
'HA_WEATHER_ENTITY': ('general', 'entity'),
|
||||
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': ('weather', 'attempts'),
|
||||
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': ('weather', 'score'),
|
||||
'ALFRED_WEATHER_DISPLAY_SIZE': ('weather', 'size'),
|
||||
'ALFRED_WEATHER_REVIEW': ('weather', 'boolean'),
|
||||
'ALFRED_WEATHER_REFERENCE_MODE': ('weather', 'reference_mode'),
|
||||
'ALFRED_WEATHER_REFERENCE_DENOISE': ('weather', 'denoise'),
|
||||
'ALFRED_WEATHER_PRESERVE_FACE': ('weather', 'boolean'),
|
||||
'ALFRED_WEATHER_ACCESSORY_LIMIT': ('weather', 'accessory'),
|
||||
}
|
||||
DEFAULTS = {
|
||||
'ALFRED_NAME': 'Alfred', 'WAKE_WORD': 'alfred',
|
||||
'PIPER_VOICE': 'fr_FR-tom-medium', 'ALFRED_OLLAMA_URL': '',
|
||||
'ALFRED_COMFYUI_URL': '', 'HA_WEATHER_ENTITY': '',
|
||||
'ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS': '2',
|
||||
'ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE': '8.6',
|
||||
'ALFRED_WEATHER_DISPLAY_SIZE': '350',
|
||||
'ALFRED_WEATHER_REVIEW': 'true',
|
||||
'ALFRED_WEATHER_REFERENCE_MODE': 'auto',
|
||||
'ALFRED_WEATHER_REFERENCE_DENOISE': '0.56',
|
||||
'ALFRED_WEATHER_PRESERVE_FACE': 'true',
|
||||
'ALFRED_WEATHER_ACCESSORY_LIMIT': '1',
|
||||
}
|
||||
|
||||
|
||||
# ALFRED_ADMIN_SURVEILLANCE_V2 : champs du moteur STT existant.
|
||||
FIELDS.update({
|
||||
'STT_ENGINE': ('general', 'stt_engine'),
|
||||
'WYOMING_WHISPER_HOST': ('general', 'stt_host'),
|
||||
'WYOMING_WHISPER_PORT': ('general', 'stt_port'),
|
||||
'WYOMING_WHISPER_LANGUAGE': ('general', 'stt_language'),
|
||||
'WYOMING_WHISPER_TIMEOUT': ('general', 'stt_timeout'),
|
||||
'WYOMING_WHISPER_CONNECT_TIMEOUT': ('general', 'stt_connect_timeout'),
|
||||
'STT_MAX_AUDIO_SECONDS': ('general', 'stt_max_audio'),
|
||||
})
|
||||
DEFAULTS.update({
|
||||
'STT_ENGINE': 'vosk',
|
||||
'WYOMING_WHISPER_HOST': '192.168.1.13',
|
||||
'WYOMING_WHISPER_PORT': '10300',
|
||||
'WYOMING_WHISPER_LANGUAGE': 'fr',
|
||||
'WYOMING_WHISPER_TIMEOUT': '20',
|
||||
'WYOMING_WHISPER_CONNECT_TIMEOUT': '3',
|
||||
'STT_MAX_AUDIO_SECONDS': '30',
|
||||
})
|
||||
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
|
||||
|
||||
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: values saved with existing /config API.
|
||||
FIELDS.update({
|
||||
'DUPLEX_MODE': ('general', 'duplex_mode'),
|
||||
'DUPLEX_MIC_THRESHOLD': ('general', 'duplex_threshold'),
|
||||
})
|
||||
DEFAULTS.update({'DUPLEX_MODE': 'wake', 'DUPLEX_MIC_THRESHOLD': '0.025'})
|
||||
|
||||
def _auth_config():
|
||||
try:
|
||||
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _authenticated():
|
||||
config = _auth_config()
|
||||
password_hash = config.get('password_hash', '')
|
||||
if not password_hash:
|
||||
return False
|
||||
# Changing the admin password instantly invalidates old signed sessions.
|
||||
expected_version = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
|
||||
return bool(session.get('alfred_admin') and
|
||||
hmac.compare_digest(str(session.get('auth_version', '')), expected_version))
|
||||
|
||||
|
||||
def _valid_csrf():
|
||||
token = request.headers.get('X-Alfred-CSRF', '')
|
||||
expected = session.get('alfred_csrf', '')
|
||||
return bool(token and expected and hmac.compare_digest(token, expected))
|
||||
|
||||
|
||||
def _trusted_request():
|
||||
origin = request.headers.get('Origin', '')
|
||||
if origin:
|
||||
parsed = urlsplit(origin)
|
||||
if parsed.netloc.lower() != request.host.lower() or parsed.scheme not in ('http', 'https'):
|
||||
return False
|
||||
if request.headers.get('Sec-Fetch-Site', '').lower() == 'cross-site':
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _require_admin(handler):
|
||||
@functools.wraps(handler)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not _authenticated():
|
||||
return jsonify(error='Authentification administrateur nécessaire.'), 401
|
||||
if request.method != 'GET' and (not _valid_csrf() or not _trusted_request()):
|
||||
return jsonify(error='Protection anti-CSRF : requête refusée.'), 403
|
||||
return handler(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
|
||||
def _validate(key, value):
|
||||
if key not in FIELDS:
|
||||
raise ValueError(f'Paramètre non autorisé : {key}.')
|
||||
kind = FIELDS[key][1]
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1
|
||||
if kind == 'duplex_mode':
|
||||
if value not in ('off', 'wake', 'natural'):
|
||||
raise ValueError('Mode duplex invalide.')
|
||||
return value
|
||||
if kind == 'duplex_threshold':
|
||||
if isinstance(value, bool):
|
||||
raise ValueError('Seuil microphone invalide.')
|
||||
try:
|
||||
threshold = float(value)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
raise ValueError('Seuil microphone invalide.')
|
||||
if not 0.008 <= threshold <= 0.30 or not __import__('math').isfinite(threshold):
|
||||
raise ValueError('Seuil microphone compris entre 0.008 et 0.30.')
|
||||
return f'{threshold:.3f}'
|
||||
if kind == 'boolean':
|
||||
if value not in (True, False, 'true', 'false'):
|
||||
raise ValueError(f'{key} doit être vrai ou faux.')
|
||||
return 'true' if value in (True, 'true') else 'false'
|
||||
if isinstance(value, bool):
|
||||
raise ValueError(f'Valeur invalide pour {key}.')
|
||||
value = str(value).strip()
|
||||
if any(c in value for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
|
||||
raise ValueError(f'Caractères interdits pour {key}.')
|
||||
# ALFRED_ADMIN_SURVEILLANCE_V2: stricte validation des paramètres Whisper.
|
||||
if kind == 'stt_engine':
|
||||
if value not in ('vosk', 'wyoming'):
|
||||
raise ValueError('Moteur STT attendu : vosk ou wyoming.')
|
||||
elif kind == 'stt_host':
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.\-]{0,252}', value):
|
||||
raise ValueError('Hôte Whisper invalide : indiquer une IP ou un nom DNS, sans protocole.')
|
||||
elif kind == 'stt_port':
|
||||
if not value.isdecimal() or not 1 <= int(value) <= 65535:
|
||||
raise ValueError('Port Whisper invalide (1 à 65535).')
|
||||
value = str(int(value))
|
||||
elif kind == 'stt_language':
|
||||
if not re.fullmatch(r'[a-z]{2}', value.lower()):
|
||||
raise ValueError('Langue Whisper invalide (exemple : fr).')
|
||||
value = value.lower()
|
||||
elif kind in ('stt_timeout', 'stt_connect_timeout'):
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
raise ValueError('Délai Whisper non numérique.')
|
||||
bounds = (5, 90) if kind == 'stt_timeout' else (1, 15)
|
||||
if not bounds[0] <= number <= bounds[1]:
|
||||
raise ValueError(f'Délai Whisper entre {bounds[0]} et {bounds[1]} secondes.')
|
||||
value = str(number).rstrip('0').rstrip('.') if '.' in str(number) else str(number)
|
||||
elif kind == 'stt_max_audio':
|
||||
if not value.isdecimal() or not 5 <= int(value) <= 60:
|
||||
raise ValueError('Enregistrement entre 5 et 60 secondes.')
|
||||
value = str(int(value))
|
||||
elif kind == 'text':
|
||||
if not 2 <= len(value) <= 30:
|
||||
raise ValueError('Le nom doit contenir entre 2 et 30 caractères.')
|
||||
elif kind == 'wake':
|
||||
if not re.fullmatch(r'[A-Za-zÀ-ÖØ-öø-ÿ]{2,22}', value):
|
||||
raise ValueError('Le mot-clé Vosk doit être un mot simple de 2 à 22 lettres.')
|
||||
value = value.lower()
|
||||
elif kind == 'voice':
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.-]{2,96}', value):
|
||||
raise ValueError('Identifiant de voix Piper invalide.')
|
||||
elif kind == 'url':
|
||||
if not value:
|
||||
return ''
|
||||
parsed = urlsplit(value)
|
||||
if (parsed.scheme not in ('http', 'https') or not parsed.hostname or
|
||||
parsed.username or parsed.password or parsed.path not in ('', '/') or
|
||||
parsed.query or parsed.fragment):
|
||||
raise ValueError(f'{key} attend une URL http(s) de serveur sans chemin ni identifiants.')
|
||||
if parsed.port and not 1 <= parsed.port <= 65535:
|
||||
raise ValueError('Port réseau invalide.')
|
||||
value = value.rstrip('/')
|
||||
elif kind == 'entity':
|
||||
if value and not re.fullmatch(r'weather\.[a-z0-9_]{2,80}', value):
|
||||
raise ValueError('Attendu : weather.identifiant ou une valeur vide.')
|
||||
elif kind == 'attempts':
|
||||
if value not in ('1', '2', '3'):
|
||||
raise ValueError('Nombre d’essais autorisé : 1, 2 ou 3 par personnage.')
|
||||
elif kind == 'score':
|
||||
value = str(float(value))
|
||||
if not 1 <= float(value) <= 10:
|
||||
raise ValueError('Score entre 1 et 10.')
|
||||
elif kind == 'size':
|
||||
if value not in ('250', '300', '350', '400', '450', '500'):
|
||||
raise ValueError('Taille autorisée : 250, 300, 350, 400, 450 ou 500 px.')
|
||||
elif kind == 'denoise':
|
||||
value = str(float(value))
|
||||
if not 0.25 <= float(value) <= 0.85:
|
||||
raise ValueError('Denoise entre 0.25 et 0.85.')
|
||||
elif kind == 'reference_mode':
|
||||
if value not in ('auto', 'required', 'off'):
|
||||
raise ValueError('Mode référence : auto, required ou off.')
|
||||
elif kind == 'accessory':
|
||||
if value not in ('0', '1', '2'):
|
||||
raise ValueError('Accessoires visibles autorisés : 0, 1 ou 2.')
|
||||
return value
|
||||
|
||||
|
||||
def _read_config():
|
||||
local = dict(dotenv_values(GENERAL_ENV)) if GENERAL_ENV.exists() else {}
|
||||
weather = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
|
||||
effective = {}
|
||||
for key, (group, _) in FIELDS.items():
|
||||
env_file = local if group == 'general' else weather
|
||||
# File values take precedence on the next restart. Show saved values.
|
||||
effective[key] = str(env_file.get(key) if env_file.get(key) is not None
|
||||
else os.getenv(key, DEFAULTS[key]))
|
||||
return effective
|
||||
|
||||
|
||||
def _save_env(path: Path, changes: dict):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing = path.read_text(encoding='utf-8') if path.exists() else ''
|
||||
lines = existing.splitlines()
|
||||
updated = set()
|
||||
result = []
|
||||
for line in lines:
|
||||
match = re.match(r'^\s*([A-Z][A-Z0-9_]*)\s*=', line)
|
||||
if match and match.group(1) in changes:
|
||||
name = match.group(1)
|
||||
if name not in updated:
|
||||
result.append(f'{name}={changes[name]}')
|
||||
updated.add(name)
|
||||
else:
|
||||
result.append(line)
|
||||
for key, value in changes.items():
|
||||
if key not in updated:
|
||||
result.append(f'{key}={value}')
|
||||
tmp = path.with_name(path.name + f'.{secrets.token_hex(4)}.tmp')
|
||||
try:
|
||||
with tmp.open('w', encoding='utf-8') as fp:
|
||||
fp.write('\n'.join(result).rstrip() + '\n')
|
||||
fp.flush()
|
||||
os.fsync(fp.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
tmp.replace(path)
|
||||
finally:
|
||||
tmp.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _probe_http(url, *, token=None, timeout=2):
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = requests.get(url, headers={'Authorization': f'Bearer {token}'} if token else {},
|
||||
timeout=timeout, allow_redirects=False)
|
||||
if 200 <= response.status_code < 300:
|
||||
state = 'ok'
|
||||
detail = f'HTTP {response.status_code}'
|
||||
elif response.status_code in (401, 403):
|
||||
state, detail = 'warning', f'Connexion établie, accès refusé ({response.status_code})'
|
||||
else:
|
||||
state, detail = 'warning', f'HTTP {response.status_code}'
|
||||
except requests.RequestException:
|
||||
state, detail = 'down', 'Aucune réponse HTTP'
|
||||
return {'state': state, 'detail': detail, 'latency_ms': round((time.monotonic()-start)*1000)}
|
||||
|
||||
|
||||
def _probe_tcp(host, port, timeout=1.5):
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=timeout):
|
||||
return {'state': 'ok', 'detail': f'Port {port} accessible'}
|
||||
except (OSError, ValueError):
|
||||
return {'state': 'down', 'detail': f'Port {port} inaccessible'}
|
||||
|
||||
|
||||
def _weather_comfy_url():
|
||||
# Respect the V5 precedence if possible, without reaching into credentials.
|
||||
settings = dict(dotenv_values(WEATHER_ENV)) if WEATHER_ENV.exists() else {}
|
||||
return (_read_config()['ALFRED_COMFYUI_URL'] or settings.get('ALFRED_COMFYUI_URL') or
|
||||
os.getenv('ALFRED_COMFYUI_URL') or os.getenv('COMFYUI_URL') or
|
||||
dict(dotenv_values('/opt/hermes/MCP_comfyui/.env')).get('COMFYUI_URL') or '')
|
||||
|
||||
|
||||
def _health(wake, audio_settings):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
audio = audio_settings.get_settings()
|
||||
try:
|
||||
wake_state = wake.status()
|
||||
except Exception:
|
||||
wake_state = {'running': False, 'paused': True, 'error': 'Impossible de lire Vosk'}
|
||||
cam_index = int(os.getenv('CAMERA_INDEX', '0'))
|
||||
camera_path = Path(f'/dev/video{cam_index}')
|
||||
camera_ok = camera_path.exists() and os.access(camera_path, os.R_OK)
|
||||
# Checking /dev/videoN doesn't open the device: no conflict with facial recognition.
|
||||
camera = {'state':'ok' if camera_ok else 'warning',
|
||||
'detail':f'{camera_path} détectée' if camera_ok else f'{camera_path} indisponible ou permissions insuffisantes'}
|
||||
mute_micro = audio.get('mic_muted', False)
|
||||
microphone = {'state':'muted' if mute_micro else ('ok' if wake_state.get('running') else 'warning'),
|
||||
'detail':'Micro coupé volontairement' if mute_micro else ('Détection Vosk active' if wake_state.get('running') and not wake_state.get('paused') else 'Vosk non disponible ou en pause')}
|
||||
loudspeaker = {'state':'muted' if audio.get('speaker_muted') else 'ok',
|
||||
'detail':'Son coupé' if audio.get('speaker_muted') else f"Volume {audio.get('volume', '?')} %"}
|
||||
|
||||
config = _read_config()
|
||||
comfy = _weather_comfy_url()
|
||||
ollama = config.get('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or os.getenv('OLLAMA_URL')
|
||||
ha_base = os.getenv('HA_URL', '').rstrip('/')
|
||||
tts_host = os.getenv('PIPER_HOST', '').strip()
|
||||
tts_port = os.getenv('PIPER_PORT', '10200')
|
||||
services = {'alfred': {'state':'ok', 'detail':'Serveur Flask actif'},
|
||||
'hermes_cli': {'state':'ok' if shutil.which('hermes') else 'warning',
|
||||
'detail':'CLI détectée' if shutil.which('hermes') else 'CLI non détectée dans le PATH du service'}}
|
||||
services['hermes_dashboard'] = _probe_tcp('127.0.0.1', 9119)
|
||||
services['hermes_dashboard']['label'] = 'Tableau de bord (optionnel)'
|
||||
services['comfyui'] = _probe_http(comfy.rstrip('/') + '/system_stats') if comfy else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['ollama'] = _probe_http(ollama.rstrip('/') + '/api/tags') if ollama else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['home_assistant'] = _probe_http(ha_base + '/api/', token=os.getenv('HA_TOKEN')) if ha_base else {'state':'unknown','detail':'URL non configurée'}
|
||||
services['wyoming_piper'] = _probe_tcp(tts_host, tts_port) if tts_host else {'state':'unknown','detail':'Wyoming Piper non configuré (Piper local possible)'}
|
||||
|
||||
mcp_path = Path('/opt/hermes')
|
||||
mcps = {}
|
||||
for slug, label in [('MCP_home_assistant','Home Assistant'), ('MCP_comfyui','ComfyUI'), ('MCP_face_recognition','Reconnaissance faciale'),('MCP_wazuh','Wazuh')]:
|
||||
found = (mcp_path / slug).exists()
|
||||
mcps[slug] = {'state':'ok' if found else 'unknown', 'detail':'Dossier présent (connexion non vérifiée)' if found else 'Dossier absent', 'label':label}
|
||||
|
||||
disk = shutil.disk_usage(ROOT)
|
||||
mem = {}
|
||||
try:
|
||||
info = {}
|
||||
for line in Path('/proc/meminfo').read_text().splitlines():
|
||||
k, _, v = line.partition(':')
|
||||
if k in ('MemTotal','MemAvailable'):
|
||||
info[k] = int(v.strip().split()[0]) * 1024
|
||||
if info.get('MemTotal'):
|
||||
mem = {'total': info['MemTotal'], 'used': info['MemTotal'] - info.get('MemAvailable', 0)}
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
result = {'time': now, 'audio':audio, 'wake':wake_state, 'camera':camera,
|
||||
'microphone':microphone, 'speaker':loudspeaker, 'services':services, 'mcps':mcps,
|
||||
'system':{'disk_total':disk.total,'disk_used':disk.used, 'memory':mem,
|
||||
'load_1m':round(os.getloadavg()[0], 2) if hasattr(os,'getloadavg') else None}}
|
||||
return _admin_v2_extend_health(result, _read_config)
|
||||
|
||||
|
||||
def register_admin_console(app, wake, audio_settings):
|
||||
"""Register pages/API on the existing Flask app. Call exactly once."""
|
||||
secret_file = ROOT / 'config' / 'admin_secret.key'
|
||||
if not secret_file.exists():
|
||||
raise RuntimeError('Secret administrateur absent ; exécuter install_patch.py.')
|
||||
app.secret_key = secret_file.read_bytes()
|
||||
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Strict',
|
||||
SESSION_COOKIE_SECURE=os.getenv('ALFRED_ADMIN_HTTPS', 'false').lower() == 'true',
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(hours=8))
|
||||
|
||||
bp = Blueprint('alfred_admin', __name__)
|
||||
|
||||
@bp.get('/config')
|
||||
@bp.get('/config.html')
|
||||
def config_page():
|
||||
return render_template('config.html')
|
||||
|
||||
@bp.get('/surveillance')
|
||||
@bp.get('/surveillance.html')
|
||||
def surveillance_page():
|
||||
return render_template('surveillance.html')
|
||||
|
||||
@bp.get('/api/control/auth')
|
||||
def auth_status():
|
||||
authed = _authenticated()
|
||||
return jsonify(authenticated=authed, configured=bool(_auth_config().get('password_hash')),
|
||||
csrf=session.get('alfred_csrf') if authed else None)
|
||||
|
||||
@bp.post('/api/control/login')
|
||||
def login():
|
||||
if not _trusted_request() or not request.is_json:
|
||||
return jsonify(error='Requête refusée.'), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
password = data.get('password', '')
|
||||
if not isinstance(password, str) or len(password) > 500:
|
||||
return jsonify(error='Mot de passe incorrect.'), 400
|
||||
now = time.monotonic()
|
||||
client = request.remote_addr or 'unknown'
|
||||
with LOGIN_LOCK:
|
||||
events = [x for x in LOGIN_ATTEMPTS.get(client, []) if x > now - 900]
|
||||
LOGIN_ATTEMPTS[client] = events
|
||||
if len(events) >= 5:
|
||||
return jsonify(error='Trop de tentatives : réessaie plus tard.'), 429
|
||||
config = _auth_config()
|
||||
password_hash = config.get('password_hash')
|
||||
if not password_hash:
|
||||
return jsonify(error='Définis d’abord le mot de passe avec set_admin_password.py.'), 503
|
||||
if not check_password_hash(password_hash, password):
|
||||
with LOGIN_LOCK:
|
||||
LOGIN_ATTEMPTS[client].append(now)
|
||||
return jsonify(error='Mot de passe incorrect.'), 401
|
||||
with LOGIN_LOCK:
|
||||
LOGIN_ATTEMPTS.pop(client, None)
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session['alfred_admin'] = True
|
||||
session['auth_version'] = __import__('hashlib').sha256(password_hash.encode()).hexdigest()[:24]
|
||||
session['alfred_csrf'] = secrets.token_urlsafe(32)
|
||||
return jsonify(authenticated=True, csrf=session['alfred_csrf'])
|
||||
|
||||
@bp.post('/api/control/logout')
|
||||
@_require_admin
|
||||
def logout():
|
||||
session.clear()
|
||||
return jsonify(ok=True)
|
||||
|
||||
@bp.get('/api/control/config')
|
||||
@_require_admin
|
||||
def config_get():
|
||||
return jsonify(values=_read_config(), note='La plupart des réglages nécessitent un redémarrage.')
|
||||
|
||||
@bp.post('/api/control/config')
|
||||
@_require_admin
|
||||
def config_update():
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict) or len(data) > len(FIELDS):
|
||||
return jsonify(error='JSON invalide.'), 400
|
||||
try:
|
||||
normalized = {key: _validate(key, value) for key, value in data.items()}
|
||||
except (ValueError, TypeError, OverflowError) as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
general = {k:v for k,v in normalized.items() if FIELDS[k][0]=='general'}
|
||||
weather = {k:v for k,v in normalized.items() if FIELDS[k][0]=='weather'}
|
||||
try:
|
||||
with CONFIG_LOCK:
|
||||
if general: _save_env(GENERAL_ENV, general)
|
||||
if weather: _save_env(WEATHER_ENV, weather)
|
||||
except OSError as exc:
|
||||
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
|
||||
# ALFRED_IDENTITY_GLASS_V1 : synchroniser le nom après sauvegarde.
|
||||
_identity_sync_warning = None
|
||||
if 'ALFRED_NAME' in normalized:
|
||||
from services.alfred_identity_page import sync_identity_name as _sync_identity_name
|
||||
os.environ['ALFRED_NAME'] = normalized['ALFRED_NAME']
|
||||
try:
|
||||
_sync_identity_name(normalized['ALFRED_NAME'])
|
||||
except (OSError, ValueError) as _identity_error:
|
||||
_identity_sync_warning = str(_identity_error)
|
||||
current_app.logger.warning('Identité non synchronisée: %s', _identity_error)
|
||||
return jsonify(ok=True, restart_required=True, identity_sync_warning=_identity_sync_warning, values=_read_config())
|
||||
|
||||
@bp.get('/api/control/health')
|
||||
@_require_admin
|
||||
def health():
|
||||
return jsonify(_health(wake, audio_settings))
|
||||
|
||||
@bp.post('/api/control/restart')
|
||||
@_require_admin
|
||||
def restart():
|
||||
if os.getenv('ALFRED_ALLOW_WEB_RESTART', 'false').lower() not in ('true', '1', 'yes'):
|
||||
return jsonify(error='Redémarrage Web désactivé. Activer ALFRED_ALLOW_WEB_RESTART=true (voir README).'), 403
|
||||
systemctl = shutil.which('systemctl')
|
||||
runner = shutil.which('systemd-run')
|
||||
if not systemctl or not runner:
|
||||
return jsonify(error='systemd-run ou systemctl absent. Redémarrer depuis le terminal.'), 503
|
||||
# Schedule restart in a DIFFERENT systemd unit/cgroup so stopping Alfred
|
||||
# cannot kill the restart command mid-flight.
|
||||
command = [runner, '--unit=alfred-web-restart', '--on-active=2s', '--collect',
|
||||
systemctl, 'restart', 'alfred.service']
|
||||
if os.geteuid() != 0:
|
||||
command = ['sudo', '-n', *command]
|
||||
def do_restart():
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=12, check=False)
|
||||
if result.returncode:
|
||||
app.logger.error('Planification du redémarrage refusée (%s): %s',
|
||||
result.returncode, result.stderr[:350])
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
app.logger.error('Planification du redémarrage échouée: %s', exc)
|
||||
threading.Thread(target=do_restart, daemon=True).start()
|
||||
return jsonify(accepted=True, message='Demande de redémarrage transmise ; contrôler le retour du service.') , 202
|
||||
|
||||
app.register_blueprint(bp)
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
"""Barge-in local during a Wyoming Piper response.
|
||||
|
||||
NOT an acoustic echo canceller or full-duplex dialogue engine. The monitor
|
||||
listens only while Alfred is speaking and interrupts *playback*; the user can
|
||||
then utter the next command after the wake listener resumes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import deque
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
text = unicodedata.normalize('NFKD', (text or '').lower())
|
||||
cleaned = re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c)))
|
||||
return ' '.join(cleaned.split())
|
||||
|
||||
|
||||
def looks_like_echo(candidate: str, spoken: str) -> bool:
|
||||
"""Conservative rejection of text already spoken by the assistant.
|
||||
|
||||
This is NOT AEC; overlapping real speech can still be missed or misheard.
|
||||
"""
|
||||
candidate = normalize(candidate)
|
||||
spoken = normalize(spoken)
|
||||
if not candidate or not spoken:
|
||||
return False
|
||||
if candidate in spoken:
|
||||
return True
|
||||
words = candidate.split()
|
||||
if len(words) >= 3:
|
||||
matching = sum(w in set(spoken.split()) for w in words)
|
||||
return matching / len(words) >= 0.85
|
||||
return False
|
||||
|
||||
|
||||
def config() -> dict:
|
||||
mode = os.getenv('DUPLEX_MODE', 'wake').strip().lower()
|
||||
if mode not in {'off', 'wake', 'natural'}:
|
||||
mode = 'off'
|
||||
try:
|
||||
threshold = float(os.getenv('DUPLEX_MIC_THRESHOLD', '0.025'))
|
||||
except ValueError:
|
||||
threshold = 0.025
|
||||
return {'mode': mode, 'threshold': max(0.008, min(threshold, 0.3)),
|
||||
'wake_word': os.getenv('WAKE_WORD', 'alfred').strip().lower(),
|
||||
'aec': 'external_required', 'limitations':
|
||||
'Interruption de la lecture Piper uniquement ; sans AEC externe le mode naturel peut se declencher sur le haut-parleur.'}
|
||||
|
||||
|
||||
class DuplexMonitor:
|
||||
def __init__(self, *, muted, on_interrupt, on_status=None):
|
||||
self.muted = muted
|
||||
self.on_interrupt = on_interrupt
|
||||
self.on_status = on_status or (lambda status: None)
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._lock = threading.RLock()
|
||||
self._interrupted = False
|
||||
self._last_error = None
|
||||
|
||||
def start(self, spoken_text: str) -> bool:
|
||||
settings = config()
|
||||
if settings['mode'] == 'off' or self.muted():
|
||||
return False
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return False
|
||||
self._stop.clear()
|
||||
self._interrupted = False
|
||||
self._last_error = None
|
||||
self._thread = threading.Thread(target=self._listen,
|
||||
args=(spoken_text, settings), daemon=True,
|
||||
name='alfred-duplex-monitor')
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
# Don't hold the lock when joining: callback also acquires it.
|
||||
t = self._thread
|
||||
if t and t is not threading.current_thread():
|
||||
t.join(timeout=0.8)
|
||||
|
||||
def status(self):
|
||||
return {'active': bool(self._thread and self._thread.is_alive() and not self._stop.is_set()),
|
||||
'interrupted': self._interrupted, 'error': self._last_error, **config()}
|
||||
|
||||
def _fire(self, mode):
|
||||
with self._lock:
|
||||
if self._interrupted or self._stop.is_set() or self.muted():
|
||||
return
|
||||
self._interrupted = True
|
||||
self._stop.set()
|
||||
print(f'[ALFRED DUPLEX] Interruption vocale : mode={mode}', flush=True)
|
||||
try:
|
||||
self.on_interrupt()
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
print(f'[ALFRED DUPLEX] Arrêt lecture impossible : {exc}', flush=True)
|
||||
try:
|
||||
self.on_status({'mode': mode, 'interrupted': True})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _listen(self, spoken_text, settings):
|
||||
try:
|
||||
import sounddevice as sd
|
||||
import numpy as np
|
||||
from vosk import KaldiRecognizer
|
||||
from services.audio import get_vosk_model
|
||||
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
|
||||
model_path = os.getenv('WAKE_VOSK_MODEL_PATH') if settings['mode'] == 'wake' else os.getenv('VOSK_MODEL_PATH')
|
||||
if not model_path or not os.path.exists(model_path):
|
||||
raise RuntimeError('Modèle Vosk du mode duplex introuvable')
|
||||
if settings['mode'] == 'wake':
|
||||
grammar = json.dumps([settings['wake_word'], '[unk]'], ensure_ascii=False)
|
||||
recognizer = KaldiRecognizer(get_vosk_model(model_path), rate, grammar)
|
||||
else:
|
||||
recognizer = KaldiRecognizer(get_vosk_model(model_path), rate)
|
||||
frames = max(320, rate // 10)
|
||||
chunks = queue.Queue(maxsize=24)
|
||||
def callback(indata, _frames, _time_info, _status):
|
||||
try:
|
||||
chunks.put_nowait(bytes(indata))
|
||||
except queue.Full:
|
||||
pass
|
||||
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
|
||||
started = time.monotonic()
|
||||
stable_text = None
|
||||
stable_since = 0.0
|
||||
recent_levels = deque(maxlen=10)
|
||||
# The initial audio output may contain a transient click.
|
||||
with sd.RawInputStream(samplerate=rate, blocksize=frames,
|
||||
dtype='int16', channels=1, callback=callback, device=device):
|
||||
while not self._stop.is_set() and not self.muted():
|
||||
try:
|
||||
block = chunks.get(timeout=0.12)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if time.monotonic() - started < 0.55:
|
||||
continue
|
||||
pcm = np.frombuffer(block, dtype=np.int16).astype(np.float32)
|
||||
rms = float(np.sqrt(np.mean(pcm * pcm))) / 32768.0 if pcm.size else 0.0
|
||||
recent_levels.append(rms)
|
||||
speech_level = max(recent_levels, default=0.0)
|
||||
final = recognizer.AcceptWaveform(block)
|
||||
payload = json.loads(recognizer.Result() if final else recognizer.PartialResult())
|
||||
heard = normalize((payload.get('text') if final else payload.get('partial')) or '')
|
||||
if not heard or looks_like_echo(heard, spoken_text):
|
||||
stable_text = None
|
||||
continue
|
||||
if settings['mode'] == 'wake':
|
||||
# Vosk keyword grammar already constrains the vocabulary.
|
||||
if settings['wake_word'] in heard.split() and speech_level >= settings['threshold']:
|
||||
self._fire('wake')
|
||||
break
|
||||
else:
|
||||
if speech_level < settings['threshold']:
|
||||
continue
|
||||
words = heard.split()
|
||||
if len(words) < 2 and heard not in ('stop', 'arrete', 'attends', 'stoppe'):
|
||||
continue
|
||||
if final:
|
||||
self._fire('natural')
|
||||
break
|
||||
if heard != stable_text:
|
||||
stable_text = heard
|
||||
stable_since = time.monotonic()
|
||||
elif time.monotonic() - stable_since >= 0.3:
|
||||
self._fire('natural')
|
||||
break
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
print(f'[ALFRED DUPLEX] Capture indisponible : {exc}', flush=True)
|
||||
@@ -0,0 +1,101 @@
|
||||
<!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">
|
||||
</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>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"patch": "ALFRED_HERMES_NEW_SESSION_V1",
|
||||
"installed": true,
|
||||
"created_at": "2026-10-29T03:39:58.458706",
|
||||
"files": {
|
||||
"app.py": {
|
||||
"existed": true,
|
||||
"before_sha256": "8652f012f05fd6437f5b53fc9d3b76492b1e1b29669750fda200b00d3f078735",
|
||||
"after_sha256": "324ef5ed2d629740f1d1ee51653977492154e71f4143c3b419fbb6e81205f52d",
|
||||
"mode": 420
|
||||
},
|
||||
"services/alfred_hermes_session_page.py": {
|
||||
"existed": false,
|
||||
"before_sha256": null,
|
||||
"after_sha256": "2d009a1a6d629e46e072ac1918849e7cba5279f466a64e5a0fedbaf20628ef2c",
|
||||
"mode": 420
|
||||
},
|
||||
"static/css/alfred-hermes-new-session.css": {
|
||||
"existed": false,
|
||||
"before_sha256": null,
|
||||
"after_sha256": "fa4592869a2660bd5741ef0414ad28503a75fa2853079d6975271f3919865408",
|
||||
"mode": 420
|
||||
},
|
||||
"static/js/alfred-hermes-new-session.js": {
|
||||
"existed": false,
|
||||
"before_sha256": null,
|
||||
"after_sha256": "205c12deeff4823aa9c54be2a0fb071733a9c109e7da5d0f29cc15316b41a9d7",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/index.html": {
|
||||
"existed": true,
|
||||
"before_sha256": "afab6eea4bb25ba1066d5b9f0939d8e76b3daa014fd0ff029e511bebed39ce17",
|
||||
"after_sha256": "0dc9c9119c28636390f662ec6f2058bbd30ef0f780992c3c9d8661dc84204554",
|
||||
"mode": 420
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
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.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)
|
||||
|
||||
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)
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
<!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">
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"installed": true,
|
||||
"created_at": "2026-10-28T22:52:17.749257",
|
||||
"files": {
|
||||
"static/css/alfred-reponse-close-v1.css": {
|
||||
"existed": false,
|
||||
"before_sha256": null,
|
||||
"after_sha256": "b6e878dda903e27d8355c8271a6cde230aaeb55d471d5d67bb74390117d91de4",
|
||||
"mode": 420
|
||||
},
|
||||
"static/js/alfred-reponse-close-v1.js": {
|
||||
"existed": false,
|
||||
"before_sha256": null,
|
||||
"after_sha256": "2a60ddcebb36a16bd417b6e3c5750f96ff5f79648a154422511620de9bd2fb6b",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/index.html": {
|
||||
"existed": true,
|
||||
"before_sha256": "5a8d12151da30fb4ba0b025872d973b2578a0bec79ed166cffc3e2ef9d9d314f",
|
||||
"after_sha256": "60636d8b9cbf0f08f25eb0d5e4c1f5729f8bd009c1a4dc1742939b58a0e27c75",
|
||||
"mode": 420
|
||||
}
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
<!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">
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"version": 1,
|
||||
"installed": true,
|
||||
"created_at": "2026-10-28T22:32:30.218418",
|
||||
"files": {
|
||||
"static/css/alfred-stt-inline-v1.css": {
|
||||
"existed": false,
|
||||
"before_sha256": null,
|
||||
"after_sha256": "14d6a2a0fd9699d8b9087dd4a43368d765d1b7199a02c645630ec4978f6ef33f",
|
||||
"mode": 420
|
||||
},
|
||||
"static/js/alfred-stt-inline-v1.js": {
|
||||
"existed": false,
|
||||
"before_sha256": null,
|
||||
"after_sha256": "6a8924ac196703cc88fca68d02a52761a9d9df8e44c2f0ca801491a090f9c9d6",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/index.html": {
|
||||
"existed": true,
|
||||
"before_sha256": "6b0a7b0d075bb79e3dac909450e35a08cc7b3928a01bf7633cb5811723cd9787",
|
||||
"after_sha256": "5a8d12151da30fb4ba0b025872d973b2578a0bec79ed166cffc3e2ef9d9d314f",
|
||||
"mode": 420
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
<!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">
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"id": "DONA_HOLO_V1",
|
||||
"created_at": "2026-10-29T09:26:17.902596",
|
||||
"installed": true,
|
||||
"files": {
|
||||
"templates/alfred_identity_glass.html": {
|
||||
"existed": true,
|
||||
"before": "bd43ead3b3be510f16084f44121bf2d23b192539eab832bbc0ca60d9f040759c",
|
||||
"after": "57c4da6a4ac148203519022a381e60f01789958a95dc861edbb82f0450722f3c",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/alfred_logs_v2.html": {
|
||||
"existed": true,
|
||||
"before": "c5b41ff1258fe9f3c2169678071673daca4b24223b541b532cb9d348529e1f6c",
|
||||
"after": "c997391f123df6c661f6f9004eaffc2d5edd2a8326cd95634c2a149baa1618a2",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/alfred_maintenance.html": {
|
||||
"existed": true,
|
||||
"before": "509f82fb16040eb65f174be45a5f0dc8048856980f7604e8d9ca6aedd00d4670",
|
||||
"after": "c7d896c43cd6d0b631391dde6aff76850da647fa68173694986e8ffcd5c13306",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/config.html": {
|
||||
"existed": true,
|
||||
"before": "22823b16a4c93b9a3fb9b69538db755509fceb8e2048097caa7965bbcb2ff44b",
|
||||
"after": "aea6ae5c433b593b299c674d55962d1264000cacc0edf54e406f852d62b36f4e",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/configuration.html": {
|
||||
"existed": true,
|
||||
"before": "14fe568c1cefe99184deda0165de8bf54a7d1e5ff45d1a2caa17ebfb835cd861",
|
||||
"after": "83de36ae2581557cb1b4174e3a57e5cb6d7172a82b58ff696c0d755e883dd97a",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/index.html": {
|
||||
"existed": true,
|
||||
"before": "0dc9c9119c28636390f662ec6f2058bbd30ef0f780992c3c9d8661dc84204554",
|
||||
"after": "681b3ae5a5fa33e24195a2ff68ae0ea7f8ed27a3510d5b229159b74daa6db791",
|
||||
"mode": 420
|
||||
},
|
||||
"templates/surveillance.html": {
|
||||
"existed": true,
|
||||
"before": "8f5184d6b888f6a533e3a059ea59bf5e5bfdca06fea5e947faab2d5bcea0750a",
|
||||
"after": "292afc3cbd3e975470c20ed0eec0afb0dc5ecab36d01dc4d4d03279ce423f79d",
|
||||
"mode": 420
|
||||
},
|
||||
"static/css/dona-holographic-v1.css": {
|
||||
"existed": false,
|
||||
"before": null,
|
||||
"after": "f4415028da23e2906042785d7b58331f0b92ce88c1b46b932749751a0173becd",
|
||||
"mode": 420
|
||||
},
|
||||
"static/js/dona-holographic-v1.js": {
|
||||
"existed": false,
|
||||
"before": null,
|
||||
"after": "31ea202d50a1065e978f3c22ff9eeca5a223890c7d8d21d1e9f12dff2ff6c6d2",
|
||||
"mode": 420
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Identité · Assistant</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-identity-glass.css?v=1">
|
||||
<script src="/static/js/alfred-identity-glass.js?v=1" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="id-head">
|
||||
<a class="id-brand" href="/"><span class="id-letter" id="idInitial">A</span><span><strong id="idName">Assistant</strong><small>Identité et personnalité</small></span></a>
|
||||
<nav aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity" class="active" aria-current="page">Identité</a></nav>
|
||||
</header>
|
||||
<main class="id-main">
|
||||
<div class="id-title"><div><p class="id-eyebrow">PERSONNALISATION · HERMES</p><h1>Identité de l'assistant</h1><p>Personnalise la personnalité, le ton et les instructions permanentes transmises à Hermes.</p></div><span class="id-pill" id="idBadge">Vérification…</span></div>
|
||||
<section class="id-panel" id="idLoginPanel" hidden>
|
||||
<h2>Accès administrateur</h2><p>Utilise le même mot de passe que la page Configuration.</p>
|
||||
<form id="idLoginForm"><label for="idPassword">Mot de passe</label><input type="password" id="idPassword" autocomplete="current-password" required><button type="submit" class="id-button primary">Se connecter</button></form>
|
||||
<p id="idLoginMessage" role="alert"></p>
|
||||
</section>
|
||||
<section id="idEditor" hidden>
|
||||
<div class="id-layout">
|
||||
<div class="id-panel id-editor-card">
|
||||
<div class="id-editor-title"><div><h2>Document d'identité</h2><p>Édition directe de <code>config/identity.md</code> · format Markdown</p></div><span id="idCount">0 caractère</span></div>
|
||||
<label class="id-sr-only" for="idText">Instructions Hermes</label>
|
||||
<textarea id="idText" spellcheck="false" autocomplete="off" aria-describedby="idEditorHelp"></textarea>
|
||||
<p class="id-tip" id="idEditorHelp">Les changements du texte s'appliquent aux prochaines demandes vocales. Les sessions Hermes anciennes peuvent conserver leur propre historique.</p>
|
||||
<div class="id-buttons"><button type="button" class="id-button" id="idReload">Recharger</button><button type="button" class="id-button primary" id="idSave">Enregistrer l'identité</button></div>
|
||||
<p id="idMessage" role="status" aria-live="polite"></p>
|
||||
</div>
|
||||
<aside class="id-side">
|
||||
<section class="id-panel"><p class="id-kicker">IDENTITÉ ACTIVE</p><div class="id-current"><span id="idAvatar">A</span><div><strong id="idActiveName">—</strong><small>Nom configuré dans /config</small></div></div><p>Modifier le nom dans <a href="/config">Configuration</a> synchronise automatiquement les mentions de l'assistant dans ce document.</p></section>
|
||||
<section class="id-panel"><h2>Conseils de rédaction</h2><p>Décris les règles de conversation et les préférences durables. Pour les outils spécialisés, utilise les Skills Hermes.</p><div class="id-example">## Style<br>Tu réponds en français, avec des phrases naturelles.<br><br>## Outils<br>Tu confirmes une action seulement après le succès de son outil.</div></section>
|
||||
<section class="id-panel"><h2>Sauvegardes</h2><p>Une copie est créée avant chaque modification dans <code>config/identity_history/</code>. Les fichiers existants et les noms techniques ne sont pas renommés.</p></section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- ALFRED_DEVELOPER_V1 -->
|
||||
<script src="/static/js/alfred-maintenance-link.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Journaux · Alfred</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-logs-v2.css?v=2">
|
||||
<script src="/static/js/alfred-logs-v2.js?v=2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="lg-header">
|
||||
<a href="/" class="lg-brand"><span class="lg-letter" id="lgLetter">A</span><span><strong id="lgName">Alfred</strong><small>Journaux de l'assistant</small></span></a>
|
||||
<nav class="lg-nav" aria-label="Navigation"><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs" class="active" aria-current="page">Journaux</a></nav>
|
||||
</header>
|
||||
<main class="lg-shell">
|
||||
<div class="lg-title"><div><p class="lg-eyebrow">OBSERVABILITÉ · ALFRED</p><h1>Journaux</h1><p>Activité du service Alfred, reconnaissance vocale, Hermes et appels d'outils lorsqu'ils sont journalisés.</p></div><div class="lg-actions"><button id="lgRefresh" type="button">Actualiser</button><button id="lgExport" type="button">Exporter en JSON</button></div></div>
|
||||
<section class="lg-filters" aria-label="Filtres des événements">
|
||||
<label>Recherche<input id="lgSearch" type="search" placeholder="Proxmox, Whisper, erreur…" autocomplete="off"></label>
|
||||
<label>Composant<select id="lgComponent"><option value="">Tous</option><option>Vocal</option><option>Hermes</option><option>MCP</option><option>Piper</option><option>Système</option></select></label>
|
||||
<label>Niveau<select id="lgLevel"><option value="">Tous</option><option>Information</option><option>Avertissement</option><option>Erreur</option></select></label>
|
||||
<label>Affichage<select id="lgLimit"><option value="100">100 lignes</option><option value="220" selected>220 lignes</option><option value="400">400 lignes</option></select></label>
|
||||
<label class="lg-check"><input id="lgAuto" type="checkbox" checked> Actualisation automatique (10 s)</label>
|
||||
</section>
|
||||
<section class="lg-table-wrap" aria-label="Événements">
|
||||
<div class="lg-table-top"><span id="lgStatus" role="status" aria-live="polite">Connexion…</span><span id="lgCount"></span></div>
|
||||
<div id="lgEvents" class="lg-events" aria-live="off"></div>
|
||||
<p class="lg-note">Source : journal systemd <code>alfred.service</code>. Les commandes Hermes exécutées dans un autre processus ne sont visibles ici que si Alfred les journalise. Les secrets courants sont masqués, mais vérifie les données avant tout partage.</p>
|
||||
</section>
|
||||
</main>
|
||||
<!-- 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>
|
||||
</body>
|
||||
</html>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="fr"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow"><title>Maintenance · Dona</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-developer-glass.css?v=1">
|
||||
<script defer src="/static/js/alfred-developer-glass.js?v=1"></script>
|
||||
</head><body>
|
||||
<header class="dv-head"><a class="dv-brand" href="/"><span class="dv-initial" id="dvInitial">A</span><span><strong id="dvName">Assistant</strong><small>Maintenance & développement</small></span></a>
|
||||
<nav><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity">Identité</a><a href="/maintenance" class="active" aria-current="page">Maintenance</a></nav></header>
|
||||
<main class="dv-main"><div class="dv-intro"><div><p class="dv-eyebrow">HERMES · DÉVELOPPEMENT CONTRÔLÉ</p><h1>Maintenance & modifications</h1><p>Hermes prépare le code dans une copie Git. Tu vérifies et approuves ici avant toute modification en production.</p></div><button id="dvRefresh" type="button">Actualiser</button></div>
|
||||
<section class="dv-panel" id="dvLoginBox" hidden><h2>Accès administrateur</h2><p>Utilise le même mot de passe que Configuration.</p><form id="dvLogin"><input type="password" id="dvPassword" autocomplete="current-password" placeholder="Mot de passe administrateur" required><button type="submit">Connexion</button></form><p id="dvLoginInfo" role="alert"></p></section>
|
||||
<div id="dvApp" class="dv-layout" hidden>
|
||||
<section class="dv-panel dv-list"><div class="dv-section-title"><h2>Propositions</h2><span id="dvCount">—</span></div><p class="dv-muted">Demande à Hermes : « Prépare une modification de ton interface ».</p><div id="dvTasks" role="list" class="dv-tasks"></div></section>
|
||||
<section class="dv-panel dv-detail"><div class="dv-section-title"><h2>Détail & validation</h2><span id="dvState">Sélectionne une proposition</span></div>
|
||||
<div id="dvEmpty" class="dv-empty">Les modifications apparaîtront ici lorsqu'Hermes aura créé une proposition.</div>
|
||||
<div id="dvSelected" hidden><h3 id="dvTitle"></h3><p class="dv-muted" id="dvMeta"></p><h4>Contrôles</h4><div id="dvChecks" class="dv-checks"></div>
|
||||
<h4>Diff des fichiers</h4><pre id="dvDiff" class="dv-diff" tabindex="0"></pre><p class="dv-muted">Le diff peut contenir du code sensible. Ne l'exporte pas publiquement.</p>
|
||||
<div class="dv-actions" id="dvActions"><button class="dv-primary" type="button" id="dvApprove">Approuver la proposition</button><button type="button" id="dvReject">Refuser</button></div>
|
||||
<div class="dv-next" id="dvNext" hidden><strong>Après approbation</strong><p>Le déploiement reste manuel : depuis SSH, lance la commande de déploiement avec l'identifiant affiché. Aucun bouton Web ne modifie le code de production.</p><code id="dvCommand"></code></div>
|
||||
<p id="dvMessage" role="status" aria-live="polite"></p></div>
|
||||
</section>
|
||||
</div><p class="dv-footnote">Sécurité : les contrôles V1 sont statiques (syntaxe Python et JavaScript, HTML, CSS). Ils ne garantissent pas qu'une modification sera fonctionnelle. Déployer uniquement après revue.</p></main>
|
||||
</body></html>
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<!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">
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Alfred — Configuration audio</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: Inter, system-ui, -apple-system, sans-serif; background:#15171a; color:#f3f4f6; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:28px; }
|
||||
main { width:min(100%,640px); }
|
||||
header { margin-bottom:30px; }
|
||||
h1 { font-size:1.8rem; font-weight:550; margin:0 0 8px; }
|
||||
.subtitle { color:#a5a9b0; margin:0; }
|
||||
.panel { background:#202328; border:1px solid #34383f; border-radius:18px; padding:26px; }
|
||||
.section { padding:20px 0; }
|
||||
.section:first-child { padding-top:0; }
|
||||
.section:last-child { padding-bottom:0; }
|
||||
.section + .section { border-top:1px solid #353940; }
|
||||
.line { display:flex; align-items:center; justify-content:space-between; gap:18px; }
|
||||
.label { display:flex; align-items:center; gap:12px; font-size:1.05rem; }
|
||||
.icon { width:24px; height:24px; color:#c9cdd3; flex-shrink:0; }
|
||||
.value { font-variant-numeric:tabular-nums; color:#c8cbd1; }
|
||||
input[type=range] { width:100%; margin:23px 0 2px; accent-color:#e5e7eb; cursor:pointer; }
|
||||
input[type=range]:disabled { opacity:.35; cursor:not-allowed; }
|
||||
.switch { position:relative; width:54px; height:30px; display:inline-block; flex-shrink:0; }
|
||||
.switch input { opacity:0; width:0; height:0; position:absolute; }
|
||||
.track { position:absolute; inset:0; background:#525760; border-radius:99px; transition:background .2s; cursor:pointer; }
|
||||
.track:before { content:""; position:absolute; width:22px; height:22px; top:4px; left:4px; background:white; border-radius:50%; transition:transform .2s; }
|
||||
.switch input:checked + .track { background:#ad5555; }
|
||||
.switch input:checked + .track:before { transform:translateX(24px); }
|
||||
.switch input:focus-visible + .track { outline:2px solid white; outline-offset:3px; }
|
||||
.hint { margin:9px 0 0 36px; color:#9298a2; font-size:.88rem; }
|
||||
#status { min-height:1.3em; margin:18px 0 0; color:#a5a9b0; font-size:.86rem; }
|
||||
a { color:#cdd2da; text-decoration:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>Configuration</h1>
|
||||
<p class="subtitle">Alfred · Paramètres audio</p>
|
||||
</header>
|
||||
<div class="panel">
|
||||
<section class="section">
|
||||
<div class="line">
|
||||
<label class="label" for="volume">
|
||||
<svg class="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 6 9H3v6h3l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M19 5a10 10 0 0 1 0 14"/></svg>
|
||||
Volume de sortie
|
||||
</label>
|
||||
<output class="value" id="volumeValue" for="volume">50 %</output>
|
||||
</div>
|
||||
<input id="volume" type="range" min="0" max="100" value="50" step="1" aria-label="Volume de sortie">
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="line">
|
||||
<span class="label" id="speakerLabel">
|
||||
<svg class="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 6 9H3v6h3l5 4V5Z"/><path d="m17 9 5 6m0-6-5 6"/></svg>
|
||||
Couper le haut-parleur
|
||||
</span>
|
||||
<label class="switch"><input type="checkbox" id="speakerMute" aria-labelledby="speakerLabel"><span class="track"></span></label>
|
||||
</div>
|
||||
<p class="hint">Désactive les réponses vocales d’Alfred.</p>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="line">
|
||||
<span class="label" id="micLabel">
|
||||
<svg class="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" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5m-4 0h8"/></svg>
|
||||
Couper le microphone
|
||||
</span>
|
||||
<label class="switch"><input type="checkbox" id="micMute" aria-labelledby="micLabel"><span class="track"></span></label>
|
||||
</div>
|
||||
<p class="hint">Suspend l’écoute et la détection du mot « Alfred ».</p>
|
||||
</section>
|
||||
</div>
|
||||
<p id="status" role="status" aria-live="polite"></p>
|
||||
</main>
|
||||
<script>
|
||||
'use strict';
|
||||
// API attendue côté Flask :
|
||||
// GET /api/audio/settings -> {"volume": 50, "speaker_muted": false, "microphone_muted": false}
|
||||
// PATCH /api/audio/settings <- mêmes clés, partielles acceptées.
|
||||
// Le navigateur n'agit PAS directement sur ALSA ou Vosk.
|
||||
const volume = document.getElementById('volume');
|
||||
const volumeValue = document.getElementById('volumeValue');
|
||||
const speakerMute = document.getElementById('speakerMute');
|
||||
const micMute = document.getElementById('micMute');
|
||||
const status = document.getElementById('status');
|
||||
let volumeTimer;
|
||||
function setStatus(message) { status.textContent = message; }
|
||||
function render(data) {
|
||||
if (typeof data.volume === 'number') volume.value = data.volume;
|
||||
if (typeof data.speaker_muted === 'boolean') speakerMute.checked = data.speaker_muted;
|
||||
if (typeof data.microphone_muted === 'boolean') micMute.checked = data.microphone_muted;
|
||||
volumeValue.textContent = `${volume.value} %`;
|
||||
}
|
||||
async function save(change) {
|
||||
try {
|
||||
const response = await fetch('/api/audio/settings', {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(change)
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
render(await response.json());
|
||||
setStatus('Paramètres appliqués.');
|
||||
} catch (error) {
|
||||
setStatus('Impossible d’appliquer le réglage : API Flask non disponible ou erreur serveur.');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
volume.addEventListener('input', () => {
|
||||
volumeValue.textContent = `${volume.value} %`;
|
||||
clearTimeout(volumeTimer);
|
||||
volumeTimer = setTimeout(() => save({ volume: Number(volume.value) }), 180);
|
||||
});
|
||||
speakerMute.addEventListener('change', () => save({ speaker_muted: speakerMute.checked }));
|
||||
micMute.addEventListener('change', () => save({ microphone_muted: micMute.checked }));
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/audio/settings');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
render(await response.json());
|
||||
setStatus('');
|
||||
} catch (_) {
|
||||
setStatus('Interface prête. Connexion à l’API audio Flask nécessaire pour contrôler le matériel.');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<!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">
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<!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 · Surveillance</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">
|
||||
</head>
|
||||
<body data-page="surveillance">
|
||||
<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 href="/config">Configuration</a><a href="/surveillance" class="selected">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">DIAGNOSTIC</p><h1>Surveillance</h1><p class="muted">État des périphériques, de l’assistant et des services locaux.</p></div><div class="page-actions"><span class="tag"><span class="pulse-dot"></span><span id="updatedAt">Chargement…</span></span><button class="button ghost" type="button" id="refreshHealth">Actualiser</button></div></div>
|
||||
<section class="monitor-grid">
|
||||
<article class="panel metric-card" id="cameraCard"><div class="metric-icon">▣</div><div class="metric-info"><span class="microtext muted">CAMÉRA</span><strong>Webcam</strong><span class="status-pill" id="cameraStatus">—</span><p id="cameraDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card" id="microCard"><div class="metric-icon">◉</div><div class="metric-info"><span class="microtext muted">MICROPHONE</span><strong>Capture audio</strong><span class="status-pill" id="microStatus">—</span><p id="microDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card" id="speakerCard"><div class="metric-icon">♫</div><div class="metric-info"><span class="microtext muted">HAUT-PARLEUR</span><strong>Sortie audio</strong><span class="status-pill" id="speakerStatus">—</span><p id="speakerDetail" class="muted">Vérification…</p></div></article>
|
||||
<article class="panel metric-card"><div class="metric-icon">◌</div><div class="metric-info"><span class="microtext muted">MOT DE RÉVEIL</span><strong id="wakeLabel">Vosk</strong><span class="status-pill" id="wakeStatus">—</span><p id="wakeDetail" class="muted">Vérification…</p></div></article>
|
||||
</section>
|
||||
<div class="two-col monitor-lower"><section class="panel"><div class="panel-title"><span class="panel-symbol">⇄</span><div><h2>Services et connexions</h2><p>Tests réels : HTTP, port TCP ou présence du binaire</p></div></div><div class="service-list" id="serviceList"><p class="muted">Chargement…</p></div></section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">▤</span><div><h2>Système</h2><p>Ressources du mini-PC hébergeant Alfred</p></div></div><div class="resource"><div class="resource-title"><span>Disque de l’application</span><strong id="diskPercent">—</strong></div><div class="meter"><span id="diskBar"></span></div><p id="diskText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Mémoire RAM</span><strong id="memPercent">—</strong></div><div class="meter"><span id="memBar"></span></div><p id="memText" class="muted microtext">—</p></div><div class="resource"><div class="resource-title"><span>Charge système (1 min)</span><strong id="loadAvg">—</strong></div></div><div class="info-note">Les contrôles de caméra n'ouvrent pas le flux vidéo, afin de ne pas bloquer la reconnaissance faciale.</div></section></div>
|
||||
|
||||
<!-- ALFRED_ADMIN_SURVEILLANCE_V2 : services vocaux et minuteurs -->
|
||||
<div class="two-col admin-v2-monitor">
|
||||
<section class="panel" aria-labelledby="v2-stt-title">
|
||||
<div class="panel-title"><span class="panel-symbol">◉</span><div><h2 id="v2-stt-title">Transcription vocale</h2><p>Mode réellement actif et disponibilité du serveur Wyoming</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Mode STT</span><strong id="v2SttActive">—</strong><span class="status-pill" id="v2SttState">—</span></div>
|
||||
<p class="muted microtext" id="v2SttDetail">Vérification…</p>
|
||||
<div class="admin-v2-summary"><span class="muted">Serveur Whisper</span><strong id="v2WhisperAddress">—</strong><span class="status-pill" id="v2WhisperState">—</span></div>
|
||||
<p class="muted microtext" id="v2WhisperDetail">Vérification…</p>
|
||||
<div class="info-note">Wyoming est interrogé avec <code>describe</code> sans lancer de transcription. Une connexion réussie ne garantit pas la qualité STT.</div>
|
||||
</section>
|
||||
<section class="panel" aria-labelledby="v2-timers-title">
|
||||
<div class="panel-title"><span class="panel-symbol">◷</span><div><h2 id="v2-timers-title">Minuteurs et chronomètres</h2><p>État enregistré localement, indépendant des appels Hermes</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Gestionnaire Alfred</span><strong id="v2TimerCount">—</strong><span class="status-pill" id="v2TimerState">—</span></div>
|
||||
<div id="v2TimerList" class="admin-v2-timer-list" aria-live="polite"><p class="muted microtext">Chargement…</p></div>
|
||||
<div class="info-note">La présence d’un MCP dans la liste ci-dessous ne prouve pas qu’Hermes l’a appelé : consulter ses logs pour vérifier les outils exécutés.</div>
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel admin-v2-maintenance"><div class="panel-title"><span class="panel-symbol">✧</span><div><h2>Maintenance et intégrations</h2><p>Vérifications complémentaires des nouveautés</p></div></div>
|
||||
<div class="admin-v2-summary"><span class="muted">Redémarrage depuis /config</span><strong id="v2RestartLabel">—</strong><span class="status-pill" id="v2RestartState">—</span></div>
|
||||
<p class="muted microtext">MCP Autres (heure/date) et Alfred Timers : la grille ci-dessous signale les dossiers détectés dans <code>/opt/hermes</code>. La validation fonctionnelle se fait avec <code>hermes mcp test</code>.</p>
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><span class="panel-symbol">⚙</span><div><h2>MCP Hermes détectés</h2><p>Présence des répertoires, pas un test de communication des outils MCP</p></div></div><div class="mcp-grid" id="mcpList"><p class="muted">Chargement…</p></div></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 consulter l'état 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>
|
||||
</body></html>
|
||||
Binary file not shown.
+55
-9
@@ -6,6 +6,8 @@ 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.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
|
||||
@@ -35,6 +37,11 @@ mic_stop_event=threading.Event()
|
||||
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()
|
||||
|
||||
@@ -46,20 +53,25 @@ def emit_state(mode=None, **extra):
|
||||
|
||||
def audio_level(level): socketio.emit("audio_level",{"level":max(0,min(float(level),1))})
|
||||
|
||||
def conversation(score=1.0):
|
||||
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)
|
||||
|
||||
r = listen_once(
|
||||
stop_event=mic_stop_event,
|
||||
on_partial=lambda p: emit_state("listening", partial=p),
|
||||
on_level=audio_level
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -176,8 +188,23 @@ def conversation(score=1.0):
|
||||
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)
|
||||
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="")
|
||||
|
||||
@@ -190,7 +217,12 @@ def conversation(score=1.0):
|
||||
|
||||
finally:
|
||||
conversation_lock.release()
|
||||
if not audio_settings.mic_muted():
|
||||
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)
|
||||
|
||||
@@ -205,6 +237,10 @@ def on_wake(score):
|
||||
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())
|
||||
@@ -538,6 +574,16 @@ _alfred_register_logs_v2(app)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
+58
@@ -655,5 +655,63 @@
|
||||
},
|
||||
"cached_at": 1793217002.2921214,
|
||||
"focus_label": "aujourd’hui"
|
||||
},
|
||||
"8a11c9e955ab8e454af78f4dda695d060a3fc2923c68a5fcfd0d26a253b1ed02": {
|
||||
"file": "8a11c9e955ab8e454af78f4dda695d060a3fc2923c68a5fcfd0d26a253b1ed02.jpg",
|
||||
"prompt_ids": [
|
||||
"4e358205-1cf0-429c-b924-6013953dd1b2",
|
||||
"0385002e-6707-4507-a19a-05bd775f21b3"
|
||||
],
|
||||
"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": 1793230507.5417078,
|
||||
"focus_label": "aujourd’hui"
|
||||
},
|
||||
"984c13f455b0a52288299eb0803a9774b076a268557b36d933f1351aac77ebf0": {
|
||||
"file": "984c13f455b0a52288299eb0803a9774b076a268557b36d933f1351aac77ebf0.jpg",
|
||||
"prompt_ids": [
|
||||
"c4a256f0-9e5f-4297-a852-ba9d7152d2a5",
|
||||
"d5e93fc5-7328-4ce1-b700-3f2e65d9508b"
|
||||
],
|
||||
"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": 1793266575.8636355,
|
||||
"focus_label": "aujourd’hui"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -1,7 +1,7 @@
|
||||
# Configuration générale Alfred (page /config)
|
||||
# ALFRED_ALLOW_WEB_RESTART=true
|
||||
ALFRED_NAME=Magalie
|
||||
WAKE_WORD=magalie
|
||||
ALFRED_NAME=Dona
|
||||
WAKE_WORD=dona
|
||||
PIPER_VOICE=fr_FR-upmc-medium
|
||||
ALFRED_OLLAMA_URL=
|
||||
HA_WEATHER_ENTITY=
|
||||
@@ -12,3 +12,9 @@ WYOMING_WHISPER_LANGUAGE=fr
|
||||
WYOMING_WHISPER_TIMEOUT=20
|
||||
WYOMING_WHISPER_CONNECT_TIMEOUT=3
|
||||
STT_MAX_AUDIO_SECONDS=30
|
||||
DUPLEX_MODE=wake
|
||||
DUPLEX_MIC_THRESHOLD=0.025
|
||||
DUPLEX_STOP_COMMANDS=true
|
||||
DUPLEX_HANDOFF=true
|
||||
DUPLEX_CONVERSATION=true
|
||||
DUPLEX_CONVERSATION_SECONDS=6
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"session_id": "local-f6c54762e29b37dc1b98da779a3690e4ddf8cb585d11705c6ee88e828ac59737"
|
||||
"session_id": "local-81ecdeab0eb437a53f1bc22637c11140819774d33839f82203e41a5e31ec69de"
|
||||
}
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"session_id": "local-f6c54762e29b37dc1b98da779a3690e4ddf8cb585d11705c6ee88e828ac59737"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# Identité et comportement de Magalie
|
||||
# Identité et comportement de Dona
|
||||
|
||||
Tu es Magalie, le majordome vocal personnel de la maison.
|
||||
Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
|
||||
Tu es Dona, le majordome vocal personnel de la maison.
|
||||
Tu t'appelles Dona, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
|
||||
|
||||
|
||||
## Langue et style
|
||||
@@ -10,6 +10,7 @@ Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner es
|
||||
- Adopte un ton naturel, calme, cordial et professionnel.
|
||||
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
|
||||
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
|
||||
- Tu peux si necessaire, illustrer avec tes tools de generation d'images
|
||||
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
|
||||
|
||||
## Domotique
|
||||
@@ -18,10 +19,10 @@ Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner es
|
||||
- Si tu ne peux pas effectuer une action, explique-le simplement.
|
||||
|
||||
## Identité
|
||||
- Si l'on te demande ton nom, réponds : « Je m'appelle Magalie. »
|
||||
- Si l'on te demande ton nom, réponds : « Je m'appelle Dona. »
|
||||
|
||||
|
||||
# Magalie
|
||||
# Dona
|
||||
Tu disposes des outils et compétences configurés
|
||||
dans Hermes Agent.
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Identité et comportement de Magalie
|
||||
|
||||
Tu es Magalie, le majordome vocal personnel de la maison.
|
||||
Tu t'appelles Magalie, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
|
||||
|
||||
|
||||
## Langue et style
|
||||
- Réponds toujours en français, sauf demande explicite de parler une autre langue.
|
||||
- Tutoie ton interlocuteur.
|
||||
- Adopte un ton naturel, calme, cordial et professionnel.
|
||||
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
|
||||
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
|
||||
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
|
||||
|
||||
## Domotique
|
||||
- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles.
|
||||
- Ne confirme une action que si son exécution a été effectivement vérifiée.
|
||||
- Si tu ne peux pas effectuer une action, explique-le simplement.
|
||||
|
||||
## Identité
|
||||
- Si l'on te demande ton nom, réponds : « Je m'appelle Magalie. »
|
||||
|
||||
|
||||
# Magalie
|
||||
Tu disposes des outils et compétences configurés
|
||||
dans Hermes Agent.
|
||||
|
||||
Utilise librement les outils disponibles lorsque
|
||||
la demande le nécessite.
|
||||
|
||||
Ne limite pas tes capacités à la domotique.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Identité et comportement de Dona
|
||||
|
||||
Tu es Dona, le majordome vocal personnel de la maison.
|
||||
Tu t'appelles Dona, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
|
||||
|
||||
|
||||
## Langue et style
|
||||
- Réponds toujours en français, sauf demande explicite de parler une autre langue.
|
||||
- Tutoie ton interlocuteur.
|
||||
- Adopte un ton naturel, calme, cordial et professionnel.
|
||||
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
|
||||
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
|
||||
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
|
||||
|
||||
## Domotique
|
||||
- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles.
|
||||
- Ne confirme une action que si son exécution a été effectivement vérifiée.
|
||||
- Si tu ne peux pas effectuer une action, explique-le simplement.
|
||||
|
||||
## Identité
|
||||
- Si l'on te demande ton nom, réponds : « Je m'appelle Dona. »
|
||||
|
||||
|
||||
# Dona
|
||||
Tu disposes des outils et compétences configurés
|
||||
dans Hermes Agent.
|
||||
|
||||
Utilise librement les outils disponibles lorsque
|
||||
la demande le nécessite.
|
||||
|
||||
Ne limite pas tes capacités à la domotique.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Identité et comportement de Dona
|
||||
|
||||
Tu es Dona, le majordome vocal personnel de la maison.
|
||||
Tu t'appelles Dona, même si le moteur logiciel qui te permet de raisonner est Hermes Agent.
|
||||
|
||||
|
||||
## Langue et style
|
||||
- Réponds toujours en français, sauf demande explicite de parler une autre langue.
|
||||
- Tutoie ton interlocuteur.
|
||||
- Adopte un ton naturel, calme, cordial et professionnel.
|
||||
- Privilégie les réponses brèves, claires et faciles à prononcer à voix haute.
|
||||
- Évite le Markdown, les tableaux et les longues listes lorsque tu réponds oralement.
|
||||
- Tu peux si necessaire, illustrer avec tes tools de generation d'image
|
||||
- Ne parle pas spontanément de Hermes, d'Ollama ou de ton architecture interne.
|
||||
|
||||
## Domotique
|
||||
- Lorsque la demande nécessite une action domotique, utilise les outils Home Assistant disponibles.
|
||||
- Ne confirme une action que si son exécution a été effectivement vérifiée.
|
||||
- Si tu ne peux pas effectuer une action, explique-le simplement.
|
||||
|
||||
## Identité
|
||||
- Si l'on te demande ton nom, réponds : « Je m'appelle Dona. »
|
||||
|
||||
|
||||
# Dona
|
||||
Tu disposes des outils et compétences configurés
|
||||
dans Hermes Agent.
|
||||
|
||||
Utilise librement les outils disponibles lorsque
|
||||
la demande le nécessite.
|
||||
|
||||
Ne limite pas tes capacités à la domotique.
|
||||
@@ -1 +1 @@
|
||||
{"name": "Magalie"}
|
||||
{"name": "Dona"}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Maximum 2 essais par personnage (1 seul si une image est deja suffisante)
|
||||
ALFRED_WEATHER_ILLUSTRATION_ATTEMPTS=2
|
||||
# Score de qualite attendu, lorsque le workflow vision peut analyser l'image
|
||||
ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE=8.6
|
||||
ALFRED_WEATHER_ILLUSTRATION_TARGET_SCORE=5.7
|
||||
# Dimensions de la vignette COMPOSEE, sur l'ecran d'Alfred
|
||||
ALFRED_WEATHER_DISPLAY_SIZE=350
|
||||
# Evaluation via le workflow ComfyUI vision_analyze.json (si compatible)
|
||||
@@ -15,7 +15,7 @@ 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.7
|
||||
ALFRED_WEATHER_REFERENCE_DENOISE=0.78
|
||||
# Maintient visuellement les visages de reference dans le rendu img2img
|
||||
ALFRED_WEATHER_PRESERVE_FACE=true
|
||||
# Nombre maximum d'accessoires visibles par personnage
|
||||
|
||||
Binary file not shown.
@@ -84,5 +84,15 @@
|
||||
"created_at": 1793205436.8959317,
|
||||
"state": "finished",
|
||||
"finished_at": 1793206457.1510372
|
||||
},
|
||||
{
|
||||
"id": "597165f549",
|
||||
"label": "Chronomètre général",
|
||||
"kind": "stopwatch",
|
||||
"duration": null,
|
||||
"elapsed": 30.873294591903687,
|
||||
"started_at": null,
|
||||
"created_at": 1793228714.9392383,
|
||||
"state": "cancelled"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restricted Alfred Developer MCP (stdio, pure stdlib JSON-RPC).
|
||||
|
||||
This MCP intentionally exposes NO deploy, rollback, shell, filesystem deletion,
|
||||
network fetch or arbitrary test execution operations.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
PRODUCTION=os.environ.get('ALFRED_DEV_PRODUCTION','/opt/alfred/app')
|
||||
sys.path.insert(0,PRODUCTION)
|
||||
|
||||
from services import alfred_developer_core as dev
|
||||
|
||||
|
||||
def tool(name, description, props=None, required=None):
|
||||
return {'name':name,'description':description,'inputSchema':{'type':'object','properties':props or {},'required':required or [],'additionalProperties':False}}
|
||||
|
||||
PID={'type':'string','pattern':'^[a-f0-9]{12}$','description':'ID renvoyé par create_task'}
|
||||
REL={'type':'string','description':'Chemin relatif (templates/, static/js/, static/css/, services/ ou app.py), jamais .env/config/secrets.'}
|
||||
TOOLS=[
|
||||
tool('create_task','Créer une copie Git des sources autorisées dans /opt/alfred-dev. Aucun fichier de production modifié.',{'request_text':{'type':'string','minLength':5,'maxLength':2000}},['request_text']),
|
||||
tool('list_source','Lister les fichiers de la copie de développement.',{'task_id':PID,'prefix':{'type':'string'}},['task_id']),
|
||||
tool('read_source','Lire un fichier source de la copie.',{'task_id':PID,'path':REL},['task_id','path']),
|
||||
tool('modify_source','Modifier ou créer UN fichier dans la copie Git ; aucun accès à la production.',{'task_id':PID,'path':REL,'content':{'type':'string','maxLength':180000}},['task_id','path','content']),
|
||||
tool('run_checks','Effectuer seulement des contrôles statiques (AST Python, node --check, HTML, CSS). Ne lance jamais le code modifié.',{'task_id':PID},['task_id']),
|
||||
tool('preview_changes','Afficher le diff et les contrôles préparés pour /maintenance.',{'task_id':PID},['task_id']),
|
||||
tool('submit_proposal','Geler une proposition testée et l’envoyer dans /maintenance pour approbation humaine.',{'task_id':PID},['task_id']),
|
||||
tool('list_tasks','Lister les propositions de développement. Pas de déploiement depuis le MCP.'),
|
||||
]
|
||||
|
||||
|
||||
def dispatch(name,args):
|
||||
assert isinstance(args,dict)
|
||||
if name=='create_task':return dev.create_task(args['request_text'])
|
||||
if name=='list_source':return {'files':dev.list_source(args['task_id'],args.get('prefix',''))}
|
||||
if name=='read_source':return dev.read_source(args['task_id'],args['path'])
|
||||
if name=='modify_source':return dev.change_source(args['task_id'],args['path'],args['content'])
|
||||
if name=='run_checks':return dev.run_checks(args['task_id'])
|
||||
if name=='preview_changes':return dev.task_detail(args['task_id'])
|
||||
if name=='submit_proposal':return dev.submit(args['task_id'])
|
||||
if name=='list_tasks':return {'tasks':dev.list_tasks()}
|
||||
raise ValueError('Outil inexistant.')
|
||||
|
||||
|
||||
def send(obj):
|
||||
sys.stdout.write(json.dumps(obj,ensure_ascii=False,separators=(',',':'))+'\n');sys.stdout.flush()
|
||||
|
||||
|
||||
def handle(message):
|
||||
if not isinstance(message,dict) or 'id' not in message:return
|
||||
mid=message['id'];method=message.get('method')
|
||||
if method=='initialize':
|
||||
params=message.get('params') or {}
|
||||
version=params.get('protocolVersion')
|
||||
if version not in ('2025-06-18','2025-03-26','2024-11-05'):version='2025-06-18'
|
||||
result={'protocolVersion':version,'capabilities':{'tools':{'listChanged':False}},
|
||||
'serverInfo':{'name':'alfred-developer','version':'1.0.0'},
|
||||
'instructions':'Modification UNIQUEMENT d’une copie Git. Après contrôles, soumettre à /maintenance. Jamais utiliser Terminal pour appliquer une modification en production.'}
|
||||
elif method=='tools/list':result={'tools':TOOLS}
|
||||
elif method=='ping':result={}
|
||||
elif method=='tools/call':
|
||||
try:
|
||||
params=message.get('params') or {}
|
||||
name=params.get('name');args=params.get('arguments') or {}
|
||||
schema=next((t['inputSchema'] for t in TOOLS if t['name']==name),None)
|
||||
if schema is None:raise ValueError('Outil inconnu.')
|
||||
if not isinstance(args,dict) or set(args)-set(schema['properties']):raise ValueError('Arguments non autorisés.')
|
||||
for key in schema['required']:
|
||||
if key not in args:raise ValueError('Argument requis : '+key)
|
||||
data=dispatch(name,args)
|
||||
result={'content':[{'type':'text','text':json.dumps(data,ensure_ascii=False)}],'isError':False}
|
||||
except Exception as exc:
|
||||
result={'content':[{'type':'text','text':str(exc)[:900]}],'isError':True}
|
||||
else:
|
||||
send({'jsonrpc':'2.0','id':mid,'error':{'code':-32601,'message':'Méthode inconnue'}});return
|
||||
send({'jsonrpc':'2.0','id':mid,'result':result})
|
||||
|
||||
if __name__=='__main__':
|
||||
for line in sys.stdin:
|
||||
if line.strip():
|
||||
try:handle(json.loads(line))
|
||||
except Exception as exc:print('MCP parsing :',str(exc)[:150],file=sys.stderr)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -87,6 +87,27 @@ DEFAULTS.update({
|
||||
from services.admin_extensions_v2 import extend_health as _admin_v2_extend_health
|
||||
|
||||
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: values saved with existing /config API.
|
||||
FIELDS.update({
|
||||
'DUPLEX_MODE': ('general', 'duplex_mode'),
|
||||
'DUPLEX_MIC_THRESHOLD': ('general', 'duplex_threshold'),
|
||||
})
|
||||
DEFAULTS.update({'DUPLEX_MODE': 'wake', 'DUPLEX_MIC_THRESHOLD': '0.025'})
|
||||
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2: configurable from the existing authenticated /config page.
|
||||
FIELDS.update({
|
||||
'DUPLEX_STOP_COMMANDS': ('general', 'boolean'),
|
||||
'DUPLEX_HANDOFF': ('general', 'boolean'),
|
||||
'DUPLEX_CONVERSATION': ('general', 'boolean'),
|
||||
'DUPLEX_CONVERSATION_SECONDS': ('general', 'duplex_seconds'),
|
||||
})
|
||||
DEFAULTS.update({
|
||||
'DUPLEX_STOP_COMMANDS': 'true',
|
||||
'DUPLEX_HANDOFF': 'true',
|
||||
'DUPLEX_CONVERSATION': 'true',
|
||||
'DUPLEX_CONVERSATION_SECONDS': '6',
|
||||
})
|
||||
|
||||
def _auth_config():
|
||||
try:
|
||||
return json.loads(AUTH_FILE.read_text(encoding='utf-8'))
|
||||
@@ -137,6 +158,30 @@ def _validate(key, value):
|
||||
if key not in FIELDS:
|
||||
raise ValueError(f'Paramètre non autorisé : {key}.')
|
||||
kind = FIELDS[key][1]
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2
|
||||
if kind == 'duplex_seconds':
|
||||
try:
|
||||
seconds = int(value)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
raise ValueError('Duree de conversation invalide.')
|
||||
if isinstance(value, bool) or not 2 <= seconds <= 15:
|
||||
raise ValueError('Duree de conversation : de 2 a 15 secondes.')
|
||||
return str(seconds)
|
||||
if kind == 'duplex_mode':
|
||||
if value not in ('off', 'wake', 'natural'):
|
||||
raise ValueError('Mode duplex invalide.')
|
||||
return value
|
||||
if kind == 'duplex_threshold':
|
||||
if isinstance(value, bool):
|
||||
raise ValueError('Seuil microphone invalide.')
|
||||
try:
|
||||
threshold = float(value)
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
raise ValueError('Seuil microphone invalide.')
|
||||
if not 0.008 <= threshold <= 0.30 or not __import__('math').isfinite(threshold):
|
||||
raise ValueError('Seuil microphone compris entre 0.008 et 0.30.')
|
||||
return f'{threshold:.3f}'
|
||||
if kind == 'boolean':
|
||||
if value not in (True, False, 'true', 'false'):
|
||||
raise ValueError(f'{key} doit être vrai ou faux.')
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Alfred Developer: safe source staging, review and controlled local deployment.
|
||||
|
||||
Only explicitly-allowed source extensions are staged. No generated code is
|
||||
executed by the MCP. Approving a proposal never deploys it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from datetime import datetime, timezone
|
||||
from html.parser import HTMLParser
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
ROOT = Path(os.environ.get('ALFRED_DEV_PRODUCTION', '/opt/alfred/app')).resolve()
|
||||
DATA = Path(os.environ.get('ALFRED_DEV_WORKSPACE', '/opt/alfred-dev')).resolve()
|
||||
BACKUPS = Path(os.environ.get('ALFRED_DEV_BACKUPS', '/opt/alfred-backups/developer')).resolve()
|
||||
LOCK = threading.RLock()
|
||||
MAX_FILES = 400
|
||||
MAX_FILE_BYTES = 300_000
|
||||
MAX_WRITE_BYTES = 180_000
|
||||
MAX_DIFF_CHARS = 160_000
|
||||
ALLOWED = {
|
||||
'templates': {'.html'},
|
||||
'static/css': {'.css'},
|
||||
'static/js': {'.js'},
|
||||
'services': {'.py'},
|
||||
}
|
||||
BLOCKED = {'services/admin_console.py','services/alfred_developer_core.py','services/alfred_developer_page.py'}
|
||||
ID_RE = re.compile(r'^[a-f0-9]{12}$')
|
||||
|
||||
|
||||
def _digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _utc():
|
||||
return datetime.now(timezone.utc).isoformat(timespec='seconds')
|
||||
|
||||
|
||||
def _validate_id(pid):
|
||||
if not isinstance(pid, str) or not ID_RE.fullmatch(pid):
|
||||
raise ValueError('Identifiant de proposition invalide.')
|
||||
return pid
|
||||
|
||||
|
||||
def _rel(path):
|
||||
if not isinstance(path, str) or len(path)>180 or '\\' in path or '\x00' in path:
|
||||
raise ValueError('Chemin de fichier invalide.')
|
||||
p = PurePosixPath(path)
|
||||
parts = p.parts
|
||||
if not parts or p.is_absolute() or any(x in ('..','.','') or x.startswith('.') for x in parts):
|
||||
raise ValueError('Chemin non autorisé.')
|
||||
rel = p.as_posix()
|
||||
if rel in BLOCKED:
|
||||
raise ValueError('Fichier protégé : '+rel)
|
||||
if p.name.endswith(('.min.js','.map','.pyc')):
|
||||
raise ValueError('Fichier généré non autorisé.')
|
||||
for base, suffixes in ALLOWED.items():
|
||||
if rel.startswith(base+'/') and len(parts)>=2 and p.suffix in suffixes:
|
||||
return rel
|
||||
if rel == 'app.py':
|
||||
return rel
|
||||
raise ValueError('Chemin ou extension interdits : '+rel)
|
||||
|
||||
|
||||
def _safe_file(base, rel):
|
||||
"""Reject symlinks at every level, including the workspace file."""
|
||||
here = base
|
||||
for part in PurePosixPath(rel).parts:
|
||||
here = here/part
|
||||
if here.is_symlink():
|
||||
raise ValueError('Lien symbolique interdit : '+rel)
|
||||
if here.exists() and not here.is_file():
|
||||
raise ValueError('Fichier non régulier : '+rel)
|
||||
return here
|
||||
|
||||
|
||||
def _workspace(pid):
|
||||
return DATA/_validate_id(pid)
|
||||
|
||||
|
||||
def _meta_path(pid):
|
||||
return _workspace(pid)/'proposal.json'
|
||||
|
||||
|
||||
def _load(pid):
|
||||
path = _meta_path(pid)
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise ValueError('Proposition introuvable.')
|
||||
return json.loads(path.read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
def _atomic(path, content, mode=0o600):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix='.alfdev-',dir=str(path.parent))
|
||||
try:
|
||||
with os.fdopen(fd,'wb') as f:
|
||||
f.write(content);f.flush();os.fsync(f.fileno())
|
||||
os.chmod(tmp, mode)
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):os.unlink(tmp)
|
||||
|
||||
|
||||
def _save(pid, meta):
|
||||
_atomic(_meta_path(pid),json.dumps(meta, ensure_ascii=False,indent=2).encode())
|
||||
|
||||
|
||||
def _git(wd, args, timeout=12):
|
||||
env={**os.environ,'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_OPTIONAL_LOCKS':'0', 'GIT_TERMINAL_PROMPT':'0'}
|
||||
cmd=['git','-c','core.hooksPath=/dev/null','-c','core.autocrlf=false','-c','user.name=Alfred Developer','-c','user.email=dev@localhost',*args]
|
||||
p=subprocess.run(cmd,cwd=wd,env=env,stdin=subprocess.DEVNULL,capture_output=True,text=True,timeout=timeout,check=False)
|
||||
if p.returncode:raise RuntimeError('Git : '+p.stderr.strip()[:500])
|
||||
return p.stdout
|
||||
|
||||
|
||||
def create_task(request_text):
|
||||
if not isinstance(request_text,str) or not 5<=len(request_text.strip())<=2000:
|
||||
raise ValueError('Décris la modification (5 à 2000 caractères).')
|
||||
with LOCK:
|
||||
pid=uuid.uuid4().hex[:12]
|
||||
wd=_workspace(pid)
|
||||
wd.mkdir(parents=True,mode=0o700,exist_ok=False)
|
||||
source=wd/'source';source.mkdir(mode=0o700)
|
||||
base={}
|
||||
try:
|
||||
paths=[]
|
||||
for basepath, endings in ALLOWED.items():
|
||||
d=ROOT/basepath
|
||||
if not d.is_dir() or d.is_symlink():continue
|
||||
for p in d.rglob('*'):
|
||||
if p.is_symlink() or not p.is_file():continue
|
||||
rel=p.relative_to(ROOT).as_posix()
|
||||
try:_rel(rel)
|
||||
except ValueError:continue
|
||||
if p.stat().st_size>MAX_FILE_BYTES:continue
|
||||
paths.append(rel)
|
||||
if (ROOT/'app.py').is_file() and not (ROOT/'app.py').is_symlink():
|
||||
paths.append('app.py')
|
||||
if len(paths)>MAX_FILES:raise ValueError('Trop de fichiers pour cette V1.')
|
||||
for rel in sorted(set(paths)):
|
||||
p=_safe_file(ROOT,rel)
|
||||
content=p.read_bytes()
|
||||
if len(content)>MAX_FILE_BYTES:continue
|
||||
target=_safe_file(source,rel)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
_atomic(target,content,0o600)
|
||||
base[rel]={'sha256':_digest(content),'mode':p.stat().st_mode & 0o777}
|
||||
if not base:raise ValueError('Aucun fichier source à préparer.')
|
||||
_git(source,['init','-q'])
|
||||
_git(source,['add','--','.'])
|
||||
_git(source,['commit','-q','-m','Base de travail (copie Alfred)'])
|
||||
meta={'id':pid,'title':request_text.strip(),'created_at':_utc(), 'status':'editing',
|
||||
'base':base,'approved_hash':None,'checks':None,'changed':[], 'events':[{'time':_utc(),'event':'Copie Git préparée'}]}
|
||||
_save(pid,meta)
|
||||
return {'id':pid,'status':'editing','files':len(base),'workspace':str(source),'note':'Copie de sources uniquement. Aucun secret ni donnée runtime copié volontairement.'}
|
||||
except Exception:
|
||||
shutil.rmtree(wd,ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
def list_source(pid, prefix=''):
|
||||
_load(pid)
|
||||
p=str(prefix or '')
|
||||
return [x for x in sorted(_load(pid)['base']) if x.startswith(p)][:250]
|
||||
|
||||
|
||||
def read_source(pid, rel):
|
||||
meta=_load(pid); rel=_rel(rel)
|
||||
if rel not in meta['base']:raise ValueError('Ce fichier n’est pas présent dans la copie.')
|
||||
path=_safe_file(_workspace(pid)/'source',rel)
|
||||
if not path.is_file():raise ValueError('Fichier absent.')
|
||||
return {'path':rel,'text':path.read_text(encoding='utf-8'),'sha256':_digest(path.read_bytes())}
|
||||
|
||||
|
||||
def change_source(pid, rel, content):
|
||||
if not isinstance(content,str) or len(content.encode('utf-8'))>MAX_WRITE_BYTES or '\x00' in content:
|
||||
raise ValueError('Contenu invalide ou supérieur à 180 Ko.')
|
||||
rel=_rel(rel)
|
||||
with LOCK:
|
||||
meta=_load(pid)
|
||||
if meta['status'] not in ('editing','tested'):raise ValueError('Proposition verrouillée ; créer une nouvelle tâche.')
|
||||
if rel not in meta['base']:
|
||||
# New files allowed under the same strict allowlist, not replacements of secrets.
|
||||
if len(meta['base'])>=MAX_FILES:raise ValueError('Limite de fichiers atteinte.')
|
||||
if _safe_file(ROOT,rel).exists():raise ValueError('Fichier présent en production : création refusée.')
|
||||
meta['base'][rel]={'sha256':None,'mode':0o644}
|
||||
target=_safe_file(_workspace(pid)/'source',rel)
|
||||
_atomic(target,content.encode('utf-8'))
|
||||
meta['status']='editing'
|
||||
meta['checks']=None
|
||||
meta['changed']=[]
|
||||
meta['diff_sha256']=None
|
||||
_save(pid,meta)
|
||||
return {'ok':True,'path':rel,'bytes':len(content.encode('utf-8')),'note':'Modification dans la copie uniquement.'}
|
||||
|
||||
|
||||
def _diff(pid):
|
||||
meta=_load(pid); wd=_workspace(pid)/'source'
|
||||
diffs=[];changed=[]; all_ok=True
|
||||
for rel in sorted(meta['base']):
|
||||
orig=meta['base'][rel]['sha256']
|
||||
p=_safe_file(wd,rel)
|
||||
if not p.exists():
|
||||
raise ValueError('Suppression de fichier non prise en charge : '+rel)
|
||||
data=p.read_bytes()
|
||||
if len(data)>MAX_WRITE_BYTES and _digest(data)!=orig:
|
||||
raise ValueError('Fichier modifié trop volumineux : '+rel)
|
||||
if _digest(data)==orig:continue
|
||||
changed.append(rel)
|
||||
# Git diff --no-index avoids untracked file omission. Python stdlib difflib
|
||||
import difflib
|
||||
old=(_git(wd,['show','HEAD:'+rel]) if orig is not None else '')
|
||||
new=data.decode('utf-8')
|
||||
diffs.extend(difflib.unified_diff(old.splitlines(True),new.splitlines(True),fromfile='avant/'+rel,tofile='apres/'+rel,n=3))
|
||||
diff=''.join(diffs)
|
||||
if len(diff)>MAX_DIFF_CHARS:raise ValueError('Diff trop grand pour revue Web.')
|
||||
return changed,diff
|
||||
|
||||
|
||||
class _HTMLCheck(HTMLParser):
|
||||
pass
|
||||
|
||||
|
||||
def run_checks(pid):
|
||||
with LOCK:
|
||||
meta=_load(pid)
|
||||
if meta['status'] not in ('editing','tested'):raise ValueError('Proposition verrouillée.')
|
||||
changed,diff=_diff(pid)
|
||||
if not changed:raise ValueError('Aucune modification à tester.')
|
||||
results=[]
|
||||
for rel in changed:
|
||||
path=_safe_file(_workspace(pid)/'source',rel)
|
||||
content=path.read_text(encoding='utf-8')
|
||||
try:
|
||||
if rel.endswith('.py'):
|
||||
ast.parse(content, filename=rel)
|
||||
verdict='Syntaxe Python : OK (aucune exécution)'
|
||||
elif rel.endswith('.js'):
|
||||
if not shutil.which('node'):
|
||||
raise ValueError('Node.js absent : vérification JS obligatoire avant approbation.')
|
||||
process=subprocess.run(['node','--check','-'],input=content,text=True,capture_output=True,timeout=6)
|
||||
if process.returncode:raise ValueError(process.stderr[:600])
|
||||
verdict='Syntaxe JavaScript : OK (aucune exécution)'
|
||||
elif rel.endswith('.html'):
|
||||
parser=_HTMLCheck();parser.feed(content);parser.close()
|
||||
verdict='Analyse HTML : OK (structure complète non garantie)'
|
||||
elif rel.endswith('.css'):
|
||||
if content.count('{')!=content.count('}'):
|
||||
raise ValueError('Nombre d’accolades CSS incohérent.')
|
||||
verdict='Contrôle CSS élémentaire : OK'
|
||||
else:raise ValueError('Type non testé.')
|
||||
results.append({'file':rel,'ok':True,'detail':verdict})
|
||||
except (SyntaxError,ValueError,subprocess.TimeoutExpired) as exc:
|
||||
results.append({'file':rel,'ok':False,'detail':str(exc)[:500]})
|
||||
meta['checks']=results
|
||||
meta['changed']=changed
|
||||
meta['diff_sha256']=_digest(diff.encode())
|
||||
meta['status']='tested' if all(x['ok'] for x in results) else 'editing'
|
||||
meta['events'].append({'time':_utc(),'event':'Contrôles effectués'})
|
||||
_save(pid,meta)
|
||||
return {'status':meta['status'],'passed':all(x['ok'] for x in results),'checks':results,'changed':changed, 'warning':'Tests statiques uniquement ; aucune exécution du nouveau code ni validation visuelle automatisée.'}
|
||||
|
||||
|
||||
def submit(pid):
|
||||
with LOCK:
|
||||
meta=_load(pid)
|
||||
if meta['status']!='tested':raise ValueError('Exécute et réussis les contrôles avant de soumettre.')
|
||||
changed,diff=_diff(pid)
|
||||
if not changed or changed!=meta['changed'] or _digest(diff.encode())!=meta.get('diff_sha256'):
|
||||
raise ValueError('Code modifié depuis les contrôles. Relancer les tests.')
|
||||
meta['status']='pending';meta['events'].append({'time':_utc(),'event':'En attente de validation'});_save(pid,meta)
|
||||
return {'id':pid,'status':'pending','changed':changed,'note':'Approbation nécessaire dans /maintenance.'}
|
||||
|
||||
|
||||
def list_tasks():
|
||||
if not DATA.exists():return []
|
||||
out=[]
|
||||
for wd in sorted(DATA.iterdir(),reverse=True):
|
||||
if not wd.is_dir() or wd.is_symlink() or not ID_RE.fullmatch(wd.name):continue
|
||||
try:
|
||||
m=_load(wd.name)
|
||||
out.append({'id':m['id'],'title':m['title'],'status':m['status'],'created_at':m['created_at'],'changed':m.get('changed',[])})
|
||||
except (ValueError,KeyError,OSError,json.JSONDecodeError):pass
|
||||
return out[:100]
|
||||
|
||||
|
||||
def task_detail(pid):
|
||||
meta=_load(pid)
|
||||
changed,diff=_diff(pid)
|
||||
return {'id':pid,'title':meta['title'],'status':meta['status'],'created_at':meta['created_at'],
|
||||
'changed':changed,'diff':diff,'checks':meta.get('checks'),'events':meta.get('events',[]),
|
||||
'approved_hash':meta.get('approved_hash'), 'diff_sha256':_digest(diff.encode('utf-8'))}
|
||||
|
||||
|
||||
def approve(pid, decision, expected_hash):
|
||||
if decision not in ('approve','reject'):raise ValueError('Décision inconnue.')
|
||||
with LOCK:
|
||||
meta=_load(pid)
|
||||
if meta['status']!='pending':raise ValueError('Proposition non soumise ou déjà décidée.')
|
||||
changed,diff=_diff(pid)
|
||||
real_hash=_digest(diff.encode())
|
||||
if not isinstance(expected_hash,str) or expected_hash!=real_hash or meta.get('diff_sha256')!=real_hash:
|
||||
raise ValueError('Le code a changé, recharger la proposition.')
|
||||
meta['status']='approved' if decision=='approve' else 'rejected'
|
||||
meta['approved_hash']=real_hash if decision=='approve' else None
|
||||
meta['events'].append({'time':_utc(),'event':'Validée par un administrateur' if decision=='approve' else 'Refusée'})
|
||||
_save(pid,meta)
|
||||
return {'id':pid,'status':meta['status'], 'note':'Approbation enregistrée. Déploiement CLI local uniquement.'}
|
||||
|
||||
|
||||
def deploy(pid, *, apply=False):
|
||||
"""Only called by local administrator CLI, never by MCP or Flask route."""
|
||||
with LOCK:
|
||||
meta=_load(pid)
|
||||
if meta['status']!='approved':raise ValueError('Validation Web préalable requise.')
|
||||
changed,diff=_diff(pid)
|
||||
if not changed or meta.get('approved_hash')!=_digest(diff.encode()):
|
||||
raise ValueError('Le correctif ne correspond plus à la version approuvée.')
|
||||
for rel in changed:
|
||||
original=_safe_file(ROOT,rel)
|
||||
current=original.read_bytes() if original.exists() else None
|
||||
current_hash=_digest(current) if current is not None else None
|
||||
if current_hash!=meta['base'][rel]['sha256']:
|
||||
raise ValueError('Production modifiée depuis la copie : '+rel)
|
||||
staged=_safe_file(_workspace(pid)/'source',rel)
|
||||
if not staged.is_file():raise ValueError('Fichier de stage absent : '+rel)
|
||||
if not apply:
|
||||
return {'ok':True,'dry_run':True,'changed':changed,'requires_restart':any(x.endswith('.py') for x in changed)}
|
||||
BACKUPS.mkdir(parents=True,exist_ok=True)
|
||||
bkp=BACKUPS/(pid+'-'+datetime.now().strftime('%Y%m%d_%H%M%S'))
|
||||
bkp.mkdir(mode=0o700)
|
||||
manifest={'id':pid,'files':{},'created_at':_utc()}
|
||||
for rel in changed:
|
||||
original=_safe_file(ROOT,rel)
|
||||
current=original.read_bytes() if original.exists() else None
|
||||
mode=original.stat().st_mode & 0o777 if current is not None else 0o644
|
||||
manifest['files'][rel]={'existed':current is not None,'mode':mode,'before_sha256':_digest(current) if current is not None else None,
|
||||
'after_sha256':_digest(_safe_file(_workspace(pid)/'source',rel).read_bytes())}
|
||||
if current is not None:
|
||||
dest=bkp/'original'/rel
|
||||
_atomic(dest,current,0o600)
|
||||
_atomic(bkp/'manifest.json',json.dumps(manifest,ensure_ascii=False,indent=2).encode())
|
||||
done=[]
|
||||
try:
|
||||
for rel in changed:
|
||||
path=_safe_file(ROOT,rel)
|
||||
code=_safe_file(_workspace(pid)/'source',rel).read_bytes()
|
||||
_atomic(path,code,manifest['files'][rel]['mode']);done.append(rel)
|
||||
except BaseException:
|
||||
for rel in reversed(done):
|
||||
i=manifest['files'][rel];path=_safe_file(ROOT,rel)
|
||||
if i['existed']:_atomic(path,(bkp/'original'/rel).read_bytes(),i['mode'])
|
||||
elif path.exists():path.unlink()
|
||||
raise
|
||||
meta['status']='deployed'
|
||||
meta['backup']=str(bkp)
|
||||
meta['events'].append({'time':_utc(),'event':'Déployé depuis CLI administrateur'})
|
||||
_save(pid,meta)
|
||||
return {'ok':True,'changed':changed,'backup':str(bkp),'requires_restart':any(x.endswith('.py') for x in changed)}
|
||||
|
||||
|
||||
def rollback(pid, *, apply=False):
|
||||
with LOCK:
|
||||
meta=_load(pid)
|
||||
if meta['status']!='deployed':raise ValueError('Aucune version déployée à restaurer.')
|
||||
bkp=Path(meta.get('backup',''))
|
||||
if not bkp.is_dir() or bkp.is_symlink() or bkp.parent!=BACKUPS:
|
||||
raise ValueError('Sauvegarde introuvable ou invalide.')
|
||||
manifest=json.loads((bkp/'manifest.json').read_text())
|
||||
for rel, info in manifest['files'].items():
|
||||
rel=_rel(rel); current=_safe_file(ROOT,rel)
|
||||
data=current.read_bytes() if current.exists() else None
|
||||
if (_digest(data) if data is not None else None)!=info['after_sha256']:
|
||||
raise ValueError('Fichier modifié depuis le déploiement : '+rel)
|
||||
if not apply:return {'ok':True,'dry_run':True,'files':list(manifest['files'])}
|
||||
for rel,info in manifest['files'].items():
|
||||
path=_safe_file(ROOT,rel)
|
||||
if info['existed']:_atomic(path,(bkp/'original'/rel).read_bytes(),info['mode'])
|
||||
elif path.exists():path.unlink()
|
||||
meta['status']='rolled_back';meta['events'].append({'time':_utc(),'event':'Retour arrière depuis CLI'})
|
||||
_save(pid,meta)
|
||||
return {'ok':True,'files':list(manifest['files'])}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Admin-only review UI for staged Alfred Developer proposals.
|
||||
|
||||
No web endpoint can deploy, restart, execute code or run shell commands.
|
||||
"""
|
||||
from flask import Blueprint, jsonify, render_template, request
|
||||
from services import admin_console
|
||||
from services import alfred_developer_core as dev
|
||||
|
||||
|
||||
def register_developer_page(app):
|
||||
bp=Blueprint('alfred_developer_ui',__name__)
|
||||
|
||||
@bp.get('/maintenance')
|
||||
def maintenance_page():
|
||||
return render_template('alfred_maintenance.html')
|
||||
|
||||
@bp.get('/api/control/developer/tasks')
|
||||
@admin_console._require_admin
|
||||
def tasks():
|
||||
return jsonify(ok=True, tasks=dev.list_tasks())
|
||||
|
||||
@bp.get('/api/control/developer/tasks/<pid>')
|
||||
@admin_console._require_admin
|
||||
def detail(pid):
|
||||
try:return jsonify(ok=True, task=dev.task_detail(pid))
|
||||
except (ValueError,OSError,RuntimeError) as e:return jsonify(error=str(e)),400
|
||||
|
||||
@bp.post('/api/control/developer/tasks/<pid>/decision')
|
||||
@admin_console._require_admin
|
||||
def decide(pid):
|
||||
data=request.get_json(silent=True) or {}
|
||||
try:
|
||||
return jsonify(ok=True, result=dev.approve(pid,data.get('decision'),data.get('diff_sha256')))
|
||||
except (ValueError,OSError,RuntimeError) as e:
|
||||
return jsonify(error=str(e)),400
|
||||
|
||||
app.register_blueprint(bp)
|
||||
@@ -0,0 +1,365 @@
|
||||
# ALFRED_DUPLEX_CONVERSATION_V2
|
||||
"""Dona duplex V2: short stop commands, interrupted follow-ups and conversation window.
|
||||
|
||||
No acoustic echo cancellation is provided. A shared, echo-cancelled input device is
|
||||
recommended. This code only stops Piper audio, never a Hermes task.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from collections import deque
|
||||
|
||||
|
||||
def normalize(text):
|
||||
text = unicodedata.normalize('NFKD', str(text or '').casefold())
|
||||
return ' '.join(re.sub(r'[^a-z0-9 ]+', ' ', ''.join(c for c in text if not unicodedata.combining(c))).split())
|
||||
|
||||
|
||||
STOP_PHRASES = ('stop', 'stoppe', 'arrete', 'arretes', 'silence', 'tais toi', 'taisez vous', 'chut')
|
||||
LEAD_WORDS = ('attends', 'attendez', 'une seconde', 'minute')
|
||||
|
||||
|
||||
def without_wake(text, wake):
|
||||
text = normalize(text)
|
||||
wake = normalize(wake)
|
||||
if wake and (text == wake or text.startswith(wake + ' ')):
|
||||
text = text[len(wake):].strip()
|
||||
return text
|
||||
|
||||
|
||||
def stop_only(text, wake=''):
|
||||
cleaned = without_wake(text, wake)
|
||||
return cleaned in STOP_PHRASES or cleaned in LEAD_WORDS
|
||||
|
||||
|
||||
def clean_question(text, wake):
|
||||
# Keep original spelling and technical names (Wazuh, Proxmox, LXC).
|
||||
text = str(text or '').strip(' ,.!?\t\n')
|
||||
if wake:
|
||||
prefix = re.match(r'^' + re.escape(wake) + r'\b[\s,.:;!?-]*', text, re.I)
|
||||
if prefix:
|
||||
text = text[prefix.end():].strip()
|
||||
for lead in STOP_PHRASES + LEAD_WORDS:
|
||||
prefix = re.match(r'^' + re.escape(lead) + r'\b[\s,.:;!?-]+', text, re.I)
|
||||
if prefix:
|
||||
text = text[prefix.end():].strip()
|
||||
break
|
||||
return text if len(text) >= 3 and not stop_only(text, wake) else ''
|
||||
|
||||
|
||||
def looks_like_echo(candidate, spoken):
|
||||
candidate = normalize(candidate)
|
||||
spoken = normalize(spoken)
|
||||
if not candidate or not spoken:
|
||||
return False
|
||||
if candidate in spoken:
|
||||
return True
|
||||
cw, sw = candidate.split(), set(spoken.split())
|
||||
return len(cw) >= 3 and sum(w in sw for w in cw) / len(cw) >= 0.85
|
||||
|
||||
|
||||
def _bool(name, default):
|
||||
return os.getenv(name, str(default)).strip().lower() in ('1', 'yes', 'true', 'on', 'oui')
|
||||
|
||||
|
||||
def config():
|
||||
mode = os.getenv('DUPLEX_MODE', 'wake').strip().lower()
|
||||
if mode not in {'off', 'wake', 'natural'}:
|
||||
mode = 'off'
|
||||
try:
|
||||
threshold = float(os.getenv('DUPLEX_MIC_THRESHOLD', '0.025'))
|
||||
except (TypeError, ValueError):
|
||||
threshold = 0.025
|
||||
try:
|
||||
seconds = int(os.getenv('DUPLEX_CONVERSATION_SECONDS', '6'))
|
||||
except (TypeError, ValueError):
|
||||
seconds = 6
|
||||
return {'mode': mode, 'threshold': min(0.30, max(0.008, threshold)),
|
||||
'wake_word': os.getenv('WAKE_WORD', 'alfred').strip().lower(),
|
||||
'stop_commands': _bool('DUPLEX_STOP_COMMANDS', True),
|
||||
'handoff': _bool('DUPLEX_HANDOFF', True),
|
||||
'conversation': _bool('DUPLEX_CONVERSATION', True),
|
||||
'conversation_seconds': min(15, max(2, seconds)),
|
||||
'aec': 'not_provided',
|
||||
'limitations': 'Pas d AEC: risque de faux positifs par le haut-parleur. A utiliser avec une source audio AEC pour le mode naturel.'}
|
||||
|
||||
|
||||
def _rms(block):
|
||||
import numpy as np
|
||||
samples = np.frombuffer(block, dtype=np.int16).astype(np.float32)
|
||||
return float(np.sqrt(np.mean(samples * samples)) / 32768.0) if samples.size else 0.0
|
||||
|
||||
|
||||
def _stream(rate, device):
|
||||
import sounddevice as sd
|
||||
frames = max(320, rate // 10)
|
||||
q = queue.Queue(maxsize=64)
|
||||
def cb(indata, _frames, _time_info, _status):
|
||||
try:
|
||||
q.put_nowait(bytes(indata))
|
||||
except queue.Full:
|
||||
pass
|
||||
return sd.RawInputStream(samplerate=rate, blocksize=frames,
|
||||
dtype='int16', channels=1, device=device, callback=cb), q
|
||||
|
||||
|
||||
def _vosk_recognizer(rate, *, wake_only=False, wake_word=''):
|
||||
from vosk import KaldiRecognizer
|
||||
from services.audio import get_vosk_model
|
||||
model_path = os.getenv('WAKE_VOSK_MODEL_PATH') if wake_only else os.getenv('VOSK_MODEL_PATH')
|
||||
if not model_path or not os.path.isfile(model_path):
|
||||
raise RuntimeError('Modele Vosk requis introuvable : ' + str(model_path))
|
||||
if wake_only:
|
||||
words = [wake_word, *STOP_PHRASES, *LEAD_WORDS, '[unk]']
|
||||
return KaldiRecognizer(get_vosk_model(model_path), rate,
|
||||
json.dumps(list(dict.fromkeys(words)), ensure_ascii=False))
|
||||
return KaldiRecognizer(get_vosk_model(model_path), rate)
|
||||
|
||||
|
||||
def transcribe_capture(pcm, rate, *, fallback=''):
|
||||
"""Uses Whisper for final text when enabled, and local Vosk otherwise."""
|
||||
if not pcm:
|
||||
return normalize(fallback)
|
||||
if os.getenv('STT_ENGINE', 'vosk').strip().lower() in ('whisper', 'wyoming', 'wyoming_whisper'):
|
||||
try:
|
||||
from services.wyoming_stt import transcribe_pcm
|
||||
result = transcribe_pcm(pcm,
|
||||
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=rate, connect_timeout=3.0,
|
||||
response_timeout=float(os.getenv('WYOMING_WHISPER_TIMEOUT', '20')))
|
||||
if result:
|
||||
return result.strip()
|
||||
except Exception as exc:
|
||||
print('[ALFRED DUPLEX V2] Whisper en secours Vosk :', exc, flush=True)
|
||||
try:
|
||||
rec = _vosk_recognizer(rate)
|
||||
rec.AcceptWaveform(pcm)
|
||||
result = json.loads(rec.FinalResult() or '{}').get('text') or ''
|
||||
return result.strip() or str(fallback).strip()
|
||||
except Exception as exc:
|
||||
print('[ALFRED DUPLEX V2] Transcription Vosk indisponible :', exc, flush=True)
|
||||
return str(fallback).strip()
|
||||
|
||||
|
||||
class DuplexMonitor:
|
||||
def __init__(self, *, muted, on_interrupt, on_status=None):
|
||||
self.muted = muted
|
||||
self.on_interrupt = on_interrupt
|
||||
self.on_status = on_status or (lambda data: None)
|
||||
self._stop = threading.Event()
|
||||
self._done = threading.Event()
|
||||
self._thread = None
|
||||
self._lock = threading.RLock()
|
||||
self._interrupted = False
|
||||
self._last_error = None
|
||||
self._followup = ''
|
||||
self._reason = None
|
||||
self._spoken = ''
|
||||
|
||||
def start(self, spoken_text):
|
||||
cfg = config()
|
||||
if cfg['mode'] == 'off' or self.muted():
|
||||
return False
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return False
|
||||
self._stop.clear()
|
||||
self._done.clear()
|
||||
self._interrupted = False
|
||||
self._followup = ''
|
||||
self._reason = None
|
||||
self._last_error = None
|
||||
self._spoken = spoken_text
|
||||
self._thread = threading.Thread(target=self._listen,
|
||||
args=(spoken_text, cfg), daemon=True, name='dona-duplex-v2')
|
||||
self._thread.start()
|
||||
return True
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
thread = self._thread
|
||||
if thread and thread is not threading.current_thread():
|
||||
thread.join(timeout=1.3)
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
return {'active': bool(self._thread and self._thread.is_alive() and not self._stop.is_set()),
|
||||
'interrupted': self._interrupted, 'reason': self._reason,
|
||||
'error': self._last_error, **config()}
|
||||
|
||||
def interrupted(self):
|
||||
with self._lock:
|
||||
return self._interrupted
|
||||
|
||||
def wait_for_followup(self, timeout=9.0):
|
||||
self._done.wait(timeout=timeout)
|
||||
with self._lock:
|
||||
return self._followup
|
||||
|
||||
def _signal(self, data):
|
||||
try:
|
||||
self.on_status(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _fire(self, reason):
|
||||
with self._lock:
|
||||
if self._interrupted or self._stop.is_set() or self.muted():
|
||||
return False
|
||||
self._interrupted = True
|
||||
self._reason = reason
|
||||
print('[ALFRED DUPLEX V2] Interruption :', reason, flush=True)
|
||||
try:
|
||||
self.on_interrupt()
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
self._signal({'mode': reason, 'interrupted': True})
|
||||
return True
|
||||
|
||||
def _read_after_interruption(self, q, rate, cfg, seed, hint):
|
||||
"""Same microphone stream: don't lose a follow-up immediately after the wake word."""
|
||||
if not cfg['handoff']:
|
||||
return ''
|
||||
# Capture up to 8 seconds, including a short preroll from before interruption.
|
||||
data = bytearray(seed)
|
||||
start = time.monotonic()
|
||||
last_voice = start
|
||||
voiced = bool(clean_question(hint, cfg['wake_word']))
|
||||
while time.monotonic() - start < 8 and not self._stop.is_set() and not self.muted():
|
||||
try:
|
||||
block = q.get(timeout=0.12)
|
||||
except queue.Empty:
|
||||
if voiced and time.monotonic() - last_voice > 1.1:
|
||||
break
|
||||
continue
|
||||
data.extend(block)
|
||||
if len(data) > rate * 2 * 12:
|
||||
break
|
||||
if _rms(block) >= cfg['threshold']:
|
||||
voiced = True
|
||||
last_voice = time.monotonic()
|
||||
if voiced and time.monotonic() - last_voice >= 1.05:
|
||||
break
|
||||
if not voiced and time.monotonic() - start > 2.3:
|
||||
return ''
|
||||
text = clean_question(transcribe_capture(bytes(data), rate, fallback=hint), cfg['wake_word'])
|
||||
if not text or looks_like_echo(text, self._spoken) or stop_only(text, cfg['wake_word']):
|
||||
return ''
|
||||
return text
|
||||
|
||||
def _listen(self, spoken, cfg):
|
||||
try:
|
||||
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
|
||||
wake_only = cfg['mode'] == 'wake'
|
||||
rec = _vosk_recognizer(rate, wake_only=wake_only, wake_word=cfg['wake_word'])
|
||||
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
|
||||
stream, q = _stream(rate, device)
|
||||
preroll = deque(maxlen=18)
|
||||
stable = ''
|
||||
stable_at = 0.0
|
||||
started = time.monotonic()
|
||||
with stream:
|
||||
while not self._stop.is_set() and not self.muted():
|
||||
try:
|
||||
block = q.get(timeout=0.12)
|
||||
except queue.Empty:
|
||||
continue
|
||||
preroll.append(block)
|
||||
if time.monotonic() - started < 0.65 or _rms(block) < cfg['threshold']:
|
||||
continue
|
||||
final = rec.AcceptWaveform(block)
|
||||
payload = json.loads(rec.Result() if final else rec.PartialResult())
|
||||
heard = normalize(payload.get('text' if final else 'partial') or '')
|
||||
if not heard or looks_like_echo(heard, spoken):
|
||||
stable = ''
|
||||
continue
|
||||
stop = cfg['stop_commands'] and stop_only(heard, cfg['wake_word'])
|
||||
woke = cfg['wake_word'] in heard.split()
|
||||
# Natural mode: a distinct 2+-word utterance, confirmed across
|
||||
# multiple chunks. The speaker itself may still cause false positives.
|
||||
natural = (cfg['mode'] == 'natural' and len(heard.split()) >= 2)
|
||||
candidate = stop or woke or natural
|
||||
if not candidate:
|
||||
stable = ''
|
||||
continue
|
||||
now = time.monotonic()
|
||||
if heard != stable:
|
||||
stable, stable_at = heard, now
|
||||
continue
|
||||
if not final and now - stable_at < (0.20 if stop else 0.43):
|
||||
continue
|
||||
if not self._fire('stop' if stop else 'wake' if woke else 'natural'):
|
||||
continue
|
||||
self._signal({'mode': 'handoff', 'listening': True})
|
||||
result = self._read_after_interruption(q, rate, cfg,
|
||||
b''.join(preroll), heard)
|
||||
with self._lock:
|
||||
self._followup = result
|
||||
if result:
|
||||
print('[ALFRED DUPLEX V2] Nouvelle question :', repr(result), flush=True)
|
||||
return
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
print('[ALFRED DUPLEX V2] Capture indisponible :', exc, flush=True)
|
||||
finally:
|
||||
self._done.set()
|
||||
|
||||
def listen_conversation(self):
|
||||
"""Short wake-free listening period AFTER Piper has finished speaking."""
|
||||
cfg = config()
|
||||
if not cfg['conversation'] or cfg['mode'] == 'off' or self.muted():
|
||||
return ''
|
||||
self.stop()
|
||||
rate = int(os.getenv('VOSK_SAMPLE_RATE', '16000'))
|
||||
device = os.getenv('DUPLEX_INPUT_DEVICE') or os.getenv('AUDIO_INPUT_DEVICE') or None
|
||||
try:
|
||||
stream, q = _stream(rate, device)
|
||||
seconds = cfg['conversation_seconds']
|
||||
self._signal({'mode': 'conversation', 'listening': True, 'timeout': seconds})
|
||||
pcm = bytearray()
|
||||
started = time.monotonic()
|
||||
last_voice = None
|
||||
voice_started = False
|
||||
consecutive_voice = 0
|
||||
with stream:
|
||||
while time.monotonic() - started < seconds and not self.muted():
|
||||
try:
|
||||
block = q.get(timeout=0.12)
|
||||
except queue.Empty:
|
||||
if last_voice and time.monotonic() - last_voice > 1.15:
|
||||
break
|
||||
continue
|
||||
# Ignore the output audio tail immediately after Piper.
|
||||
if time.monotonic() - started < 0.4:
|
||||
continue
|
||||
pcm.extend(block)
|
||||
if len(pcm) > rate * 2 * 12:
|
||||
break
|
||||
if _rms(block) >= cfg['threshold']:
|
||||
consecutive_voice += 1
|
||||
if consecutive_voice >= 3: # reject isolated clicks (< ~0.3 s)
|
||||
voice_started = True
|
||||
last_voice = time.monotonic()
|
||||
else:
|
||||
consecutive_voice = 0
|
||||
if last_voice and time.monotonic() - last_voice > 1.15:
|
||||
break
|
||||
if not voice_started:
|
||||
return ''
|
||||
transcript = transcribe_capture(bytes(pcm), rate)
|
||||
if stop_only(transcript, cfg['wake_word']) or looks_like_echo(transcript, self._spoken):
|
||||
return ''
|
||||
return clean_question(transcript, cfg['wake_word'])
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
print('[ALFRED DUPLEX V2] Conversation indisponible :', exc, flush=True)
|
||||
return ''
|
||||
finally:
|
||||
self._signal({'mode': 'conversation', 'listening': False})
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Reset only the Hermes conversation ID used by Alfred.
|
||||
|
||||
Admin-protected, CSRF-protected, and concurrency-safe with ask_hermes().
|
||||
Does NOT delete the actual Hermes conversation history or change MCP/skills.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
from services import admin_console, hermes_client
|
||||
|
||||
|
||||
class HermesSessionBusy(RuntimeError):
|
||||
"""Hermes is currently processing a message."""
|
||||
|
||||
|
||||
def archive_current_session() -> bool:
|
||||
"""Return True when an existing session was archived.
|
||||
|
||||
The exact same thread lock and filesystem lock are used by ask_hermes.
|
||||
Both locks are non-blocking here to keep HTTP responsive.
|
||||
"""
|
||||
if os.getenv('HERMES_SESSION_MODE', 'persistent').strip().lower() == 'legacy':
|
||||
raise ValueError('Hermes est en mode legacy : les conversations sont déjà indépendantes.')
|
||||
|
||||
request_lock = hermes_client._REQUEST_LOCK
|
||||
if not request_lock.acquire(blocking=False):
|
||||
raise HermesSessionBusy('Hermes traite une demande. Réessaie lorsqu’il a terminé.')
|
||||
try:
|
||||
path = hermes_client._session_file()
|
||||
if path.is_symlink():
|
||||
raise ValueError('Fichier de session symbolique non autorisé.')
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = path.with_name(path.name + '.lock')
|
||||
flags = os.O_CREAT | os.O_RDWR
|
||||
if hasattr(os, 'O_NOFOLLOW'):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(lock_path, flags, 0o600)
|
||||
with os.fdopen(fd, 'r+') as lock_file:
|
||||
try:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
raise HermesSessionBusy('Une conversation Hermes est en cours. Réessaie plus tard.') from exc
|
||||
try:
|
||||
if not path.exists():
|
||||
return False
|
||||
if not path.is_file():
|
||||
raise ValueError('État de session Hermes inattendu.')
|
||||
# Archive the file even if its JSON is invalid: this action is also a recovery path.
|
||||
archive_dir = path.parent / 'hermes_session_archives'
|
||||
if archive_dir.is_symlink():
|
||||
raise ValueError('Répertoire d’archives symbolique non autorisé.')
|
||||
archive_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
stem = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||
name = f'{path.stem}-{stem}-{secrets.token_hex(4)}.json'
|
||||
os.replace(path, archive_dir / name)
|
||||
print('[ALFRED] Nouvelle session Hermes demandée depuis l’interface administrateur.', flush=True)
|
||||
return True
|
||||
finally:
|
||||
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
request_lock.release()
|
||||
|
||||
|
||||
def register_hermes_session_page(app):
|
||||
bp = Blueprint('alfred_hermes_session_button_v1', __name__)
|
||||
|
||||
@bp.post('/api/control/hermes/new-session')
|
||||
@admin_console._require_admin
|
||||
def new_session():
|
||||
try:
|
||||
archived = archive_current_session()
|
||||
except HermesSessionBusy as exc:
|
||||
return jsonify(ok=False, error=str(exc)), 409
|
||||
except (OSError, ValueError, RuntimeError) as exc:
|
||||
app.logger.warning('Nouvelle session Hermes impossible : %s', exc)
|
||||
return jsonify(ok=False, error='Impossible de réinitialiser la session : '+str(exc)), 503
|
||||
response = jsonify(ok=True, archived=archived,
|
||||
message=('Session précédente sauvegardée. La prochaine demande ouvrira une nouvelle session Hermes.'
|
||||
if archived else 'Aucune session précédente : Hermes en créera une à la prochaine demande.'))
|
||||
response.headers['Cache-Control'] = 'no-store, private'
|
||||
return response
|
||||
|
||||
app.register_blueprint(bp)
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import threading # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
import wave
|
||||
|
||||
from wyoming.audio import AudioChunk, AudioStart, AudioStop
|
||||
@@ -71,10 +72,31 @@ async def synthesize(text, wav_path):
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: control only this module's aplay process.
|
||||
_playback_lock = threading.RLock()
|
||||
_playback_cancel = threading.Event()
|
||||
_playback_proc = None
|
||||
|
||||
|
||||
def stop_playback():
|
||||
"""Interrupts Piper playback or marks pending synth for cancellation."""
|
||||
global _playback_proc
|
||||
_playback_cancel.set()
|
||||
with _playback_lock:
|
||||
process = _playback_proc
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
process.terminate()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def speak(text):
|
||||
if not text or not text.strip():
|
||||
return
|
||||
|
||||
_playback_cancel.clear() # ALFRED_DUPLEX_BARGE_IN_V1
|
||||
|
||||
fd, wav_path = tempfile.mkstemp(
|
||||
prefix="alfred_piper_",
|
||||
suffix=".wav"
|
||||
@@ -90,11 +112,32 @@ def speak(text):
|
||||
|
||||
print("[ALFRED PIPER] Lecture...", flush=True)
|
||||
|
||||
subprocess.run(
|
||||
["aplay", "-q", "-D", AUDIO_DEVICE, wav_path],
|
||||
check=True,
|
||||
timeout=120
|
||||
)
|
||||
# ALFRED_DUPLEX_BARGE_IN_V1: allow a concurrent microphone detector
|
||||
# to terminate playback, without killing Hermes or the entire service.
|
||||
global _playback_proc
|
||||
if _playback_cancel.is_set():
|
||||
print("[ALFRED PIPER] Annulé avant lecture", flush=True)
|
||||
return
|
||||
proc = subprocess.Popen(["aplay", "-q", "-D", AUDIO_DEVICE, wav_path])
|
||||
with _playback_lock:
|
||||
_playback_proc = proc
|
||||
if _playback_cancel.is_set() and proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
result = proc.wait(timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
raise
|
||||
finally:
|
||||
with _playback_lock:
|
||||
if _playback_proc is proc:
|
||||
_playback_proc = None
|
||||
if _playback_cancel.is_set():
|
||||
print("[ALFRED PIPER] Lecture interrompue par la voix", flush=True)
|
||||
return
|
||||
if result:
|
||||
raise subprocess.CalledProcessError(result, proc.args)
|
||||
|
||||
print(
|
||||
f"[ALFRED PIPER] Terminé en {time.monotonic() - start:.2f}s",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
name: alfred-developer
|
||||
description: "Pour les demandes de modification de l'interface, du code Python, HTML, CSS, JavaScript, de l'ergonomie ou de fonctionnalités de Dona/Alfred ; préparer et tester dans une copie, puis soumettre une proposition à valider sur /maintenance."
|
||||
version: 1.0.0
|
||||
---
|
||||
# Alfred Developer — modifications du propre programme de l'assistant
|
||||
|
||||
Lorsque l'utilisateur demande à Dona/Alfred de modifier son programme, exploiter **exclusivement le MCP `alfred_developer`** pour préparer les modifications. Le projet de production se trouve sous `/opt/alfred/app` et ne doit jamais être modifié directement.
|
||||
|
||||
## Méthode obligatoire
|
||||
|
||||
1. Appeler `create_task` avec une description exacte de la demande, puis noter l'ID.
|
||||
2. Examiner les fichiers utiles avec `list_source` puis `read_source` ; ne pas supposer leur organisation.
|
||||
3. Préparer des modifications minimales avec `modify_source` : copier le contenu intégral du fichier voulu, modifier uniquement le nécessaire et préserver le reste.
|
||||
4. Appeler `run_checks`, résoudre les erreurs de syntaxe et relancer les contrôles.
|
||||
5. Appeler `preview_changes` pour examiner les différences ; éviter les régressions.
|
||||
6. Si les contrôles passent, appeler `submit_proposal`.
|
||||
7. Annoncer **l'ID** et inviter l'utilisateur à examiner la proposition sur `/maintenance`.
|
||||
|
||||
## Règles de sécurité et de vérité
|
||||
|
||||
- **Jamais utiliser Terminal, `sed`, `cp`, `git`, Python direct ou d'autres MCP pour modifier `/opt/alfred/app`, redémarrer le service ou déployer.**
|
||||
- Les outils MCP de développement ne permettent ni déploiement ni restauration.
|
||||
- Ne modifier aucun `.env`, secret, token, mot de passe, base SQLite ou configuration de sécurité.
|
||||
- La validation humaine et le déploiement CLI sont séparés. Une approbation Web ne déploie pas le code.
|
||||
- Les contrôles V1 sont des **vérifications statiques**, pas des tests de fonctionnement en situation réelle : le dire clairement.
|
||||
- Si des tests complémentaires, une modification de fichier protégé ou l'exécution du code sont nécessaires, demander l'intervention de l'administrateur.
|
||||
- Ne pas inventer de statut, d'aperçu ni de résultat d'installation.
|
||||
- Cette version n'est pas un agent de développement en arrière-plan indépendant : la session Hermes conduit le travail, et les propositions restent persistées dans l'espace Git.
|
||||
|
||||
## Exemple
|
||||
|
||||
« Dona, rends la carte météo plus transparente » → `create_task`, inspecter CSS, `modify_source`, `run_checks`, `submit_proposal`, répondre : « Proposition 12caractères prête à valider sur /maintenance. »
|
||||
@@ -0,0 +1 @@
|
||||
:root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,'Segoe UI',sans-serif;color:#f3f7fe;background:#09121d}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:linear-gradient(180deg,#06131fbf,#0a1526c7),url('/static/img/alfred-ambience.webp') center/cover fixed no-repeat}button,input{font:inherit}a{color:inherit;text-decoration:none}.dv-head{display:flex;justify-content:space-between;align-items:center;gap:15px;flex-wrap:wrap;padding:18px clamp(16px,3vw,45px);background:#07132096;border-bottom:1px solid #d3e3fd23;backdrop-filter:blur(18px)}.dv-brand{display:flex;align-items:center;gap:14px}.dv-initial{font-weight:900;font-size:53px;line-height:1;background:linear-gradient(120deg,#a3e4ff,#0875fb);background-clip:text;color:transparent}.dv-brand strong{font-size:24px;display:block}.dv-brand small{color:#aec3dc;font-size:12px}.dv-head nav{display:flex;flex-wrap:wrap;gap:5px}.dv-head nav a{padding:9px 11px;border-radius:9px;font-size:13px;color:#c8d9ed}.dv-head nav a:hover,.dv-head nav a.active{background:#1a6cb6a6;color:white}.dv-main{max-width:1550px;margin:auto;padding:27px clamp(14px,3vw,44px)}.dv-eyebrow{font-size:11px;color:#7cbeff;font-weight:800;letter-spacing:.13em}.dv-intro{display:flex;justify-content:space-between;gap:22px;align-items:end;margin-bottom:22px}.dv-intro h1{font-size:clamp(28px,3vw,43px);margin:4px 0}.dv-intro p:not(.dv-eyebrow){color:#bacbe0;font-size:14px;max-width:790px}.dv-panel{padding:20px;border:1px solid #cfebff36;border-radius:19px;background:rgba(11,24,41,.49);backdrop-filter:blur(19px);box-shadow:0 18px 55px #0003}.dv-layout{display:grid;grid-template-columns:minmax(260px,365px) minmax(0,1fr);gap:17px}.dv-section-title{display:flex;justify-content:space-between;align-items:center;gap:12px}.dv-section-title h2{margin:2px 0 15px;font-size:19px}.dv-muted,.dv-footnote{font-size:13px;color:#bad0e4;line-height:1.6}.dv-tasks{display:flex;flex-direction:column;gap:9px;margin-top:17px}.dv-task{text-align:left;width:100%;background:#061b31a8;border:1px solid #a2c5e73d;border-radius:11px;padding:13px;cursor:pointer;color:white}.dv-task:hover,.dv-task.selected{background:#1261aa60;border-color:#8cc9ff}.dv-task strong,.dv-task small{display:block}.dv-task strong{line-height:1.4}.dv-task small{margin-top:7px;color:#9fbdda}button{background:#145c9fb3;border:1px solid #92c3ec80;color:white;border-radius:11px;padding:10px 15px;cursor:pointer}button:hover{background:#2676bd}button:disabled{opacity:.45;cursor:default}.dv-primary{background:#176bc8}.dv-empty{border:1px dashed #acd4f864;border-radius:12px;padding:28px;color:#a9c5de}.dv-detail h3{font-size:20px;margin:5px 0}.dv-detail h4{font-size:13px;color:#badcfb;letter-spacing:.04em;margin-bottom:10px}.dv-checks{display:grid;gap:8px}.dv-check{font-size:13px;padding:9px 12px;border-radius:9px;background:#051627ba;border-left:3px solid #44cba2}.dv-check.fail{border-color:#f5a37d}.dv-diff{max-height:47vh;overflow:auto;white-space:pre;tab-size:2;background:#030e1be8;border:1px solid #a6cbe13e;border-radius:11px;padding:17px;color:#d9ecff;font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace}.dv-actions{display:flex;gap:11px;flex-wrap:wrap;margin-top:18px}.dv-next{margin-top:15px;border-radius:12px;padding:15px;background:#071f32c9;border:1px solid #40a7ee64}.dv-next code{display:block;padding:10px;margin-top:10px;overflow-x:auto;color:#a5d6ff}.dv-footnote{margin-top:19px}.dv-section-title span{font-size:12px;color:#91d0ff}.dv-panel form{display:flex;gap:10px;flex-wrap:wrap}.dv-panel input{padding:10px 12px;background:#061629;border:1px solid #a5cfed6b;border-radius:10px;color:white}#dvMessage{color:#e8c8a3}#dvMessage.ok{color:#86e6bf}:focus-visible{outline:2px solid #90d0ff;outline-offset:2px}@media(max-width:920px){.dv-layout{grid-template-columns:1fr}.dv-intro{align-items:start;flex-direction:column}.dv-head nav{width:100%}}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* ALFRED_HERMES_NEW_SESSION_V1 — habillage Glass, compatible avec le menu V1/V2. */
|
||||
#avHermesNewSession {cursor:pointer;}
|
||||
.av-hermes-session-dialog {
|
||||
position:fixed; inset:0; margin:auto; width:min(520px,calc(100vw - 28px));
|
||||
max-height:calc(100dvh - 32px);overflow:auto;color:#eef6ff;
|
||||
border:1px solid rgba(174,210,246,.36);border-radius:19px;
|
||||
background:linear-gradient(145deg,rgba(18,37,58,.94),rgba(7,19,36,.96));
|
||||
box-shadow:0 22px 65px rgba(0,0,0,.55);backdrop-filter:blur(22px);
|
||||
padding:0;z-index:2147483640;
|
||||
}
|
||||
.av-hermes-session-dialog::backdrop {background:rgba(2,8,19,.67);backdrop-filter:blur(4px);}
|
||||
.av-hermes-session-content {display:flex;flex-direction:column;gap:16px;padding:27px;margin:0;}
|
||||
.av-hermes-session-content h2 {font-size:23px;color:#f4f9ff;margin:0;}
|
||||
.av-hermes-session-content p {font-size:14px;line-height:1.6;margin:0;color:#d1deed;}
|
||||
.av-hermes-session-note {font-size:12px!important;color:#a8c0d6!important;}
|
||||
.av-hermes-session-message {min-height:18px;color:#89d9af!important;}
|
||||
.av-hermes-session-message.is-error {color:#ffb7b7!important;}
|
||||
.av-hermes-session-actions {display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;}
|
||||
.av-hermes-session-actions button,.av-hermes-session-login {border-radius:11px;padding:11px 16px;font-weight:600;font-size:13px;cursor:pointer;}
|
||||
.av-hermes-session-secondary {background:rgba(255,255,255,.10);color:white;border:1px solid rgba(255,255,255,.22);}
|
||||
.av-hermes-session-primary,.av-hermes-session-login {background:#268ce8;color:white;border:1px solid rgba(119,198,255,.45);}
|
||||
.av-hermes-session-login {align-self:flex-start;text-decoration:none;}
|
||||
.av-hermes-session-actions button:disabled {opacity:.45;cursor:wait;}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* ALFRED_REPONSE_CLOSE_V1 — actions toujours au-dessus du contenu rendu par Hermes. */
|
||||
.alfred-glass-theme #stage {
|
||||
padding-top: clamp(76px, 9vh, 104px) !important;
|
||||
position: fixed;
|
||||
}
|
||||
.alfred-glass-theme #stage .av-reponse-actions {
|
||||
position: absolute;
|
||||
top: clamp(14px, 2.2vh, 25px);
|
||||
right: clamp(15px, 2vw, 31px);
|
||||
z-index: 32;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
/* #avBack est déplacé hors de .alfred-main-v1 pour éviter son contexte d'empilement. */
|
||||
.alfred-glass-theme #stage #avBack.av-reponse-home,
|
||||
.alfred-glass-theme #stage .av-reponse-close {
|
||||
position: static !important;
|
||||
inset: auto !important;
|
||||
transform: none !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-height: 43px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid rgba(181, 218, 248, .47);
|
||||
border-radius: 13px;
|
||||
background: rgba(18, 42, 65, .93);
|
||||
color: #f6fbff;
|
||||
font: 600 13px/1.2 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 18px rgba(0, 5, 15, .4);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
.alfred-glass-theme #stage #avBack.av-reponse-home[hidden] { display: none !important; }
|
||||
.alfred-glass-theme #stage .av-reponse-close {
|
||||
width: 43px;
|
||||
min-width: 43px;
|
||||
padding: 9px;
|
||||
border-radius: 50%;
|
||||
background: rgba(37, 51, 70, .96);
|
||||
}
|
||||
.alfred-glass-theme #stage .av-reponse-close svg,
|
||||
.alfred-glass-theme #stage #avBack.av-reponse-home svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.alfred-glass-theme #stage .av-reponse-close:hover,
|
||||
.alfred-glass-theme #stage #avBack.av-reponse-home:hover {
|
||||
background: rgba(21, 103, 169, .98);
|
||||
border-color: #9ed7ff;
|
||||
}
|
||||
.alfred-glass-theme #stage .av-reponse-close:focus-visible,
|
||||
.alfred-glass-theme #stage #avBack.av-reponse-home:focus-visible {
|
||||
outline: 3px solid #83d1ff;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.alfred-glass-theme #stage { padding-top: 88px !important; }
|
||||
.alfred-glass-theme #stage .av-reponse-actions { top: 12px; right: 12px; gap: 7px; }
|
||||
.alfred-glass-theme #stage #avBack.av-reponse-home,
|
||||
.alfred-glass-theme #stage .av-reponse-close { font-size: 12px; padding: 8px 11px; min-height: 40px; }
|
||||
.alfred-glass-theme #stage .av-reponse-close { width: 40px; min-width: 40px; padding: 8px; }
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/* ALFRED_STT_INLINE_GLASS_V1 — transcription en texte libre au-dessus de la vague.
|
||||
Charge apres alfred-main-v1.css et alfred-main-v2.css. */
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on {
|
||||
display:flex !important;
|
||||
flex-direction:column !important;
|
||||
align-items:center !important;
|
||||
justify-content:center !important;
|
||||
gap:0 !important;
|
||||
overflow:hidden;
|
||||
}
|
||||
/* Ancien aside STT : aucun cadre, aucune position flottante. */
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero .transcript-panel.av-stt-inline {
|
||||
position:relative !important;
|
||||
inset:auto !important;
|
||||
left:auto !important; right:auto !important; top:auto !important; bottom:auto !important;
|
||||
transform:none !important;
|
||||
order:0;
|
||||
z-index:3 !important;
|
||||
display:flex !important;
|
||||
flex-direction:column !important;
|
||||
align-items:center !important;
|
||||
justify-content:center !important;
|
||||
flex:0 0 auto;
|
||||
width:min(920px,96%) !important;
|
||||
max-width:96% !important;
|
||||
min-height:0 !important;
|
||||
height:auto !important;
|
||||
margin:0 auto !important;
|
||||
padding:0 8px !important;
|
||||
border:0 !important;
|
||||
border-radius:0 !important;
|
||||
outline:0 !important;
|
||||
background:transparent !important;
|
||||
box-shadow:none !important;
|
||||
backdrop-filter:none !important;
|
||||
-webkit-backdrop-filter:none !important;
|
||||
opacity:1 !important;
|
||||
text-align:center;
|
||||
pointer-events:none;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .panel-heading {
|
||||
min-height:0 !important;
|
||||
width:100%;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
flex-wrap:wrap;
|
||||
gap:8px 14px;
|
||||
margin:0 0 4px !important;
|
||||
padding:0 !important;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .panel-title {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
color:rgba(209,226,247,.83);
|
||||
font-size:clamp(10px,.85vw,13px) !important;
|
||||
font-weight:650;
|
||||
letter-spacing:.08em;
|
||||
text-shadow:0 2px 9px #0008;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .stt-indicator {background:#5e94df;}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline[data-active="true"] .stt-indicator {background:#61dfc4;}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .transcript-state {
|
||||
color:#87c9ff !important;
|
||||
font-size:clamp(10px,.85vw,12px);
|
||||
font-weight:650;
|
||||
white-space:nowrap;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .transcript-text {
|
||||
display:-webkit-box;
|
||||
-webkit-box-orient:vertical;
|
||||
-webkit-line-clamp:2;
|
||||
line-clamp:2;
|
||||
overflow:hidden !important;
|
||||
width:100%;
|
||||
max-height:3.1em !important;
|
||||
margin:0 !important;
|
||||
color:#f0f7ff !important;
|
||||
font-size:clamp(13px,1.18vw,18px) !important;
|
||||
font-weight:450;
|
||||
line-height:1.45 !important;
|
||||
text-align:center;
|
||||
text-shadow:0 2px 13px rgba(0,0,0,.85);
|
||||
overflow-wrap:anywhere;
|
||||
opacity:1 !important;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .transcript-text.is-placeholder {
|
||||
color:rgba(205,223,243,.62) !important;
|
||||
}
|
||||
/* Vague et messages existants sous la transcription. Le canvas reste original. */
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-wave-host {
|
||||
position:relative !important;
|
||||
inset:auto !important;
|
||||
left:auto !important; top:auto !important;
|
||||
transform:none !important;
|
||||
order:1;
|
||||
z-index:1;
|
||||
flex:0 0 clamp(46px,5.3vw,72px);
|
||||
align-self:center;
|
||||
width:min(800px,90%) !important;
|
||||
height:clamp(46px,5.3vw,72px) !important;
|
||||
margin:0 auto !important;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-wave-host #wave,
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-wave-host canvas {
|
||||
height:100% !important;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-voice-controls {
|
||||
position:relative !important;
|
||||
inset:auto !important;
|
||||
order:2;
|
||||
min-height:0 !important;
|
||||
height:auto !important;
|
||||
flex:0 0 auto;
|
||||
padding-top:0 !important;
|
||||
margin:0 !important;
|
||||
align-self:center !important;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-voice-center {
|
||||
min-width:0 !important;
|
||||
}
|
||||
/* Mode et messages d'etat, volontairement inchanges. */
|
||||
@media (max-width:1100px) {
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on {
|
||||
min-height:215px !important;
|
||||
}
|
||||
}
|
||||
@media (max-width:600px) {
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on {
|
||||
min-height:200px !important;
|
||||
padding:8px 0;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero .transcript-panel.av-stt-inline {
|
||||
width:100% !important; max-width:100% !important;
|
||||
}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .panel-heading {gap:3px 8px;}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .panel-title {font-size:10px !important;letter-spacing:.03em;}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline .transcript-text {font-size:13px !important;}
|
||||
.alfred-glass-theme .alfred-main-v1 .av-voice-hero.av-stt-inline-on .av-wave-host {
|
||||
width:100% !important;
|
||||
flex-basis:50px;
|
||||
height:50px !important;
|
||||
}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.alfred-glass-theme .alfred-main-v1 .av-stt-inline {transition:none !important;}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/* DONA_HOLOGRAPHIC_V1
|
||||
* Thème transversal non destructif. Les contrôleurs et APIs existants restent les mêmes.
|
||||
* Chargement APRÈS les feuilles Glass, y compris les correctifs précédents.
|
||||
*/
|
||||
:root { color-scheme:dark; --dh-white:#f1f4ff;--dh-muted:#a8b8d3;--dh-border:rgba(155,183,234,.23);--dh-panel:rgba(4,10,22,.81);--dh-blue:#5796ff;--dh-teal:#74e6d4;--dh-purple:#ab78ff;--dh-orange:#ffae73;--dh-shadow:0 24px 72px rgba(0,0,0,.38); }
|
||||
html {scrollbar-color:#596b8c #060a14;}
|
||||
body.dona-holo-v1 { color:var(--dh-white); background-color:#070913!important; background-image:
|
||||
radial-gradient(ellipse 35% 39% at 3% 70%,rgba(251,101,44,.5),transparent 95%),
|
||||
radial-gradient(ellipse 36% 52% at 96% 26%,rgba(18,125,236,.42),transparent 85%),
|
||||
radial-gradient(ellipse 42% 35% at 50% 0%,rgba(119,68,173,.31),transparent 90%),
|
||||
radial-gradient(ellipse 24% 32% at 82% 94%,rgba(243,97,42,.29),transparent 92%),
|
||||
linear-gradient(125deg,#070810,#0a1020 54%,#090914) !important;
|
||||
background-attachment:fixed!important; }
|
||||
body.dona-holo-v1::before{content:"";position:fixed;z-index:0;inset:-10%;pointer-events:none;
|
||||
background:radial-gradient(ellipse at 12% 18%,rgba(56,111,251,.17),transparent 33%),radial-gradient(ellipse at 78% 65%,rgba(227,100,165,.15),transparent 32%);
|
||||
filter:blur(68px);animation:dh-ambient 19s ease-in-out infinite alternate;}
|
||||
body.dona-holo-v1::after{content:"";position:fixed;inset:0;z-index:0;pointer-events:none;
|
||||
background:linear-gradient(90deg,rgba(1,5,13,.13),rgba(1,5,13,.45) 43%,rgba(1,5,13,.18));}
|
||||
@keyframes dh-ambient {0%{transform:translate(-1.5%,0)}100%{transform:translate(2%,1.4%)}}
|
||||
/* Écrans d'administration, qui utilisent des conteneurs et feuilles CSS différents. */
|
||||
body.dona-holo-v1 :is(.admin-layout,.id-shell,.id-wrap,.id-layout,.dv-main,.lv-layout,.lg-layout,.log-layout,.logs-layout){position:relative;z-index:1;}
|
||||
body.dona-holo-v1 .alfred-main-v1{z-index:1;}
|
||||
/* Priorité au panneau noir central de la maquette. Le décor reste diffus en périphérie. */
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-head {background:transparent!important;}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-logo {background:linear-gradient(145deg,#95c2ff,#5187ff 55%,#c58bff);background-clip:text;-webkit-background-clip:text;color:transparent;filter:drop-shadow(0 1px 18px #4486ff73);}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-brand small{color:#c4cde1;}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-round{background:rgba(5,9,22,.74)!important;border:1px solid rgba(155,174,224,.24)!important;box-shadow:inset 0 1px rgba(255,255,255,.04);}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-round[aria-pressed="true"]{background:rgba(108,28,58,.75)!important;border-color:rgba(255,116,151,.66)!important;}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-summary{gap:clamp(9px,1.05vw,18px);}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-stat-card{color:var(--dh-white)!important;
|
||||
background:linear-gradient(140deg,rgba(6,13,31,.79),rgba(8,16,34,.68))!important;
|
||||
border:1px solid rgba(126,157,227,.24)!important;border-radius:22px!important;
|
||||
box-shadow:0 12px 44px #01051255,inset 0 1px #ffffff0a!important;
|
||||
backdrop-filter:blur(20px) saturate(120%)!important;-webkit-backdrop-filter:blur(20px) saturate(120%)!important;
|
||||
transition:transform .2s,border-color .2s,box-shadow .2s;}
|
||||
body.dona-holo-v1 .alfred-main-v1 .av-stat-card:hover{transform:translateY(-2px);border-color:#8bb9ff83!important;box-shadow:0 14px 45px #0206128b,0 0 23px #336ce22c!important;background:rgba(10,21,42,.85)!important;}
|
||||
body.dona-holo-v1 .av-circle{background:#1e376566;color:#94c9ff;}
|
||||
body.dona-holo-v1 .av-circle.ai{background:#492b7a80;color:#d2b3ff;}
|
||||
body.dona-holo-v1 .av-weather{background:linear-gradient(140deg,rgba(12,28,44,.74),rgba(8,19,37,.8))!important;}
|
||||
body.dona-holo-v1 .av-shortcuts button{background:rgba(5,10,24,.82)!important;border:1px solid rgba(151,177,221,.27)!important;border-radius:21px!important;box-shadow:inset 0 1px rgba(255,255,255,.035);}
|
||||
body.dona-holo-v1 .av-shortcuts button:hover{background:#102344e6!important;border-color:#65a1ff82!important;}
|
||||
body.dona-holo-v1 .av-shortcuts .a-icon{color:#a9c6ff;}
|
||||
body.dona-holo-v1 .av-status-line{background:rgba(6,11,27,.65);border-color:rgba(157,186,229,.23);}
|
||||
body.dona-holo-v1 .av-wave-host canvas{filter:drop-shadow(0 0 9px rgba(91,137,255,.65))!important;}
|
||||
/* La réponse qui était une énorme div pleine page devient une scène avec cartes vivantes. */
|
||||
body.dona-holo-v1.alfred-glass-theme #stage {top:clamp(93px,11vh,128px)!important;right:clamp(12px,2vw,34px)!important;bottom:clamp(12px,2.3vh,30px)!important;left:clamp(12px,2vw,34px)!important;
|
||||
background:linear-gradient(146deg,rgba(2,5,13,.97),rgba(3,7,18,.96) 55%,rgba(6,10,26,.95))!important;
|
||||
border:1px solid rgba(154,180,231,.25)!important;border-radius:clamp(18px,2vw,31px)!important;
|
||||
padding:clamp(60px,8vh,100px) clamp(14px,3vw,46px) clamp(14px,3vh,28px)!important;
|
||||
box-shadow:0 36px 95px rgba(0,0,0,.55),inset 0 1px #d7dfff0d;
|
||||
display:flex!important;align-items:center!important;justify-content:center!important;overflow:auto!important;}
|
||||
body.dona-holo-v1.alfred-glass-theme #stage #content{width:min(1300px,100%)!important;max-width:none!important;max-height:none!important;min-height:0!important;overflow:visible!important;margin:auto;}
|
||||
body.dona-holo-v1.alfred-glass-theme #stage .av-reponse-actions {top:16px!important;right:20px!important;}
|
||||
body.dona-holo-v1.alfred-glass-theme #stage :is(#avBack.av-reponse-home,.av-reponse-close){background:rgba(17,27,51,.91)!important;border:1px solid #9bb9ec55!important;color:#f4f7ff!important;border-radius:15px!important;}
|
||||
body.dona-holo-v1.alfred-glass-theme #stage .av-reponse-close{border-radius:50%!important;}
|
||||
/* Réponse illustrée construite sur les données déjà présentes dans la réponse Hermes.
|
||||
Les éléments ne sont pas des chiffres simulés : un métrique n'apparaît que s'il est cité. */
|
||||
#content .dh-response{display:grid;grid-template-columns:minmax(250px,1.12fr) minmax(300px,.95fr);gap:clamp(12px,1.6vw,22px);align-items:stretch;min-width:0;}
|
||||
#content .dh-primary{position:relative;min-width:0;min-height:335px;display:flex;flex-direction:column;gap:20px;
|
||||
padding:clamp(22px,2.3vw,36px);border:1px solid rgba(96,142,255,.72);border-radius:24px;
|
||||
background:linear-gradient(135deg,rgba(13,30,62,.9),rgba(6,14,31,.89) 62%,rgba(15,36,42,.82));
|
||||
box-shadow:inset 0 1px #ffffff12,0 0 23px #3976fc2b,0 20px 35px #0004;}
|
||||
#content .dh-primary:before{content:"";position:absolute;inset:-1px;pointer-events:none;border-radius:24px;background:linear-gradient(120deg,#5b83ff33,transparent 44%,#72e8cd17);opacity:.7;}
|
||||
#content .dh-kicker{display:flex;align-items:center;gap:11px;font-size:11px!important;color:#a9bee6!important;letter-spacing:.16em;font-weight:700;text-transform:uppercase;}
|
||||
#content .dh-kicker:before{content:"";height:9px;width:9px;background:#61a3ff;border-radius:50%;box-shadow:0 0 12px #579cff;}
|
||||
#content .dh-headline{font-size:clamp(21px,2.1vw,33px)!important;letter-spacing:-.025em;font-weight:590;line-height:1.28;color:#f4f6ff;overflow-wrap:anywhere;}
|
||||
#content .dh-excerpt{color:#c8d2e6!important;font-size:clamp(13px,1.02vw,16px)!important;line-height:1.7;white-space:pre-wrap;overflow-wrap:anywhere;flex:1;}
|
||||
#content .dh-more{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:52px;padding:13px 16px;
|
||||
background:rgba(37,58,95,.44);border:1px solid #aac9ff42;border-radius:13px;color:#f0f5ff;font:600 13px/1.3 inherit;cursor:pointer;text-align:left;}
|
||||
#content .dh-more:hover{background:#294b748e;border-color:#95caff92;}
|
||||
#content .dh-facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-auto-rows:minmax(145px,1fr);gap:14px;min-width:0;}
|
||||
#content .dh-fact{border:1px solid rgba(113,151,218,.35);border-radius:20px;padding:clamp(16px,1.7vw,25px);min-width:0;
|
||||
background:linear-gradient(135deg,rgba(17,34,63,.94),rgba(9,22,48,.85));box-shadow:inset 0 1px #ffffff0d;display:flex;flex-direction:column;gap:8px;}
|
||||
#content .dh-fact:nth-child(3n + 2){border-color:rgba(101,209,189,.29);background:linear-gradient(145deg,rgba(12,38,52,.94),rgba(13,26,45,.85));}
|
||||
#content .dh-fact:nth-child(3n + 3){border-color:rgba(239,150,94,.27);background:linear-gradient(135deg,rgba(45,29,40,.91),rgba(29,22,37,.86));}
|
||||
#content .dh-fact:nth-child(4n){border-color:rgba(167,124,239,.28);background:linear-gradient(135deg,rgba(31,26,60,.94),rgba(16,20,43,.86));}
|
||||
#content .dh-fact-label{font-size:clamp(11px,.85vw,14px)!important;line-height:1.35;color:#c1cee8;}
|
||||
#content .dh-fact-icon{font-size:21px;margin-bottom:6px;opacity:.9;}
|
||||
#content .dh-fact-value{font-size:clamp(19px,2.25vw,36px)!important;font-weight:570;line-height:1.15;color:#f9faff;letter-spacing:-.04em;overflow-wrap:anywhere;}
|
||||
#content .dh-fact-note{font-size:11px!important;color:#93a6c9;line-height:1.5;margin-top:auto;overflow-wrap:anywhere;}
|
||||
#content .dh-tags{display:flex;flex-wrap:wrap;gap:7px;margin-top:6px;}
|
||||
#content .dh-tag{padding:7px 11px;background:rgba(88,111,171,.2);border:1px solid rgba(143,166,218,.24);border-radius:999px;color:#d1daf6;font-size:12px;}
|
||||
#content .dh-full{grid-column:1/-1;border:1px solid rgba(145,174,227,.27);background:rgba(11,20,39,.82);border-radius:18px;padding:20px;}
|
||||
#content .dh-full[hidden]{display:none!important;}
|
||||
#content .dh-full h3{font-size:16px;margin:0 0 12px;color:#deecff;text-align:left;}
|
||||
#content .dh-full-text{white-space:pre-wrap;color:#ced8ea;line-height:1.65;font-size:14px;overflow-wrap:anywhere;}
|
||||
#content .dh-source{font-size:11px;color:#8b9fc1;margin-top:9px;}
|
||||
#content .dh-response:after{content:"";grid-column:1/-1;justify-self:center;width:min(490px,64%);height:3px;
|
||||
background:linear-gradient(90deg,transparent,#3e92fe 30%,#c876f8 55%,#f7a569 73%,transparent);border-radius:50%;box-shadow:0 0 18px 5px #529bff5b;margin:7px 0 0;}
|
||||
/* Les anciens types de cartes émis par les renderers restent pris en charge. */
|
||||
body.dona-holo-v1 #stage #content > .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:17px;text-align:left;}
|
||||
body.dona-holo-v1 #stage #content > .cards .card{padding:24px;border-radius:21px;background:linear-gradient(145deg,#13243ddf,#0a162ce5);border:1px solid #7c9cde57;text-align:left;}
|
||||
body.dona-holo-v1 #stage #content > img.hero {max-height:calc(100dvh - 260px);width:auto;max-width:100%;object-fit:contain;border-radius:16px;}
|
||||
/* Configuration et Surveillance */
|
||||
body.dona-holo-v1.alfred-admin-glass .admin-layout{z-index:1;}
|
||||
body.dona-holo-v1.alfred-admin-glass .ag-rail{background:rgba(3,7,20,.89)!important;border-color:var(--dh-border)!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass .admin-header{background:transparent!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass .brand-orb{background:linear-gradient(135deg,#438bff,#9368fa)!important;border-radius:18px;}
|
||||
body.dona-holo-v1.alfred-admin-glass :is(.panel,.metric-card,.auth-box,.ag-row,.mcp-item){
|
||||
background:linear-gradient(145deg,rgba(5,13,30,.91),rgba(9,18,36,.84))!important;
|
||||
border:1px solid rgba(133,164,229,.29)!important;border-radius:21px!important;box-shadow:var(--dh-shadow);backdrop-filter:blur(18px);}
|
||||
body.dona-holo-v1.alfred-admin-glass .page-top .eyebrow{color:#9cb9ff;}
|
||||
body.dona-holo-v1.alfred-admin-glass .button.primary{background:linear-gradient(125deg,#3063d0,#556dca)!important;border-color:#98baff88!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass :is(.field input:not([type=range]):not([type=checkbox]),.field select,.ag-field input,.ag-field select){background:rgba(4,11,25,.87)!important;border-color:#799acc55!important;color:#f2f6ff!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass .admin-header nav :is(a,button){background:rgba(6,13,31,.78)!important;border-color:#8aace950!important;}
|
||||
body.dona-holo-v1.alfred-admin-glass .admin-header nav .selected{border-color:#76a5fd!important;background:rgba(38,73,138,.92)!important;}
|
||||
/* Pages dont les classes proviennent d'extensions installées séparément. */
|
||||
body.dona-holo-v1 :is(.id-panel,.dv-panel,.lg-panel,.lv-panel,.log-panel,.logs-panel,.glass-panel,.id-editor,.dv-task,.dv-list,.dv-detail){background:linear-gradient(135deg,rgba(6,14,31,.92),rgba(12,20,42,.87))!important;
|
||||
border:1px solid rgba(127,158,221,.3)!important;border-radius:22px!important;box-shadow:0 15px 35px rgba(0,0,0,.22);backdrop-filter:blur(15px);}
|
||||
body.dona-holo-v1 :is(.dv-head,.id-header,.id-head,.lv-head,.lg-head,.logs-header,.log-header){position:relative;z-index:2;background:rgba(5,10,23,.66)!important;border-color:#819bd046!important;backdrop-filter:blur(15px);}
|
||||
body.dona-holo-v1 :is(.dv-head,.id-header,.id-head) :is(a,button){border-radius:12px;}
|
||||
body.dona-holo-v1 :is(.dv-main,.id-main,.lg-main,.lv-main,.logs-main,.log-main) h1{color:#f5f8ff;letter-spacing:-.035em;}
|
||||
body.dona-holo-v1 :is(input,select,textarea){accent-color:#7fa7ff;}
|
||||
body.dona-holo-v1 :is(.id-panel,.dv-panel,.lg-panel,.lv-panel,.log-panel,.logs-panel) :is(input:not([type=checkbox]):not([type=range]),textarea,select){background:rgba(5,11,27,.91)!important;color:#f2f6ff!important;border:1px solid #96b9ef55!important;border-radius:12px!important;}
|
||||
body.dona-holo-v1 :is(.dv-primary,.id-button.primary){background:linear-gradient(120deg,#376cdd,#6763d5)!important;color:white!important;}
|
||||
body.dona-holo-v1 :is(a,button,input,select,textarea):focus-visible{outline:2px solid #a9c5ff;outline-offset:3px;}
|
||||
/* Page Journaux : log monospace lisible, pas de glow derrière le contenu. */
|
||||
body.dona-holo-v1 :is(.lg-list,.logs-feed,.log-feed,.logs-table,.log-list,.lg-entries,pre.dv-diff){background:rgba(2,6,15,.79)!important;border:1px solid rgba(122,149,208,.25)!important;border-radius:14px!important;color:#d2e5ff;}
|
||||
/* Les modules secondaires restent visibles au-dessus des effets de fond. */
|
||||
body.dona-holo-v1 :is(dialog,.av-image-dialog,.camera-popup,#alfredTimersDock,#alfredTimerToast,.av-more-dialog,.audio-popover){z-index:100;}
|
||||
@media(max-width:1050px){#content .dh-response{grid-template-columns:1fr;}#content .dh-primary{min-height:220px}#content .dh-facts{grid-auto-rows:minmax(125px,auto);}body.dona-holo-v1.alfred-glass-theme #stage{align-items:flex-start!important;}}
|
||||
@media(max-width:700px){body.dona-holo-v1.alfred-glass-theme #stage{top:85px!important;left:8px!important;right:8px!important;bottom:8px!important;padding:80px 12px 18px!important;}#content .dh-response{display:block;}#content .dh-primary{padding:20px;margin-bottom:13px;}#content .dh-facts{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;}#content .dh-fact{padding:14px;min-height:130px;}#content .dh-full{margin-top:12px;}.alfred-glass-theme #stage .av-reponse-actions{top:12px!important;right:12px!important;}}
|
||||
@media(prefers-reduced-motion:reduce){body.dona-holo-v1::before{animation:none}body.dona-holo-v1 .av-stat-card{transition:none}}
|
||||
/* Finitions du panneau et affichage sans métriques */
|
||||
#content .dh-response{text-align:left;}
|
||||
#content .dh-response.dh-no-facts{grid-template-columns:minmax(0,900px);justify-content:center;}
|
||||
#content .dh-more{font-family:inherit;font-size:13px;font-weight:600;line-height:1.3;}
|
||||
body.dona-holo-v1, #content .dh-response{font-family:Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;}
|
||||
/* Fenêtres secondaires de l'accueil (listes, ComfyUI, caméra) dans la même palette. */
|
||||
body.dona-holo-v1 :is(#alfredListsGlassDialog,.av-image-dialog,.av-comfy-dialog,.av-more-dialog,.av-popup,.camera-popup,.audio-popover){
|
||||
background:linear-gradient(145deg,rgba(4,10,24,.97),rgba(9,19,41,.96))!important;
|
||||
border:1px solid rgba(142,176,242,.38)!important;border-radius:22px!important;
|
||||
box-shadow:0 32px 90px #000a,0 0 26px #4f7fff18!important;color:#f4f7ff!important;
|
||||
backdrop-filter:blur(22px)!important;}
|
||||
body.dona-holo-v1 :is(.al-list-section,.al-item){background:rgba(14,25,48,.75)!important;border-color:#8aa9e34a!important;}
|
||||
body.dona-holo-v1 :is(.al-add-form button,.al-footer button){background:linear-gradient(120deg,#254a9f,#4f56bb)!important;border-color:#8baffb77!important;}
|
||||
@@ -0,0 +1,45 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
const $=id=>document.getElementById(id);
|
||||
let csrf='',selected=null,latest=null, busy=false;
|
||||
const message=(txt,ok=false,id='dvMessage')=>{let e=$(id);e.textContent=txt;e.classList.toggle('ok',ok);};
|
||||
async function fetchJson(url,options={}){
|
||||
const r=await fetch(url,{credentials:'same-origin',cache:'no-store',...options});
|
||||
const d=await r.json().catch(()=>({}));
|
||||
if(!r.ok)throw Error(d.error || 'Erreur HTTP '+r.status);
|
||||
return d;
|
||||
}
|
||||
async function auth(){
|
||||
const d=await fetchJson('/api/control/auth');csrf=d.csrf||'';
|
||||
$('dvLoginBox').hidden=!!d.authenticated;$('dvApp').hidden=!d.authenticated;
|
||||
if(d.authenticated)await refresh();
|
||||
}
|
||||
async function getName(){try{const r=await fetchJson('/api/alfred/identity/name');let n=r.name||'Assistant';$('dvName').textContent=n;$('dvInitial').textContent=Array.from(n)[0]?.toLocaleUpperCase('fr-FR')||'A';document.title='Maintenance · '+n;}catch(_){}}
|
||||
function renderTasks(tasks){$('dvCount').textContent=tasks.length+' propositions';const host=$('dvTasks');host.replaceChildren();
|
||||
if(!tasks.length){let p=document.createElement('p');p.className='dv-muted';p.textContent='Aucune proposition pour le moment.';host.append(p);return;}
|
||||
for(const t of tasks){let btn=document.createElement('button');btn.type='button';btn.className='dv-task'+(selected===t.id?' selected':'');
|
||||
let strong=document.createElement('strong');strong.textContent=t.title;let small=document.createElement('small');small.textContent=t.id+' · '+t.status+' · '+t.changed.length+' fichier(s)';btn.append(strong,small);btn.addEventListener('click',()=>select(t.id));host.append(btn);}
|
||||
}
|
||||
async function refresh(){const r=await fetchJson('/api/control/developer/tasks');renderTasks(r.tasks);if(selected)await select(selected,false);}
|
||||
async function select(id,again=true){selected=id;const d=await fetchJson('/api/control/developer/tasks/'+id);latest=d.task;
|
||||
$('dvEmpty').hidden=true;$('dvSelected').hidden=false;$('dvTitle').textContent=latest.title;
|
||||
$('dvState').textContent=latest.status;$('dvMeta').textContent='Proposition '+id+' · '+latest.changed.length+' fichier(s) · '+latest.created_at;
|
||||
$('dvDiff').textContent=latest.diff||'Aucun changement détecté.';
|
||||
const c=$('dvChecks');c.replaceChildren();for(const t of latest.checks||[]){let div=document.createElement('div');div.className='dv-check'+(t.ok?'':' fail');div.textContent=(t.ok?'✓ ':'✗ ')+t.file+' — '+t.detail;c.append(div);}
|
||||
if(!c.childNodes.length)c.textContent='Contrôles pas encore réalisés.';
|
||||
$('dvActions').hidden=latest.status!=='pending';$('dvNext').hidden=latest.status!=='approved';
|
||||
$('dvCommand').textContent='sudo python3 /opt/alfred/app/tools/alfred_deploy.py deploy '+id+' --apply';
|
||||
if(again)await refresh();
|
||||
}
|
||||
async function decide(decision){if(!latest||busy)return;const desc=decision==='approve'?'approuver':'refuser';
|
||||
if(!confirm('Confirmer : '+desc+' la proposition '+latest.id+' ?'))return;
|
||||
busy=true;try{const id=latest.id;const d=await fetchJson('/api/control/developer/tasks/'+id+'/decision',{
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-Alfred-CSRF':csrf},body:JSON.stringify({decision,diff_sha256:latest.diff_sha256})
|
||||
});message('Décision enregistrée : '+d.result.status,true);await select(id);}catch(e){message(e.message);}finally{busy=false;}
|
||||
}
|
||||
$('dvLogin').addEventListener('submit',async e=>{e.preventDefault();try{await fetchJson('/api/control/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:$('dvPassword').value})});$('dvPassword').value='';await auth();}catch(err){message(err.message,false,'dvLoginInfo');}});
|
||||
$('dvRefresh').addEventListener('click',()=>refresh().catch(err=>message(err.message)));
|
||||
$('dvApprove').addEventListener('click',()=>decide('approve'));
|
||||
$('dvReject').addEventListener('click',()=>decide('reject'));
|
||||
getName();auth().catch(e=>{$('dvLoginBox').hidden=false;message(e.message,false,'dvLoginInfo');});
|
||||
})();
|
||||
@@ -0,0 +1,111 @@
|
||||
/* ALFRED_HERMES_NEW_SESSION_V1 — reset sûr de la seule session conversationnelle Hermes. */
|
||||
(() => {
|
||||
'use strict';
|
||||
function setup() {
|
||||
const grid = document.querySelector('#avMoreDialog .av-more-grid');
|
||||
if (!grid || document.getElementById('avHermesNewSession')) return false;
|
||||
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.id = 'avHermesNewSession';
|
||||
button.className = 'av-hermes-new-session';
|
||||
button.innerHTML = '<svg viewBox="0 0 24 24" class="a-icon" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 3v6h6"/><path d="M12 7v5l4 2"/></svg><span>Nouvelle session Hermes</span>';
|
||||
grid.appendChild(button);
|
||||
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.id = 'avHermesSessionDialog';
|
||||
dialog.className = 'av-hermes-session-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'avHermesSessionTitle');
|
||||
dialog.innerHTML = `
|
||||
<form method="dialog" class="av-hermes-session-content">
|
||||
<h2 id="avHermesSessionTitle">Nouvelle session Hermes</h2>
|
||||
<p>Repartir sans le contexte de l’ancienne conversation. La prochaine demande utilisera une nouvelle session avec les outils Hermes disponibles. La session actuelle sera sauvegardée.</p>
|
||||
<p class="av-hermes-session-note">Les outils, les Skills et les réglages de Dona restent inchangés. Aucune tâche Hermes n'est annulée.</p>
|
||||
<p id="avHermesSessionMessage" class="av-hermes-session-message" role="status" aria-live="polite"></p>
|
||||
<div class="av-hermes-session-actions">
|
||||
<button type="button" id="avHermesSessionCancel" class="av-hermes-session-secondary">Annuler</button>
|
||||
<button type="button" id="avHermesSessionConfirm" class="av-hermes-session-primary">Créer une nouvelle session</button>
|
||||
</div>
|
||||
</form>`;
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const msg = dialog.querySelector('#avHermesSessionMessage');
|
||||
const confirm = dialog.querySelector('#avHermesSessionConfirm');
|
||||
const cancel = dialog.querySelector('#avHermesSessionCancel');
|
||||
let csrf = '';
|
||||
let busy = false;
|
||||
|
||||
function setMessage(text, error = false) {
|
||||
msg.textContent = text;
|
||||
msg.classList.toggle('is-error', error);
|
||||
}
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
document.getElementById('avMoreDialog')?.close();
|
||||
setMessage('');
|
||||
confirm.hidden = false;
|
||||
cancel.textContent = 'Annuler';
|
||||
dialog.showModal();
|
||||
confirm.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/control/auth', {credentials:'same-origin',cache:'no-store'});
|
||||
if (!response.ok) throw new Error('Vérification administrateur impossible.');
|
||||
const auth = await response.json();
|
||||
if (!auth.authenticated || !auth.csrf) {
|
||||
setMessage('Connecte-toi d’abord en administrateur dans Configuration (/config), puis reviens ici.', true);
|
||||
confirm.hidden = true;
|
||||
cancel.textContent = 'Fermer';
|
||||
const link = document.createElement('a');
|
||||
link.href = '/config';
|
||||
link.textContent = 'Ouvrir Configuration';
|
||||
link.className = 'av-hermes-session-login';
|
||||
const old = dialog.querySelector('.av-hermes-session-login');
|
||||
old?.remove();
|
||||
msg.after(link);
|
||||
} else {
|
||||
dialog.querySelector('.av-hermes-session-login')?.remove();
|
||||
csrf = auth.csrf;
|
||||
confirm.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
setMessage(e.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
cancel.addEventListener('click', () => { if (!busy) dialog.close(); });
|
||||
confirm.addEventListener('click', async () => {
|
||||
if (busy || !csrf) return;
|
||||
busy = true;
|
||||
confirm.disabled = true;
|
||||
cancel.disabled = true;
|
||||
setMessage('Création de la nouvelle session…');
|
||||
try {
|
||||
const response = await fetch('/api/control/hermes/new-session', {
|
||||
method:'POST', credentials:'same-origin', cache:'no-store',
|
||||
headers: {'X-Alfred-CSRF':csrf,'Content-Type':'application/json'},
|
||||
body:'{}'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.error || 'Erreur HTTP '+response.status);
|
||||
setMessage(data.message || 'Nouvelle session prête.');
|
||||
confirm.hidden = true;
|
||||
cancel.textContent = 'Fermer';
|
||||
} catch (e) {
|
||||
setMessage(e.message, true);
|
||||
} finally {
|
||||
busy = false;
|
||||
cancel.disabled = false;
|
||||
confirm.disabled = false;
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (setup()) return;
|
||||
const observer = new MutationObserver(() => { if (setup()) observer.disconnect(); });
|
||||
observer.observe(document.body,{childList:true,subtree:true});
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, {once:true});
|
||||
else init();
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Add a maintenance link without changing any existing dashboard handler. */
|
||||
(() => {
|
||||
'use strict';
|
||||
function run(){
|
||||
const grid=document.querySelector('#avMoreDialog .av-more-grid');
|
||||
if(grid&&!grid.querySelector('a[href="/maintenance"]')){
|
||||
const link=document.createElement('a');link.href='/maintenance';link.textContent='Maintenance & développement';grid.append(link);
|
||||
}
|
||||
for(const nav of document.querySelectorAll('header nav')){
|
||||
if(nav.querySelector('a[href="/maintenance"]'))continue;
|
||||
const a=document.createElement('a');a.href='/maintenance';a.textContent='Maintenance';nav.append(a);
|
||||
}
|
||||
}
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',run,{once:true});else run();
|
||||
})();
|
||||
@@ -0,0 +1,51 @@
|
||||
/* ALFRED_REPONSE_CLOSE_V1 — Boutons visibles dans la scène, sans modifier Hermes. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const stage = document.getElementById('stage');
|
||||
const main = document.getElementById('alfredMainV1');
|
||||
const returnButton = document.getElementById('avBack');
|
||||
if (!stage || !main || !returnButton) {
|
||||
console.warn('[Dona] Fermeture réponse : éléments Glass V2 manquants.');
|
||||
return;
|
||||
}
|
||||
if (stage.dataset.alfredReplyCloseV1 === 'ready') return;
|
||||
stage.dataset.alfredReplyCloseV1 = 'ready';
|
||||
|
||||
// La V1 crée le bouton « Retour à l’accueil » DANS #alfredMainV1 (z-index 1),
|
||||
// alors que la scène #stage est un frère au-dessus (z-index 8).
|
||||
// On déplace le bouton D'ORIGINE : son écouteur de clic et son id restent intacts.
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'av-reponse-actions';
|
||||
bar.setAttribute('role', 'group');
|
||||
bar.setAttribute('aria-label', 'Navigation dans la réponse');
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'av-reponse-close';
|
||||
closeButton.setAttribute('aria-label', 'Fermer la réponse et revenir à l’accueil');
|
||||
closeButton.title = 'Fermer la réponse';
|
||||
closeButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M5 5l14 14M19 5 5 19"/></svg>';
|
||||
|
||||
returnButton.classList.add('av-reponse-home');
|
||||
returnButton.removeAttribute('style');
|
||||
returnButton.title = 'Fermer cette réponse et revenir à l’accueil';
|
||||
bar.append(returnButton, closeButton);
|
||||
stage.insertBefore(bar, stage.firstChild);
|
||||
|
||||
const goHome = () => {
|
||||
// Utilise le gestionnaire déjà attaché à #avBack par Glass V1 :
|
||||
// focusScene(false) + éventuel AlfredViews.goHome().
|
||||
returnButton.click();
|
||||
};
|
||||
closeButton.addEventListener('click', goHome);
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
// Les boîtes de dialogue (caméra, ComfyUI, etc.) gardent leur propre Escape.
|
||||
if (document.querySelector('dialog[open], [role="dialog"][aria-modal="true"]')) return;
|
||||
if (main.classList.contains('is-focused')) {
|
||||
event.preventDefault();
|
||||
goHome();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,28 @@
|
||||
/* ALFRED_STT_INLINE_GLASS_V1 — deplacement de la transcription existante, sans dupliquer les elements. */
|
||||
(() => {
|
||||
'use strict';
|
||||
function mount() {
|
||||
const hero = document.querySelector('#alfredMainV1 .av-voice-hero');
|
||||
const panel = document.querySelector('.transcript-panel');
|
||||
if (!hero || !panel) return false;
|
||||
if (panel.classList.contains('av-stt-inline')) return true;
|
||||
|
||||
// alfred.js et les autres extensions gardent les memes noeuds et les memes IDs.
|
||||
// Les mises a jour Vosk/Whisper restent donc fonctionnelles sans nouveau listener.
|
||||
hero.insertBefore(panel, hero.firstChild);
|
||||
panel.classList.add('av-stt-inline');
|
||||
hero.classList.add('av-stt-inline-on');
|
||||
panel.setAttribute('aria-label', 'Transcription vocale en direct');
|
||||
|
||||
// Taille du canvas recalculee par le gestionnaire actuel en cas de resize.
|
||||
window.requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
return true;
|
||||
}
|
||||
if (!mount()) {
|
||||
// Les scripts Glass sont generalement synchrones, mais on gere un chargement differe.
|
||||
let count = 0;
|
||||
const interval = window.setInterval(() => {
|
||||
if (mount() || ++count >= 16) window.clearInterval(interval);
|
||||
}, 300);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,140 @@
|
||||
/* DONA_HOLOGRAPHIC_V1
|
||||
* Cartes contextuelles issues du TEXTE réellement reçu d'Hermes.
|
||||
* Aucune donnée, état de serveur, distance ou durée n'est calculé/inventé.
|
||||
* Les autres vues (images, calendriers, cartes de renderer) restent inchangées.
|
||||
*/
|
||||
(() => {
|
||||
'use strict';
|
||||
const words = Object.freeze({zero:0,un:1,une:1,deux:2,trois:3,quatre:4,cinq:5,six:6,sept:7,huit:8,neuf:9,dix:10,onze:11,douze:12,treize:13,quatorze:14,quinze:15,seize:16,dixsept:17,dixhuit:18,dixneuf:19,vingt:20});
|
||||
function numberValue(s){
|
||||
if(!s)return null;
|
||||
const normalized=String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/[\s-]/g,'');
|
||||
if(Object.prototype.hasOwnProperty.call(words,normalized))return String(words[normalized]);
|
||||
const str=String(s).trim();return /^\d{1,5}$/.test(str)?str:null;
|
||||
}
|
||||
const number='(\\d{1,5}|vingt|dix[- ]?neuf|dix[- ]?huit|dix[- ]?sept|seize|quinze|quatorze|treize|douze|onze|dix|neuf|huit|sept|six|cinq|quatre|trois|deux|une|un)';
|
||||
function getFacts(input) {
|
||||
const text=String(input||'').replace(/\u00a0/g,' ').slice(0,16000);
|
||||
const facts=[];const seen=new Set();
|
||||
function add(key,icon,label,value,note='Mentionné dans la réponse'){
|
||||
if(!value||seen.has(key)||facts.length>=4)return;
|
||||
facts.push({key,icon,label,value:String(value),note});seen.add(key);
|
||||
}
|
||||
let m;
|
||||
m=text.match(new RegExp('\\b'+number+'\\s+(?:machines?\\s+virtuelles?|VMs?\\b)','i'));
|
||||
if(m)add('vm','⬡','Machines virtuelles',numberValue(m[1]),'Chiffre annoncé par Hermes');
|
||||
m=text.match(new RegExp('\\b'+number+'\\s+(?:n[œo]uds?|nodes?)(?:\\s|\\.|,|$)','i'));
|
||||
if(m)add('nodes','▦','Nœuds mentionnés',numberValue(m[1]),'Chiffre annoncé par Hermes');
|
||||
m=text.match(/\b(\d{1,3})\s*\/\s*(\d{1,3})\s*(?:n[œo]uds|serveurs|nodes?)\b/i);
|
||||
if(m)add('online','●','Nœuds disponibles',m[1]+' / '+m[2],'Valeur citée par Hermes');
|
||||
const route=/\b(trajet|distance|route|entre\s+\S+\s+et\s+\S+|autoroute|en voiture)\b/i.test(text);
|
||||
const building=/\b(hauteur|haut(?:e|s)?|b[aâ]timent|immeuble|tour|monument|construction)\b/i.test(text);
|
||||
if(route){
|
||||
m=text.match(/\b(\d{1,5}(?:[,.]\d+)?)\s*(kilom[eè]tres?|km)\b/i);
|
||||
if(m)add('distance','↗','Distance indiquée',m[1].replace(',', '.')+' km','Distance issue de la réponse');
|
||||
m=text.match(/\b(\d{1,3})\s*(?:à|a|–|-)\s*(\d{1,3})\s*(minutes?|min|heures?|h)\b/i);
|
||||
if(m)add('duration','◷','Durée annoncée',m[1]+' à '+m[2]+' '+m[3],'Estimation communiquée par Hermes');
|
||||
else {
|
||||
m=text.match(/\b(\d{1,3})\s*(minutes?|min|heures?|h)\b/i);
|
||||
if(m)add('duration','◷','Durée annoncée',m[1]+' '+m[2],'Estimation communiquée par Hermes');
|
||||
}
|
||||
}
|
||||
if(building&&!route){
|
||||
m=text.match(/\b(\d{1,5}(?:[,.]\d+)?)\s*(?:m[eè]tres?|m)\b/i);
|
||||
if(m)add('height','♜','Hauteur indiquée',m[1]+' m','Valeur issue de la réponse');
|
||||
}
|
||||
if(/\b(temp[eé]rature|m[eé]t[eé]o|degr[eé]s)\b/i.test(text)){
|
||||
m=text.match(/(-?\d{1,3}(?:[,.]\d+)?)\s*°\s*C\b/i);
|
||||
if(m)add('temp','☀','Température citée',m[1]+' °C','Valeur issue de la réponse');
|
||||
}
|
||||
// Ne jamais induire qu'un nœud est en ligne à partir du seul nom.
|
||||
if(facts.length<4){
|
||||
m=text.match(/[«"']\s*([\wÀ-ÿ][\wÀ-ÿ_.-]{1,39})\s*[»"'][^.!?\n]{0,90}(arr[eê]t[eé]e?|[eé]teint(?:e)?)(?=[\s,.!?]|$)/i);
|
||||
if(m)add('stopped','⏻','Équipement signalé arrêté',m[1],'État décrit dans la réponse');
|
||||
}
|
||||
if(facts.length===0){
|
||||
m=text.match(/\b(?:il est(?: actuellement)?|heure(?: locale)?\s*[::])\s*(\d{1,2}[:h]\d{2})\b/i);
|
||||
if(m)add('time','◷','Heure indiquée',m[1].replace('h',':'),'Issue de la réponse, non vérifiée ici');
|
||||
}
|
||||
const names=[];
|
||||
const re=/(?:n[œo]ud|node)\s*['"«]?([\wÀ-ÿ_.-]{2,35})['"»]?\s*:/gi;
|
||||
while((m=re.exec(text))&&names.length<10){if(!names.includes(m[1])) names.push(m[1]);}
|
||||
return {facts,names};
|
||||
}
|
||||
function summaryOf(text,title){
|
||||
const clean=String(text||'').trim();const t=String(title||'').trim();
|
||||
const first=clean.split(/(?<=[.!?])\s+|\n{2,}/)[0]?.trim()||clean;
|
||||
const headline=t && !/^(r[eé]ponse|hermes|texte)$/i.test(t)?t:first;
|
||||
const truncated=headline.length>170?headline.slice(0,167).replace(/\s+\S*$/,'')+'…':headline;
|
||||
let excerpt=clean;
|
||||
if(clean.startsWith(first)&&first.length>35)excerpt=clean.slice(first.length).replace(/^\s+/,'');
|
||||
if(!excerpt)excerpt=clean;
|
||||
if(excerpt.length>290)excerpt=excerpt.slice(0,287).replace(/\s+\S*$/,'')+'…';
|
||||
return {headline:truncated,excerpt};
|
||||
}
|
||||
if(typeof module==='object'&&module.exports){module.exports={getFacts,summaryOf,numberValue};}
|
||||
if(typeof document==='undefined')return;
|
||||
const body=document.body;
|
||||
if(!body||body.dataset.donaHoloV1==='active')return;
|
||||
body.dataset.donaHoloV1='active';body.classList.add('dona-holo-v1');
|
||||
const main=document.getElementById('alfredMainV1');
|
||||
const content=document.getElementById('content');
|
||||
if(!main||!content)return; // Pages secondaires : seulement le thème transversal.
|
||||
function el(tag,className,txt){const node=document.createElement(tag);if(className)node.className=className;if(txt!==undefined)node.textContent=String(txt);return node;}
|
||||
let busy=false;let original='';
|
||||
function renderCards(){
|
||||
if(busy || content.firstElementChild?.classList.contains('dh-response'))return;
|
||||
if(content.querySelector('.cards, img.hero, video, canvas, iframe'))return;
|
||||
// N'afficher les cartes que pour une réponse réellement ouverte par Alfred.
|
||||
if(!main.classList.contains('is-focused'))return;
|
||||
const children=[...content.children];
|
||||
if(!children.length)return;
|
||||
const text=children.filter(n=>n.matches('h1,h2,p,pre')).map(n=>n.textContent.trim()).filter(Boolean).join('\n\n').trim();
|
||||
if(!text || text.length<13)return;
|
||||
if(/^(bonjour\.|à votre écoute\.|en attente|chargement)/i.test(text))return;
|
||||
if(text===original && content.dataset.holoTransformed==='done')return;
|
||||
const title=content.querySelector('h1,h2')?.textContent||'';
|
||||
const raw=(title && text.startsWith(title+'\n\n'))?text.slice(title.length+2):text;
|
||||
const {facts,names}=getFacts(text);
|
||||
const summary=summaryOf(raw,title);
|
||||
const wrap=el('section','dh-response');
|
||||
wrap.setAttribute('aria-label','Synthèse contextuelle de la réponse Hermes');
|
||||
const primary=el('article','dh-primary');
|
||||
primary.append(el('div','dh-kicker','RÉPONSE · DONA'),el('div','dh-headline',summary.headline));
|
||||
if(summary.excerpt && summary.excerpt!==summary.headline)primary.append(el('div','dh-excerpt',summary.excerpt));
|
||||
else primary.append(el('div','dh-excerpt','Réponse disponible dans son intégralité.'));
|
||||
const more=el('button','dh-more');more.type='button';more.setAttribute('aria-expanded','false');more.append(el('span','', '▤ Afficher la réponse complète'),el('span','','›'));
|
||||
primary.append(more);wrap.append(primary);
|
||||
if(facts.length||names.length){
|
||||
const grid=el('div','dh-facts');
|
||||
facts.forEach(f=>{
|
||||
const c=el('article','dh-fact');
|
||||
c.append(el('span','dh-fact-icon',f.icon),el('div','dh-fact-label',f.label),el('strong','dh-fact-value',f.value),el('div','dh-fact-note',f.note));
|
||||
grid.append(c);
|
||||
});
|
||||
if(names.length && grid.children.length<4){
|
||||
const c=el('article','dh-fact');c.append(el('span','dh-fact-icon','⬡'),el('div','dh-fact-label','Nœuds cités'));
|
||||
const tags=el('div','dh-tags');names.forEach(n=>tags.append(el('span','dh-tag',n)));c.append(tags,el('div','dh-fact-note','Noms présents dans la réponse'));grid.append(c);
|
||||
}
|
||||
wrap.append(grid);
|
||||
} else {
|
||||
wrap.classList.add('dh-no-facts');
|
||||
}
|
||||
const full=el('section','dh-full');full.hidden=true;full.id='dhFullReply';
|
||||
full.append(el('h3','', 'Réponse complète d’Hermes'),el('div','dh-full-text',text),el('p','dh-source','Affichage fidèle au texte reçu. Les faits n’ont pas été vérifiés séparément.'));
|
||||
more.setAttribute('aria-controls','dhFullReply');
|
||||
more.addEventListener('click',()=>{
|
||||
const opened=full.hidden;full.hidden=!opened;more.setAttribute('aria-expanded',String(opened));
|
||||
more.firstElementChild.textContent=opened?'▤ Masquer la réponse complète':'▤ Afficher la réponse complète';
|
||||
more.lastElementChild.textContent=opened?'⌃':'›';
|
||||
});
|
||||
wrap.append(full);
|
||||
busy=true;content.replaceChildren(wrap);content.dataset.holoTransformed='done';original=text;busy=false;
|
||||
}
|
||||
const obs=new MutationObserver(()=>{if(!busy)renderCards();});
|
||||
obs.observe(content,{childList:true});
|
||||
// Accès à l'écran de réponse après mise à jour de la classe focus, sans boucle.
|
||||
main.addEventListener('click',()=>setTimeout(renderCards,0));
|
||||
// Le renderer/socket réactualise #content : la mutation constitue l'événement canonique.
|
||||
setTimeout(renderCards,100);
|
||||
})();
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>Identité · Assistant</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-identity-glass.css?v=1">
|
||||
<script src="/static/js/alfred-identity-glass.js?v=1" defer></script>
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
<header class="id-head">
|
||||
@@ -37,5 +39,8 @@
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<!-- 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>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>Journaux · Alfred</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-logs-v2.css?v=2">
|
||||
<script src="/static/js/alfred-logs-v2.js?v=2" defer></script>
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
<header class="lg-header">
|
||||
@@ -29,5 +31,8 @@
|
||||
</main>
|
||||
<!-- 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,26 @@
|
||||
<!doctype html>
|
||||
<html lang="fr"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow"><title>Maintenance · Dona</title>
|
||||
<link rel="stylesheet" href="/static/css/alfred-developer-glass.css?v=1">
|
||||
<script defer src="/static/js/alfred-developer-glass.js?v=1"></script>
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
</head><body>
|
||||
<header class="dv-head"><a class="dv-brand" href="/"><span class="dv-initial" id="dvInitial">A</span><span><strong id="dvName">Assistant</strong><small>Maintenance & développement</small></span></a>
|
||||
<nav><a href="/">Accueil</a><a href="/config">Configuration</a><a href="/surveillance">Surveillance</a><a href="/logs">Journaux</a><a href="/identity">Identité</a><a href="/maintenance" class="active" aria-current="page">Maintenance</a></nav></header>
|
||||
<main class="dv-main"><div class="dv-intro"><div><p class="dv-eyebrow">HERMES · DÉVELOPPEMENT CONTRÔLÉ</p><h1>Maintenance & modifications</h1><p>Hermes prépare le code dans une copie Git. Tu vérifies et approuves ici avant toute modification en production.</p></div><button id="dvRefresh" type="button">Actualiser</button></div>
|
||||
<section class="dv-panel" id="dvLoginBox" hidden><h2>Accès administrateur</h2><p>Utilise le même mot de passe que Configuration.</p><form id="dvLogin"><input type="password" id="dvPassword" autocomplete="current-password" placeholder="Mot de passe administrateur" required><button type="submit">Connexion</button></form><p id="dvLoginInfo" role="alert"></p></section>
|
||||
<div id="dvApp" class="dv-layout" hidden>
|
||||
<section class="dv-panel dv-list"><div class="dv-section-title"><h2>Propositions</h2><span id="dvCount">—</span></div><p class="dv-muted">Demande à Hermes : « Prépare une modification de ton interface ».</p><div id="dvTasks" role="list" class="dv-tasks"></div></section>
|
||||
<section class="dv-panel dv-detail"><div class="dv-section-title"><h2>Détail & validation</h2><span id="dvState">Sélectionne une proposition</span></div>
|
||||
<div id="dvEmpty" class="dv-empty">Les modifications apparaîtront ici lorsqu'Hermes aura créé une proposition.</div>
|
||||
<div id="dvSelected" hidden><h3 id="dvTitle"></h3><p class="dv-muted" id="dvMeta"></p><h4>Contrôles</h4><div id="dvChecks" class="dv-checks"></div>
|
||||
<h4>Diff des fichiers</h4><pre id="dvDiff" class="dv-diff" tabindex="0"></pre><p class="dv-muted">Le diff peut contenir du code sensible. Ne l'exporte pas publiquement.</p>
|
||||
<div class="dv-actions" id="dvActions"><button class="dv-primary" type="button" id="dvApprove">Approuver la proposition</button><button type="button" id="dvReject">Refuser</button></div>
|
||||
<div class="dv-next" id="dvNext" hidden><strong>Après approbation</strong><p>Le déploiement reste manuel : depuis SSH, lance la commande de déploiement avec l'identifiant affiché. Aucun bouton Web ne modifie le code de production.</p><code id="dvCommand"></code></div>
|
||||
<p id="dvMessage" role="status" aria-live="polite"></p></div>
|
||||
</section>
|
||||
</div><p class="dv-footnote">Sécurité : les contrôles V1 sont statiques (syntaxe Python et JavaScript, HTML, CSS). Ils ne garantissent pas qu'une modification sera fonctionnelle. Déployer uniquement après revue.</p></main>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
|
||||
</body></html>
|
||||
@@ -10,6 +10,8 @@
|
||||
<!-- 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">
|
||||
@@ -68,6 +70,41 @@
|
||||
</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>
|
||||
@@ -76,5 +113,8 @@
|
||||
<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>
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
#status { min-height:1.3em; margin:18px 0 0; color:#a5a9b0; font-size:.86rem; }
|
||||
a { color:#cdd2da; text-decoration:none; }
|
||||
</style>
|
||||
<!-- DONA_HOLO_V1 -->
|
||||
<link rel="stylesheet" href="/static/css/dona-holographic-v1.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
@@ -128,5 +130,6 @@
|
||||
</script>
|
||||
<!-- ALFRED_IDENTITY_GLASS_V1 -->
|
||||
<script src="/static/js/alfred-name-dynamic.js?v=1" defer></script>
|
||||
<script defer src="/static/js/dona-holographic-v1.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -18,6 +18,14 @@
|
||||
<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=1">
|
||||
</head>
|
||||
<body>
|
||||
<main class="alfred-shell">
|
||||
@@ -196,5 +204,11 @@
|
||||
<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-holographic-v1.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<!-- 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="surveillance">
|
||||
<div class="admin-layout">
|
||||
@@ -46,4 +48,7 @@
|
||||
</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,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local administrator-only command-line deployment / rollback.
|
||||
|
||||
This CLI is NOT exposed via MCP or Flask.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0,str(ROOT))
|
||||
from services import alfred_developer_core as dev
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description='Alfred Developer: déploiement local contrôlé')
|
||||
p.add_argument('action',choices=['deploy','rollback'])
|
||||
p.add_argument('id')
|
||||
p.add_argument('--apply',action='store_true',help='Appliquer réellement ; sinon simulation')
|
||||
args=p.parse_args()
|
||||
if args.apply and os.geteuid()!=0:
|
||||
p.error('Seul un administrateur root local peut appliquer un changement.')
|
||||
try:
|
||||
if args.action=='deploy':out=dev.deploy(args.id,apply=args.apply)
|
||||
else:out=dev.rollback(args.id,apply=args.apply)
|
||||
print(out)
|
||||
if args.apply and out.get('requires_restart'):
|
||||
print('Fichiers Python modifiés : redémarrer alfred.service puis vérifier la santé du service.')
|
||||
elif args.apply:
|
||||
print('Actualise ton navigateur (Ctrl+F5) ; vérifier l’interface avant de conserver le changement.')
|
||||
return 0
|
||||
except (ValueError,OSError,RuntimeError) as e:
|
||||
print('OPÉRATION REFUSÉE :',e,file=sys.stderr);return 1
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
Reference in New Issue
Block a user