v2.2 - Small improvement Interface change
This commit is contained in:
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",
|
||||
|
||||
Reference in New Issue
Block a user