v2 - new interface, many new page for fine tuning

This commit is contained in:
pporcheret
2026-10-28 21:45:40 +01:00
parent a6169bcfdb
commit a17c3cb614
89 changed files with 8691 additions and 13 deletions
+11 -1
View File
@@ -445,7 +445,17 @@ def register_admin_console(app, wake, audio_settings):
if weather: _save_env(WEATHER_ENV, weather)
except OSError as exc:
return jsonify(error=f'Impossible d’enregistrer : {exc}'), 500
return jsonify(ok=True, restart_required=True, values=_read_config())
# 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
+209
View File
@@ -0,0 +1,209 @@
"""Alfred dashboard V1 — safe, read-only aggregated statuses + allowed quick actions.
No credentials, IP addresses, entity ids or full HA states are sent to the browser.
The dashboard probes are advisory; MCP success is never inferred from a folder.
"""
from __future__ import annotations
from datetime import datetime
import json
import os
from pathlib import Path
import shutil
import threading
import time
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo
from flask import Blueprint, jsonify, request
_CACHE = {'expires': 0.0, 'value': None}
_LOCK = threading.Lock()
def _json_get(url: str, token: str = '', timeout: float = 2.0):
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
raise ValueError('URL non HTTP(S)')
headers = {'Accept': 'application/json', 'User-Agent': 'Alfred-Dashboard/1'}
if token:
headers['Authorization'] = 'Bearer ' + token
req = Request(url, headers=headers)
with urlopen(req, timeout=timeout) as response:
return json.loads(response.read(15_000_000).decode('utf-8'))
def _stat_local():
disk = shutil.disk_usage(Path(__file__).resolve().parents[1])
percent = round(disk.used * 100 / disk.total) if disk.total else None
mem_pct = None
try:
m = {}
for line in Path('/proc/meminfo').read_text().splitlines():
key, _, value = line.partition(':')
if key in ('MemTotal', 'MemAvailable'):
m[key] = int(value.strip().split()[0])
if m.get('MemTotal'):
mem_pct = round((m['MemTotal'] - m.get('MemAvailable', 0)) * 100 / m['MemTotal'])
except (OSError, ValueError, ZeroDivisionError):
pass
try:
load_1m = round(os.getloadavg()[0], 2)
except (OSError, AttributeError):
load_1m = None
return {'disk_percent': percent, 'memory_percent': mem_pct, 'load_1m': load_1m}
def _safe_float(value):
try:
n = float(value)
return round(n, 1) if abs(n) < 10000 else None
except (TypeError, ValueError):
return None
def _ha_data():
base = os.getenv('HA_URL', '').strip().rstrip('/')
token = os.getenv('HA_TOKEN', '').strip()
if not (base and token):
return {'available': False, 'detail': 'Home Assistant non configuré'}, {'available': False}
try:
states = _json_get(base + '/api/states', token, timeout=2.5)
if not isinstance(states, list):
raise ValueError('États Home Assistant invalides')
except (ValueError, HTTPError, URLError, OSError, TimeoutError):
return {'available': False, 'detail': 'Home Assistant indisponible'}, {'available': False}
lights = sum(x.get('state') == 'on' for x in states if str(x.get('entity_id','')).startswith('light.'))
covers = sum(x.get('state') == 'closed' for x in states if str(x.get('entity_id','')).startswith('cover.'))
t = None
candidates = []
for x in states:
eid = str(x.get('entity_id',''))
attrs = x.get('attributes') or {}
if eid.startswith('climate.'):
v = _safe_float(attrs.get('current_temperature'))
if v is not None: candidates.append((0 if 'salon' in eid.lower() else 2, v))
elif eid.startswith('sensor.') and attrs.get('device_class') == 'temperature':
v = _safe_float(x.get('state'))
if v is not None: candidates.append((1 if 'salon' in eid.lower() else 3, v))
if candidates: t = sorted(candidates, key=lambda x: x[0])[0][1]
condition_map = {
'sunny':'Ensoleillé', 'clear-night':'Ciel dégagé', 'partlycloudy':'Éclaircies',
'cloudy':'Nuageux', 'rainy':'Pluvieux', 'pouring':'Fortes pluies',
'snowy':'Neigeux', 'fog':'Brouillard', 'windy':'Venteux',
'lightning':'Orageux', 'hail':'Grêle', 'exceptional':'Conditions exceptionnelles',
}
weather = {'available': False}
weather_entities = [x for x in states if str(x.get('entity_id','')).startswith('weather.')]
selected_weather = (os.getenv('HA_WEATHER_ENTITY') or os.getenv('WEATHER_ENTITY') or '').strip()
if selected_weather:
weather_entities.sort(key=lambda item: item.get('entity_id') != selected_weather)
if weather_entities:
x = weather_entities[0]
attrs = x.get('attributes') or {}
condition = str(x.get('state') or '')
weather = {
'available': True,
'temperature': _safe_float(attrs.get('temperature')),
'condition': condition_map.get(condition, 'Conditions non précisées'),
'condition_id': condition,
'unit': str(attrs.get('temperature_unit') or '°C')[:6],
}
return {'available': True, 'lights_on': lights, 'covers_closed': covers,
'indoor_temperature': t}, weather
def _probe(url: str):
if not url:
return 'unconfigured'
try:
_json_get(url, timeout=1.8)
return 'ok'
except (ValueError, HTTPError, URLError, OSError, TimeoutError):
return 'offline'
def _services():
ollama = (os.getenv('ALFRED_OLLAMA_URL') or os.getenv('OLLAMA_HOST') or
os.getenv('OLLAMA_URL') or '').strip().rstrip('/')
comfy = (os.getenv('COMFYUI_URL') or os.getenv('ALFRED_COMFYUI_URL') or '').strip().rstrip('/')
hermes = bool(shutil.which('hermes') or Path('/root/.local/bin/hermes').is_file()
or Path('/opt/hermes/venv/bin/hermes').is_file())
return {
'ollama': _probe(ollama + '/api/tags') if ollama else 'unconfigured',
'comfyui': _probe(comfy + '/system_stats') if comfy else 'unconfigured',
# Presence of a binary is not proof of a healthy agent process.
'hermes': 'installed' if hermes else 'unknown',
}
def dashboard_snapshot():
now = time.monotonic()
with _LOCK:
if _CACHE['value'] is not None and now < _CACHE['expires']:
return _CACHE['value']
home, weather = _ha_data()
result = {'home': home, 'weather': weather,
'system': _stat_local(), 'services': _services(),
'updated_at': datetime.now(ZoneInfo('Europe/Paris')).isoformat()}
_CACHE.update(value=result, expires=time.monotonic() + 25)
return result
# Only these preset actions can invoke Hermes from the browser.
_QUICK_ACTIONS = {
'proxmox': 'Vérifie l’état de mes serveurs Proxmox avec les outils dont tu disposes. Si tu ne peux pas y accéder, indique-le clairement, sans inventer de résultat.',
'wazuh': 'Consulte les alertes de sécurité Wazuh récentes avec tes outils. Si l’accès échoue, dis-le clairement et ne fabrique aucune alerte.',
}
def _trusted_local_request():
if request.headers.get('X-Alfred-Request') != '1' or not request.is_json:
return False
origin = request.headers.get('Origin', '')
# Browsers use Origin for JSON POST; allow trusted headless local clients
# only through loopback when Origin is absent.
if origin:
o = urlparse(origin)
return o.scheme in ('http','https') and o.netloc == request.host
return request.remote_addr in ('127.0.0.1', '::1')
def register_dashboard(app, quick_command=None):
bp = Blueprint('alfred_home_visual_v1', __name__)
@bp.get('/api/alfred/dashboard')
def summary():
return jsonify(dashboard_snapshot())
@bp.post('/api/alfred/dashboard/command')
def action():
if not _trusted_local_request():
return jsonify(ok=False, error='Requête non autorisée.'), 403
data = request.get_json(silent=True) or {}
key = data.get('action', '')
if key == 'comfyui':
description = data.get('description', '')
if not isinstance(description, str) or not (4 <= len(description.strip()) <= 450):
return jsonify(ok=False, error='Description attendue (4 à 450 caractères).'), 400
prompt = 'Génère une image avec ComfyUI correspondant à cette description : ' + description.strip()
else:
prompt = _QUICK_ACTIONS.get(key)
if prompt is None:
return jsonify(ok=False, error='Action inconnue.'), 400
if quick_command is None:
return jsonify(ok=False, error='Commande interactive indisponible.'), 503
try:
ok = bool(quick_command(prompt))
except Exception:
return jsonify(ok=False, error='Impossible de soumettre la commande.'), 500
if not ok:
# ALFRED_IDENTITY_GLASS_V1
return jsonify(ok=False, error=os.getenv('ALFRED_NAME', 'Alfred') + ' traite déjà une autre demande.'), 409
return jsonify(ok=True, queued=True), 202
app.register_blueprint(bp)
+194
View File
@@ -0,0 +1,194 @@
"""Options de carte Maison de l'accueil Glass, en conservant l'auth Admin.
Aucune URL arbitraire, commande, ou credentiel stocke dans la configuration.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import re
import secrets
import threading
import time
from urllib.error import URLError, HTTPError
from urllib.request import Request, urlopen
from flask import Blueprint, jsonify, request
from services import admin_console
ROOT = Path(__file__).resolve().parents[1]
SETTINGS = ROOT / 'config' / 'home_card.json'
LOCK = threading.RLock()
_CACHE = {'expires': 0.0, 'states': None}
_CACHE_LOCK = threading.Lock()
DOMAINS = {'light', 'cover', 'sensor', 'binary_sensor', 'switch', 'climate',
'input_boolean', 'person', 'device_tracker', 'lock', 'weather', 'fan'}
DEFAULT_ROWS = [
{'kind': 'lights_on', 'label': 'Lumières allumées'},
{'kind': 'covers_closed', 'label': 'Volets fermés'},
{'kind': 'indoor_temperature', 'label': 'Température intérieure'},
]
PRESETS = {'lights_on', 'covers_closed', 'indoor_temperature'}
def validate_rows(rows):
if not isinstance(rows, list) or not 1 <= len(rows) <= 3:
raise ValueError('Choisir entre 1 et 3 lignes pour le cadre Maison.')
out = []
for row in rows:
if not isinstance(row, dict) or set(row) - {'kind','label','entity_id'}:
raise ValueError('Ligne de configuration invalide.')
kind = row.get('kind')
if kind not in (*PRESETS, 'entity'):
raise ValueError('Type de ligne non autorisé.')
label = row.get('label', '')
if not isinstance(label, str) or not 1 <= len(label.strip()) <= 38 or any(ord(c) < 32 for c in label):
raise ValueError('Chaque intitulé doit contenir 1 à 38 caractères.')
item = {'kind': kind, 'label': label.strip()}
if kind == 'entity':
eid = row.get('entity_id', '')
if not isinstance(eid, str) or len(eid) > 120 or not re.fullmatch(r'[a-z][a-z0-9_]*\.[a-z0-9_]+', eid):
raise ValueError('Identifiant Home Assistant invalide.')
if eid.split('.')[0] not in DOMAINS:
raise ValueError('Domaine Home Assistant non autorisé pour le cadre Maison.')
item['entity_id'] = eid
out.append(item)
return out
def read_rows():
try:
loaded = json.loads(SETTINGS.read_text(encoding='utf-8'))
return validate_rows(loaded['rows'])
except (OSError, ValueError, KeyError, TypeError):
return [dict(row) for row in DEFAULT_ROWS]
def save_rows(rows):
valid = validate_rows(rows)
SETTINGS.parent.mkdir(parents=True, exist_ok=True)
with LOCK:
tmp = SETTINGS.with_name(SETTINGS.name + '.' + secrets.token_hex(6) + '.tmp')
try:
with tmp.open('w', encoding='utf-8') as out:
json.dump({'version': 1, 'rows': valid}, out, ensure_ascii=False, indent=2)
out.write('\n'); out.flush(); os.fsync(out.fileno())
os.chmod(tmp, 0o600)
tmp.replace(SETTINGS)
finally:
tmp.unlink(missing_ok=True)
return valid
def states_from_ha():
now = time.monotonic()
with _CACHE_LOCK:
if _CACHE['states'] is not None and now < _CACHE['expires']:
return _CACHE['states']
base = os.getenv('HA_URL', '').strip().rstrip('/')
token = os.getenv('HA_TOKEN', '').strip()
if not base or not token or not (base.startswith('http://') or base.startswith('https://')):
raise ValueError('Connexion Home Assistant non configurée pour le tableau de bord.')
from services.alfred_dashboard import _json_get
data = _json_get(base + '/api/states', token=token, timeout=3)
if not isinstance(data, list):
raise ValueError('Réponse Home Assistant invalide.')
with _CACHE_LOCK:
_CACHE.update(states=data, expires=time.monotonic() + 20)
return data
def _pretty_state(state, attrs):
if state in ('unknown','unavailable',''):
return 'Indisponible'
value = {'on':'Allumé', 'off':'Éteint', 'open':'Ouvert', 'closed':'Fermé',
'home':'Présent', 'not_home':'Absent', 'locked':'Verrouillé',
'unlocked':'Déverrouillé', 'heat':'Chauffage', 'cool':'Climatisation'}.get(state, state)
unit = str(attrs.get('unit_of_measurement') or '')
if unit and len(unit) <= 12:
return str(value) + ' ' + unit
return str(value)[:42]
def resolve_rows(rows, states, dashboard_home=None):
lookup = {str(s.get('entity_id')): s for s in states if isinstance(s, dict)}
output = []
for item in rows:
kind, label = item['kind'], item['label']
value = 'Indisponible'
if kind == 'lights_on':
value = str(sum(e.get('state') == 'on' for eid,e in lookup.items() if eid.startswith('light.')))
elif kind == 'covers_closed':
value = str(sum(e.get('state') == 'closed' for eid,e in lookup.items() if eid.startswith('cover.')))
elif kind == 'indoor_temperature':
candidates=[]
for eid,e in lookup.items():
attr=e.get('attributes') or {}
try:
if eid.startswith('climate.'):
t=float(attr.get('current_temperature'))
elif eid.startswith('sensor.') and attr.get('device_class') == 'temperature':
t=float(e.get('state'))
else: continue
if abs(t) < 150: candidates.append((0 if 'salon' in eid else 1,t))
except (TypeError,ValueError): pass
if candidates:
candidates.sort(key=lambda x:x[0]); value=f'{candidates[0][1]:g} °C'
elif kind == 'entity':
e=lookup.get(item['entity_id'])
if e:
value=_pretty_state(str(e.get('state') or ''), e.get('attributes') or {})
output.append({'label':label,'value':value,'available':value!='Indisponible'})
return output
def register_home_glass(app):
bp = Blueprint('alfred_home_glass_v1', __name__)
@bp.get('/api/control/home-card')
@admin_console._require_admin
def get_config():
return jsonify(rows=read_rows())
@bp.post('/api/control/home-card')
@admin_console._require_admin
def set_config():
data=request.get_json(silent=True)
if not isinstance(data,dict) or set(data)!={'rows'}:
return jsonify(error='JSON invalide.'), 400
try:
result=save_rows(data['rows'])
except (ValueError, OSError) as exc:
return jsonify(error=str(exc)), 400
return jsonify(ok=True,rows=result,applied_immediately=True)
@bp.get('/api/control/home-entities')
@admin_console._require_admin
def list_entities():
try:
states=states_from_ha()
except (OSError, ValueError, HTTPError, URLError) as exc:
return jsonify(error='Home Assistant inaccessible ou non configuré.', detail=str(exc)[:150]), 503
options=[]
for entity in states:
eid=entity.get('entity_id','')
if not isinstance(eid,str) or eid.split('.')[0] not in DOMAINS:
continue
attrs=entity.get('attributes') or {}
friendly=str(attrs.get('friendly_name') or eid)[:80]
options.append({'id':eid,'name':friendly})
options.sort(key=lambda x:(x['name'].casefold(),x['id']))
return jsonify(entities=options[:1500])
@bp.get('/api/alfred/home-card')
def live_card():
rows=read_rows()
try:
states=states_from_ha()
values=resolve_rows(rows,states)
except (OSError, ValueError, HTTPError, URLError):
values=[{'label':r['label'],'value':'Indisponible','available':False} for r in rows]
return jsonify(rows=values,available=any(x['available'] for x in values))
app.register_blueprint(bp)
+204
View File
@@ -0,0 +1,204 @@
"""Identity editor and current assistant name, authenticated by Alfred's admin UI.
Identity text stays in config/identity.md. All writes are atomic; dated revisions
are kept separately. Technical names/paths/API identifiers are never renamed.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import threading
from datetime import datetime
from flask import Blueprint, jsonify, render_template, request
from services import admin_console
ROOT = Path(__file__).resolve().parents[1]
CONFIG = ROOT / 'config'
IDENTITY = CONFIG / 'identity.md'
TRACKER = CONFIG / 'identity_name_state.json'
HISTORY = CONFIG / 'identity_history'
LOCK = threading.RLock()
MAX_SIZE = 40_000
ASSISTANT_RE = re.compile(r"(?<![A-Za-z0-9_.-])Alfred(?![A-Za-z0-9_.-])")
def active_name():
name = (os.getenv('ALFRED_NAME') or 'Alfred').strip()
return name if 2 <= len(name) <= 30 else 'Alfred'
def identity_path():
# Avoid an admin Web editor writing outside the identity config directory.
raw = Path(os.getenv('ALFRED_IDENTITY_FILE', str(IDENTITY))).expanduser()
resolved = raw.resolve()
if resolved.parent != CONFIG.resolve() or raw.is_symlink():
raise ValueError('Le fichier ALFRED_IDENTITY_FILE est externe à config/ : édition Web refusée.')
return resolved
def sha(text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def _read_text():
path = identity_path()
return path.read_text(encoding='utf-8') if path.exists() else ''
def _save_atomic(path, content, mode=0o600):
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp = tempfile.mkstemp(dir=str(path.parent), prefix='.identity-', suffix='.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as fh:
fh.write(content)
fh.flush()
os.fsync(fh.fileno())
os.chmod(temp, mode)
os.replace(temp, path)
finally:
if os.path.exists(temp):
os.unlink(temp)
def _revision(text, label):
HISTORY.mkdir(mode=0o700, parents=True, exist_ok=True)
filename = datetime.now().strftime('%Y%m%d_%H%M%S_%f') + '_' + label + '.md'
dest = HISTORY / filename
fd = os.open(dest, os.O_CREAT | os.O_WRONLY | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(text)
def _old_name(text):
if TRACKER.exists():
try:
data = json.loads(TRACKER.read_text(encoding='utf-8'))
value = data.get('name', '')
if isinstance(value, str) and 2 <= len(value) <= 30:
return value
except (OSError, ValueError, TypeError):
pass
# Existing identity files tend to contain "Tu t'appelles Alfred".
match = re.search(r"\bTu\s+t['’]appelles\s+([A-Za-zÀ-ÖØ-öø-ÿ-]{2,30})\b", text, flags=re.I)
if match:
return match.group(1)
return 'Alfred'
def _replace_persona_name(text, old, new):
if old == new:
return text
# Replace proper standalone name only; not ALFRED_NAME, alfred.service,
# MCP_alfred_timers, /opt/alfred etc.
pattern = re.compile(r'(?<![A-Za-z0-9_.-])' + re.escape(old) + r'(?![A-Za-z0-9_-]|\.[A-Za-z0-9_-])')
changed = pattern.sub(lambda _m: new, text)
if old != new and old and old[0].lower() in 'aeiouy' and new and new[0].lower() not in 'aeiouyh':
changed = changed.replace("d'" + new, 'de ' + new).replace('d’' + new, 'de ' + new)
if changed == text:
# No existing self-name found: insert an explicit, visible identity rule.
changed = f'# Identité actuelle\nTu t’appelles {new}.\n\n' + text
return changed
def sync_identity_name(name=None):
"""Called on startup and after a /config rename. Never erases custom prose."""
new = name or active_name()
if not isinstance(new, str) or not 2 <= len(new) <= 30 or any(c in new for c in ('\r', '\n', '\x00', '"', "'", '\\', '$', '`')):
raise ValueError('Nom d’assistant invalide.')
with LOCK:
path = identity_path()
original = _read_text()
if not original.strip():
# No identity installed: don't silently create a new persona.
raise ValueError('Le fichier identity.md est vide ou absent.')
old = _old_name(original)
updated = _replace_persona_name(original, old, new)
if updated != original:
_revision(original, 'avant_nom')
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
_save_atomic(path, updated, mode)
# Keep original metadata if the file text was unchanged; preserve editability.
if not TRACKER.exists() or old != new:
_save_atomic(TRACKER, json.dumps({'name':new}, ensure_ascii=False) + '\n')
return {'changed': updated != original, 'name':new}
def identity_for_hermes(text):
"""Runtime guard: current name wins even if a legacy identity wasn't synced."""
new = active_name()
old = _old_name(text)
content = _replace_persona_name(text, old, new) if text else text
return ('Règle d’identité prioritaire : ton nom actuel est ' + new +
'. Si l’on demande ton nom, réponds « Je m’appelle ' + new + ' ».\n\n' + content).strip()
def register_identity_page(app):
bp = Blueprint('alfred_identity_glass', __name__)
@bp.get('/identity')
@bp.get('/identity/')
def page():
# Never embed identity text or secrets in unauthenticated HTML.
return render_template('alfred_identity_glass.html')
@bp.get('/api/alfred/identity/name')
def name_api():
res = jsonify(name=active_name(), wake_word=os.getenv('WAKE_WORD', 'alfred'))
res.headers['Cache-Control'] = 'no-store'
return res
@bp.get('/api/control/identity')
@admin_console._require_admin
def get_identity():
try:
with LOCK:
content = _read_text()
except (OSError, ValueError) as e:
return jsonify(error=str(e)), 503
res = jsonify(name=active_name(), text=content, revision=sha(content),
file='config/identity.md')
res.headers['Cache-Control'] = 'no-store, private'
return res
@bp.post('/api/control/identity')
@admin_console._require_admin
def save_identity():
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify(error='Données JSON invalides.'), 400
text = data.get('text')
revision = data.get('revision')
if not isinstance(text, str) or not isinstance(revision, str):
return jsonify(error='Texte ou révision manquante.'), 400
if not text.strip() or len(text.encode('utf-8')) > MAX_SIZE or '\x00' in text:
return jsonify(error='L’identité doit être non vide et ne pas dépasser 40 Ko.'), 400
if not re.fullmatch(r'[0-9a-f]{64}', revision):
return jsonify(error='Révision invalide.'), 400
try:
with LOCK:
path = identity_path()
old = _read_text()
if sha(old) != revision:
return jsonify(error='Le fichier a changé depuis ton chargement. Recharge avant d’enregistrer.'), 409
if text != old:
_revision(old, 'avant_edition')
mode = (path.stat().st_mode & 0o777) if path.exists() else 0o600
_save_atomic(path, text, mode)
res = jsonify(ok=True, revision=sha(text), name=active_name(),
updated=text != old, file='config/identity.md')
res.headers['Cache-Control'] = 'no-store, private'
return res
except (OSError, ValueError) as e:
return jsonify(error='Enregistrement impossible : '+str(e)), 500
app.register_blueprint(bp)
# If a renamed assistant was configured before installation, reconcile on startup.
try:
sync_identity_name()
except (OSError, ValueError) as e:
app.logger.warning('Synchronisation identité différée : %s', e)
+249
View File
@@ -0,0 +1,249 @@
"""Alfred Listes V1 — courses et tâches locales, SQLite, commandes vocales sûres.
Aucune dépendance externe. Le serveur MCP n'attend jamais et ne lance jamais de shell.
"""
from __future__ import annotations
from datetime import datetime, timezone
from contextlib import contextmanager
import os
from pathlib import Path
import re
import sqlite3
import threading
import unicodedata
from urllib.parse import urlparse
from flask import Blueprint, jsonify, request
DB_PATH = Path(os.getenv('ALFRED_LISTS_DB', str(Path(__file__).resolve().parents[1] / 'data' / 'alfred_lists.sqlite3')))
LOCK = threading.RLock()
KINDS = {'courses': 'courses', 'course': 'courses', 'taches': 'taches', 'tache': 'taches',
'tasks': 'taches', 'task': 'taches', 'todo': 'taches', 'shopping': 'courses'}
MAX_ITEMS = 500
MAX_LEN = 160
def fold(s):
return ''.join(c for c in unicodedata.normalize('NFKD', s.casefold()) if not unicodedata.combining(c))
def normalize_kind(kind):
if not isinstance(kind, str) or fold(kind.strip()) not in KINDS:
raise ValueError('Liste inconnue : choisir courses ou taches.')
return KINDS[fold(kind.strip())]
def clean_label(label, kind=None):
if not isinstance(label, str):
raise ValueError('Le nom de l’élément doit être un texte.')
label = ' '.join(label.split()).strip(' .!?;,')
if kind == 'courses':
label = re.sub(r"^(?:du\s+|de\s+la\s+|des\s+|de\s+l['’])", '', label, flags=re.I).strip()
if not label or len(label) > MAX_LEN or any(ord(x) < 32 for x in label):
raise ValueError('Nom attendu : de 1 à 160 caractères.')
return label
def _conn():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH), timeout=6)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA busy_timeout=6000')
conn.execute('PRAGMA journal_mode=WAL')
conn.execute('''CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('courses','taches')),
label TEXT NOT NULL,
normalized TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(kind, normalized)
)''')
return conn
@contextmanager
def _db():
with LOCK:
conn = _conn()
try:
with conn:
yield conn
finally:
conn.close()
def _row(row):
return {'id': row['id'], 'kind': row['kind'], 'label': row['label'],
'done': bool(row['done']), 'created_at': row['created_at']}
def list_items(kind=None):
if kind is not None: kind = normalize_kind(kind)
with _db() as conn:
q = ('SELECT id, kind, label, done, created_at FROM items '
+ ('WHERE kind=? ' if kind else '')
+ 'ORDER BY done ASC, id DESC LIMIT 1000')
rows = conn.execute(q, (kind,) if kind else ()).fetchall()
items = [_row(r) for r in rows]
result = {'courses': [], 'taches': []}
for item in items: result[item['kind']].append(item)
return {'ok': True, 'lists': result,
'counts': {k: {'active': sum(not r['done'] for r in v), 'total': len(v)}
for k, v in result.items()}}
def add_item(kind, label):
kind = normalize_kind(kind)
label = clean_label(label, kind)
norm = fold(label)
now = datetime.now(timezone.utc).isoformat()
with _db() as conn:
row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, norm)).fetchone()
if row:
if row['done']:
conn.execute('UPDATE items SET done=0, label=?, updated_at=? WHERE id=?', (label, now, row['id']))
existing = False
else: existing = True
item_id = row['id']
else:
n = conn.execute('SELECT COUNT(*) FROM items WHERE kind=?', (kind,)).fetchone()[0]
if n >= MAX_ITEMS: raise ValueError('Liste pleine (500 éléments maximum).')
item_id = conn.execute('INSERT INTO items (kind,label,normalized,done,created_at,updated_at) VALUES (?,?,?,?,?,?)',
(kind, label, norm, 0, now, now)).lastrowid
existing = False
row = conn.execute('SELECT * FROM items WHERE id=?', (item_id,)).fetchone()
return {'ok': True, 'item': _row(row), 'already_present': existing}
def _resolve(conn, kind, item_id=None, label=None):
kind = normalize_kind(kind)
if item_id is not None:
try: n = int(item_id)
except (ValueError, TypeError): raise ValueError('Identifiant invalide.')
row = conn.execute('SELECT * FROM items WHERE id=? AND kind=?', (n, kind)).fetchone()
elif label is not None:
label = clean_label(label,kind)
row = conn.execute('SELECT * FROM items WHERE kind=? AND normalized=?', (kind, fold(label))).fetchone()
else:
raise ValueError('Indique l’identifiant ou le nom exact de l’élément.')
if row is None: raise ValueError('Élément introuvable dans la liste.')
return row
def change_item(action, kind, item_id=None, label=None):
if action not in ('complete_item', 'reopen_item', 'delete_item'):
raise ValueError('Action inconnue.')
with _db() as conn:
row = _resolve(conn, kind, item_id, label)
if action == 'delete_item':
conn.execute('DELETE FROM items WHERE id=?', (row['id'],))
else:
conn.execute('UPDATE items SET done=?, updated_at=? WHERE id=?',
(int(action == 'complete_item'), datetime.now(timezone.utc).isoformat(), row['id']))
return {'ok': True, 'item': _row(row), 'action': action}
def clear_completed(kind):
kind = normalize_kind(kind)
with _db() as conn:
count = conn.execute('DELETE FROM items WHERE kind=? AND done=1', (kind,)).rowcount
return {'ok': True, 'deleted': count}
def execute(action, data):
if action == 'list_items': return list_items(data.get('kind'))
if action == 'add_item': return add_item(data.get('kind'), data.get('label'))
if action in ('complete_item', 'reopen_item', 'delete_item'):
return change_item(action, data.get('kind'), data.get('id'), data.get('label'))
if action == 'clear_completed': return clear_completed(data.get('kind'))
raise ValueError('Action inconnue.')
# Routes vocales directes pour les cas courants : les LLM ne peuvent pas inventer le succès.
_ADD_RE = re.compile(
r"^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(?P<label>.+?)\s+"
r"(?:a|à|sur|dans)\s+(?:ma|la|mes|notre)\s+liste\s+(?:de\s+)?"
r"(?P<kind>courses?|taches?|tâches?|todo)\s*[.!?]*\s*$", re.I)
_VIEW_RE = re.compile(
r"^\s*(?:(?:affiche|montre|lis|donne|consulte)\s+)?"
r"(?:(?:moi|nous)\s+)?(?:(?:qu['’]est.ce\s+qu['’]il\s+y\s+a\s+(?:dans|sur)\s+)|(?:quelle\s+est\s+))?"
r"(?:ma|la|notre)\s+liste\s+(?:de\s+)?(?P<kind>courses?|taches?|tâches?|todo)\s*[.!?]*\s*$", re.I)
def parse_voice(text):
"""Return (action, kind, label) for unambiguous commands only."""
if not isinstance(text, str): return None
# Case insensitive + accent insensitive, but keep original item spelling.
normalized = fold(text).replace('’', "'")
m = _ADD_RE.match(normalized)
if m:
# Re-extract original label by matching an accent-tolerant prefix/suffix.
orig = re.match(r'^\s*(?:ajoute|rajoute|mets|met|note|inscris|ajouter)\s+(.+?)\s+(?:à|a|sur|dans)\s+(?:ma|la|mes|notre)\s+liste\s+(?:de\s+)?(?:courses?|taches?|tâches?|todo)\s*[.!?]*\s*$',text,re.I)
label = orig.group(1) if orig else m.group('label')
return ('add_item', normalize_kind(m.group('kind')), label)
m = _VIEW_RE.match(normalized)
if m: return ('list_items', normalize_kind(m.group('kind')), None)
return None
def handle_voice(text, notify=None):
parsed = parse_voice(text)
if not parsed: return None
action, kind, label = parsed
result = execute(action, {'kind': kind, 'label': label})
if action == 'add_item':
item = result['item']['label']
dest = 'liste de courses' if kind == 'courses' else 'liste de tâches'
message = (f'{item} est déjà dans ta {dest}.' if result['already_present']
else f"J’ai ajouté {item} à ta {dest}.")
if not result['already_present'] and notify: notify()
return message
active = [x['label'] for x in result['lists'][kind] if not x['done']]
if not active: return 'Ta liste de courses est vide.' if kind == 'courses' else 'Ta liste de tâches est vide.'
category = 'courses' if kind == 'courses' else 'tâches'
readable = ', '.join(active[:12])
return f'Voici ta liste de {category} : {readable}' + (f', et {len(active)-12} autres.' if len(active)>12 else '.')
def _authorized_write():
if not request.is_json: return False
# local MCP path: no remote client may spoof this header
if request.remote_addr in ('127.0.0.1','::1') and request.headers.get('X-Alfred-MCP-Local') == 'lists':
return True
if request.headers.get('X-Alfred-Request') != '1': return False
origin = request.headers.get('Origin', '')
if origin:
u = urlparse(origin)
return u.scheme in ('http','https') and u.netloc == request.host
return request.remote_addr in ('127.0.0.1','::1')
def register_lists(app, socketio):
bp = Blueprint('alfred_lists_v1', __name__)
@bp.get('/api/alfred/lists')
def get_lists():
try: return jsonify(list_items())
except (OSError, sqlite3.Error) as exc:
return jsonify(ok=False, error='Stockage listes indisponible.'), 503
@bp.post('/api/alfred/lists/action')
def action_lists():
if not _authorized_write(): return jsonify(ok=False, error='Requête non autorisée.'), 403
data = request.get_json(silent=True)
if not isinstance(data, dict): return jsonify(ok=False, error='Objet JSON attendu.'), 400
action = data.get('action')
try:
result = execute(action, data)
except ValueError as exc:
return jsonify(ok=False, error=str(exc)), 400
except (sqlite3.Error, OSError) as exc:
return jsonify(ok=False, error='Stockage listes indisponible.'), 503
if action != 'list_items' and not result.get('already_present'):
socketio.emit('alfred_lists_changed', {'kind': data.get('kind'), 'action': action})
return jsonify(result)
app.register_blueprint(bp)
+115
View File
@@ -0,0 +1,115 @@
"""Journal admin Alfred Glass V2 : lecture seule du journal systemd d'Alfred.
Les donnees sont protegees par la meme session admin que /config.
Les sorties Hermes independantes ne sont PAS inventees/aggregees artificiellement.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
import re
import subprocess
from flask import Blueprint, jsonify, render_template, request
from services import admin_console
# Redaction defensive : le journal applicatif peut contenir des erreurs
# avec des secrets. Aucune methode ne peut promettre une suppression parfaite.
_SECRET = re.compile(
r'(?i)\b((?:PROXMOX_TOKEN_VALUE|HA_TOKEN|OPENAI_API_KEY|API_KEY|PASSWORD|SECRET|'
r'ACCESS_TOKEN|REFRESH_TOKEN|TOKEN|AUTHORIZATION|API[-_]?KEY|BEARER)\s*[:=]\s*)'
r'(?:["\']?[^\s,;"\']+["\']?)'
)
_BEARER = re.compile(r'(?i)\bBearer\s+[a-z0-9._~+/=-]{8,}')
_URI_SECRET = re.compile(r'(?i)([?&](?:token|key|api_key|password|secret)=)[^\s&#]+')
def redact(message: str) -> str:
result = str(message or '')[:2200]
result = _SECRET.sub(r'\1[MASQUÉ]', result)
result = _BEARER.sub('Bearer [MASQUÉ]', result)
result = _URI_SECRET.sub(r'\1[MASQUÉ]', result)
return result
def component(message: str) -> str:
upper = message.upper()
if any(k in upper for k in ('[ALFRED STT]', 'WHISPER', 'VOSK', 'WAKE WORD', 'WAKEWORD', 'TRANSCRIPTION')):
return 'Vocal'
if any(k in upper for k in ('[ALFRED PIPER]', '[ALFRED TTS]', 'WYOMING PIPER', 'SPEAK_ASYNC')):
return 'Piper'
if any(k in upper for k in ('MCP_', 'MCP ', 'TOOL CALL', 'LIST_NODES', 'CREATE_TIMER', 'HA_LIST')):
return 'MCP'
if any(k in upper for k in ('[ALFRED] ENVOI À HERMES', '[ALFRED] RÉPONSE HERMES', 'HERMES', 'AGENT')):
return 'Hermes'
return 'Système'
def level(message: str, priority: str) -> str:
if priority and priority.isdigit() and int(priority) <= 3:
return 'Erreur'
upper = message.upper()
if 'TRACEBACK' in upper or 'EXCEPTION' in upper or '[ERROR]' in upper or ' ERREUR ' in upper:
return 'Erreur'
if priority and priority.isdigit() and int(priority) == 4 or 'WARNING' in upper or 'ATTENTION' in upper:
return 'Avertissement'
return 'Information'
def read_journal(limit: int = 220):
cmd = ['journalctl', '--no-pager', '--quiet', '--output=json',
'--unit=alfred.service', '--lines='+str(limit)]
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=7, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
raise RuntimeError('Lecture du journal indisponible : '+type(exc).__name__) from exc
if result.returncode != 0:
raise RuntimeError('Lecture du journal refusée. Vérifier les droits systemd.')
items = []
for raw in result.stdout.splitlines()[-limit:]:
try:
record = json.loads(raw)
msg = str(record.get('MESSAGE') or '')
if not msg:
continue
# Certaines traces Flask contiennent des en-tetes d'authentification : prudence.
micros = str(record.get('__REALTIME_TIMESTAMP') or '0')
iso = datetime.fromtimestamp(int(micros)/1_000_000,timezone.utc).isoformat()
items.append({'at': iso, 'component':component(msg),
'level':level(msg,str(record.get('PRIORITY') or '')),
'message':redact(msg)})
except (TypeError, ValueError, OverflowError, json.JSONDecodeError, OSError):
continue
return items
def register_logs_page(app):
bp = Blueprint('alfred_glass_logs_v2', __name__)
@bp.get('/logs')
@bp.get('/logs/')
def logs_page():
# Le HTML ne contient jamais les journaux; l'API exige la session admin.
return render_template('alfred_logs_v2.html')
@bp.get('/api/alfred/logs')
def logs_api():
if not admin_console._authenticated():
return jsonify(error='Authentification administrateur nécessaire.',
login_url='/config'), 401
try:
limit = min(400,max(20,int(request.args.get('limit',220))))
except (TypeError,ValueError):
return jsonify(error='Limite invalide.'),400
try:
records=read_journal(limit)
except RuntimeError as exc:
return jsonify(error=str(exc)),503
response = jsonify(ok=True,source='journalctl -u alfred.service',items=records,
warning='Lecture du journal Alfred uniquement ; les logs Hermes autonomes peuvent être absents.')
response.headers['Cache-Control'] = 'no-store, private'
return response
app.register_blueprint(bp)
+1 -1
View File
@@ -13,7 +13,7 @@ _CAMERA_LOCK = threading.Lock()
def snapshot(path, index=0):
import cv2
if not _CAMERA_LOCK.acquire(blocking=False):
raise RuntimeError('Caméra déjà utilisée par la prévisualisation Alfred')
raise RuntimeError('Caméra déjà utilisée par la prévisualisation de ' + os.getenv('ALFRED_NAME', 'Alfred')) # ALFRED_IDENTITY_GLASS_V1
try:
cap = cv2.VideoCapture(index)
try:
+4
View File
@@ -62,6 +62,10 @@ def _build_prompt(question: str) -> str:
)
identity = ""
# ALFRED_IDENTITY_GLASS_V1 : garantir le nom actuel dans le contexte vocal.
from services.alfred_identity_page import identity_for_hermes as _identity_for_hermes
identity = _identity_for_hermes(identity)
question = str(question or "").strip()
if not identity: