160 lines
8.0 KiB
Python
160 lines
8.0 KiB
Python
"""Impostazioni dell'app: file JSON in config/, chiavi API nel portachiavi di sistema."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
CONFIG_DIR = BASE_DIR / "config"
|
|
SETTINGS_FILE = CONFIG_DIR / "settings.json"
|
|
DATA_DIR = BASE_DIR / "data"
|
|
KEYRING_SERVICE = "AvatarPy"
|
|
|
|
DEFAULTS: dict[str, Any] = {
|
|
"provider": "anthropic", # anthropic | local | claudecode | mlx
|
|
"effort": "medium", # low | medium | high
|
|
"local_base_url": "http://localhost:8000/v1",
|
|
"local_model": "",
|
|
"mlx_model": "mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit", # motore interno (mlx-lm), repo Hugging Face o cartella
|
|
"mlx_thinking": "auto", # ragionamento del modello interno: auto (secondo il modello) | on | off
|
|
"search_api_key": "",
|
|
"claudecode_model": "sonnet",
|
|
"claudecode_access": "chat", # chat | read | full
|
|
"claudecode_config_dir": "",
|
|
"claudecode_path": "",
|
|
"tts_engine": "kokoro", # kokoro | system
|
|
"kokoro_voice": "if_sara",
|
|
"chatterbox_exaggeration": 0.6, # 0.3 sobria … 0.9 molto enfatica
|
|
"chatterbox_cfg": 0.3, # più basso = più veloce e meno aderente al testo
|
|
"voicebox_profile_id": "",
|
|
"voicebox_engine": "qwen", # qwen | chatterbox | chatterbox_turbo | kokoro | luxtts
|
|
"voicebox_instruct": "", # istruzione di stile per Qwen (es. "parla in modo caloroso e calmo")
|
|
"elevenlabs_voice_id": "",
|
|
"elevenlabs_model": "eleven_flash_v2_5", # rapido; eleven_multilingual_v2 = qualità massima
|
|
"elevenlabs_stability": 0.45,
|
|
"elevenlabs_style": 0.3,
|
|
"chatterbox_ref": "preset:femminile", # preset:femminile | preset:maschile | percorso di un wav da imitare
|
|
"system_voice": "", # "" = automatica
|
|
"stt_model": "mlx-community/whisper-small-mlx",
|
|
"vad_threshold": 0.08,
|
|
"avatar_model": "allegra_2", # nome file (senza .glb) in avatar3d/models
|
|
"telegram_api_id": "",
|
|
"whatsapp_live": False, # avvia il ponte WhatsApp all'apertura
|
|
"whatsapp_annuncia": False, # annuncia a voce i messaggi in arrivo
|
|
"monitor_enabled": False, # monitor Mail/WhatsApp/Telegram con avvisi
|
|
"monitor_annuncia": True, # annuncia a voce gli avvisi
|
|
"monitor_intervallo": 60, # secondi tra un controllo e l'altro
|
|
"monitor_regole": "", # cosa merita attenzione (vuoto = regole predefinite)
|
|
"monitor_escludi": "", # parole/frasi (separate da virgola) che escludono un messaggio dagli avvisi
|
|
"server_assist_ssh": "", # utente@host del server ponte (assistenza server), es. root@1.2.3.4
|
|
"server_assist_dir": "/opt/aiserverassistance", # cartella del bot sul server ponte (contiene .env)
|
|
"server_assist_bot": "luzaserver_bot", # username Telegram del bot sysadmin
|
|
"homeassistant_url": "http://homeassistant.local:8123", # Home Assistant (token nel portachiavi)
|
|
"immagini_famiglia": "z-image-turbo", # z-image-turbo | schnell | dev | qwen (comando mflux e passi predefiniti)
|
|
"immagini_modello": "", # repo Hugging Face o cartella; vuoto = predefinito della famiglia
|
|
"abitudini_enabled": True, # distillazione settimanale delle abitudini nella memoria
|
|
"umore_enabled": False, # stima oraria dell'umore dai messaggi scritti dall'utente
|
|
"io_nomi": "Luciano", # come compare l'utente come mittente nelle chat esportate
|
|
"frasi_attesa": True, # "Un attimo che controllo…" se la risposta tarda più di ~1,3 s
|
|
"riserva_cloud": True,
|
|
"mcp_locali": False, # strumenti dei server MCP esterni anche per i modelli locali (prompt molto più lungo) # se il motore locale fallisce, la risposta la dà Claude
|
|
"documenti_enabled": True, # indicizzazione oraria dei documenti per documenti_cerca
|
|
"documenti_cartelle": "~/Documents, ~/Desktop",
|
|
"documenti_escludi": "Progetti2026", # nomi di cartelle da saltare (codice, dati sensibili…)
|
|
"qwen_voce": "luza_voce",
|
|
"qwen_modello": "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-8bit", # oppure ...-Base-bf16 (più lento, qualità piena) # voce clonata (file in data/voices) per il motore Qwen3-TTS
|
|
"ipixel_enabled": False, # pannello LED iPIXEL via Bluetooth
|
|
"ipixel_address": "", # identificativo BLE (rilevato da solo)
|
|
"ipixel_luminosita": 40,
|
|
"ipixel_stati": True, # mostra lo stato di LuZa (penso, token, strumenti) mentre lavora
|
|
"ipixel_notifiche": True, # mostra avvisi e messaggi in arrivo
|
|
"ipixel_orologio_stile": 1,
|
|
"ipixel_musica": True, # titolo del brano Spotify sul pannello a ogni cambio
|
|
"spotify_client_id": "",
|
|
"ipixel_intermezzi_min": 15, # ogni quanti minuti una frase o animazione (0 = mai); non di notte
|
|
"remote_enabled": True, # app web per il telefono (HTTPS sulla rete locale)
|
|
"remote_token": "", # chiave di abbinamento (generata al primo avvio)
|
|
}
|
|
|
|
SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash", "elevenlabs_api_key", "homeassistant_token", "spotify_client_secret")
|
|
|
|
|
|
class Settings:
|
|
def __init__(self) -> None:
|
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
self._data: dict[str, Any] = dict(DEFAULTS)
|
|
try:
|
|
self._data.update(json.loads(SETTINGS_FILE.read_text(encoding="utf-8")))
|
|
except Exception:
|
|
pass
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
return self._data.get(key, DEFAULTS.get(key, default))
|
|
|
|
def set(self, key: str, value: Any) -> None:
|
|
self._data[key] = value
|
|
|
|
def update(self, values: dict[str, Any]) -> None:
|
|
for k, v in values.items():
|
|
if k in SECRET_KEYS:
|
|
self.set_secret(k, str(v))
|
|
else:
|
|
self._data[k] = v
|
|
self.save()
|
|
|
|
def save(self) -> None:
|
|
SETTINGS_FILE.write_text(json.dumps(self._data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
try:
|
|
os.chmod(SETTINGS_FILE, 0o600)
|
|
except OSError:
|
|
pass
|
|
|
|
# ── Segreti ──────────────────────────────────────────────────────────
|
|
def get_secret(self, key: str) -> str:
|
|
env = {"anthropic_api_key": "ANTHROPIC_API_KEY"}.get(key)
|
|
try:
|
|
import keyring
|
|
value = keyring.get_password(KEYRING_SERVICE, key)
|
|
if value:
|
|
return value
|
|
except Exception:
|
|
value = self._data.get(f"_{key}")
|
|
if value:
|
|
return str(value)
|
|
if env and os.environ.get(env):
|
|
return os.environ[env]
|
|
return str(self._data.get(f"_{key}", "") or "")
|
|
|
|
def set_secret(self, key: str, value: str) -> None:
|
|
value = value.strip()
|
|
try:
|
|
import keyring
|
|
if value:
|
|
keyring.set_password(KEYRING_SERVICE, key, value)
|
|
else:
|
|
try:
|
|
keyring.delete_password(KEYRING_SERVICE, key)
|
|
except Exception:
|
|
pass
|
|
self._data.pop(f"_{key}", None)
|
|
except Exception:
|
|
# Portachiavi non disponibile: salva nel file (permessi 600).
|
|
if value:
|
|
self._data[f"_{key}"] = value
|
|
else:
|
|
self._data.pop(f"_{key}", None)
|
|
|
|
@property
|
|
def ready(self) -> bool:
|
|
p = self.get("provider")
|
|
if p == "local":
|
|
return bool(self.get("local_base_url") and self.get("local_model"))
|
|
if p == "claudecode":
|
|
return True
|
|
if p == "mlx":
|
|
return bool(self.get("mlx_model"))
|
|
return bool(self.get_secret("anthropic_api_key"))
|