app/main.py
Rama main · réplica de lectura del repositorio original.
from contextlib import asynccontextmanager
import asyncio
import hashlib
import json
import logging
logger = logging.getLogger("uvicorn.error")
from pathlib import Path
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from .config import get_settings
from .memory import Memory
from .rag import index_knowledge
from .graph import run_agent
from .tools import full_status_report, infra_live_report, model_card, seeds_report
from .ping import ping_report
from .ops import ops_report
from . import watch as watch_mod
from . import alexa as alexa_mod
memory = Memory()
# Empreinte du répertoire knowledge : l'auto-reload (v1.5.0) réindexe
# automatiquement quand la doc change — plus besoin de penser à /reload.
_knowledge_sig: str = ""
def _knowledge_fingerprint() -> str:
"""Empreinte stable des fichiers markdown de knowledge/ (noms + contenu)."""
digest = hashlib.sha256()
for path in sorted(Path("knowledge").rglob("*.md")):
if path.name.startswith("._"):
continue
digest.update(path.name.encode())
digest.update(path.read_bytes())
return digest.hexdigest()
async def _knowledge_watcher(interval: int = 30) -> None:
"""Compare l'empreinte toutes les 30 s ; réindexe au changement (v1.5.0).
Une erreur ne tue jamais la boucle : le reload reste possible à la main."""
global _knowledge_sig
while True:
await asyncio.sleep(interval)
try:
signature = await asyncio.to_thread(_knowledge_fingerprint)
if _knowledge_sig and signature != _knowledge_sig:
count = await index_knowledge(memory)
logger.info("knowledge/ modifié : réindexation auto de %d extraits", count)
_knowledge_sig = signature
except asyncio.CancelledError:
raise
except Exception:
logger.exception("auto-reload knowledge : échec de ce tour")
_watcher_task: asyncio.Task | None = None
async def _heartbeat_loop(interval: int = 300) -> None:
"""Ping le check Healthchecks de l'API toutes les 5 min (symétrique du bot).
Si l'API meurt, le check passe down en ~5-10 min (interval + grace) —
l'auto-test horaire du bot ne le voit qu'au prochain tour (1 h max).
URL via VEKTOR_HC_API_PING_URL, vide = désactivé."""
import httpx
import os
ping_url = os.environ.get("VEKTOR_HC_API_PING_URL", "").strip()
if not ping_url:
return
while True:
await asyncio.sleep(interval)
try:
async with httpx.AsyncClient(timeout=10) as client:
await client.post(ping_url)
except Exception:
logger.warning("heartbeat API Healthchecks impossible")
@asynccontextmanager
async def lifespan(app: FastAPI):
global _knowledge_sig
await memory.connect()
_knowledge_sig = _knowledge_fingerprint()
await index_knowledge(memory)
_watcher_task = asyncio.create_task(_knowledge_watcher())
heartbeat_task = asyncio.create_task(_heartbeat_loop())
yield
heartbeat_task.cancel()
if _watcher_task:
_watcher_task.cancel()
await memory.close()
app = FastAPI(title="Vektor API", version="0.1.0", lifespan=lifespan)
class ChatRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=128)
channel: str = Field(pattern="^(telegram|alexa|web)$")
text: str = Field(min_length=1, max_length=4000)
conversation_id: str | None = None
class ForgetRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=128)
class ChatResponse(BaseModel):
response: str
conversation_id: str
def require_token(token: str | None):
if token != get_settings().vektor_api_token:
raise HTTPException(status_code=401, detail="Unauthorized")
@app.get("/health")
async def health():
return {"status": "ok", "service": "vektor"}
@app.post("/api/webhook/grafana")
async def grafana_webhook_endpoint(request: Request):
"""Alertes Grafana -> auto-réparation sûre (selfheal).
Sécurité : header X-Vektor-Selfheal obligatoire (secret partagé généré
sur le VPS, jamais commité) — l'endpoint est sinon joignable depuis
Internet via Caddy. L'action reste de toute façon bornée à la whitelist
selfheal avec cooldown anti-tempête."""
import os
expected = os.environ.get("VEKTOR_SELFHEAL_SECRET", "").strip()
provided = request.headers.get("X-Vektor-Selfheal", "")
if not expected or provided != expected:
raise HTTPException(status_code=403, detail="secret selfheal manquant ou invalide")
try:
payload = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="payload JSON invalide")
from .selfheal import handle_grafana_webhook
summary = await handle_grafana_webhook(payload)
return {
"acted": summary["acted"],
"skipped_count": len(summary["skipped"]),
"reports_generated": summary["reports_generated"],
"reports_sent": summary["reports_sent"],
}
@app.post("/api/chat", response_model=ChatResponse)
async def chat(request: ChatRequest, x_vektor_token: str | None = Header(default=None)):
require_token(x_vektor_token)
conversation_id = await memory.ensure_conversation(
request.conversation_id, request.user_id, request.channel
)
await memory.add_message(conversation_id, "user", request.text)
history = await memory.history(conversation_id)
# user_key = périmètre des confirmations d'actions (canal:user)
response = await run_agent(
memory, request.text, history, user_key=f"{request.channel}:{request.user_id}"
)
await memory.add_message(conversation_id, "assistant", response)
return ChatResponse(response=response, conversation_id=str(conversation_id))
@app.post("/api/alexa")
async def alexa_endpoint(request: Request):
body = await request.body()
if len(body) > 512 * 1024:
raise HTTPException(status_code=413, detail="Payload too large")
try:
await alexa_mod.verify_request(
body,
request.headers.get("Signature"),
request.headers.get("SignatureCertChainUrl"),
)
payload = json.loads(body)
alexa_mod.check_timestamp_and_app(payload)
except alexa_mod.AlexaVerificationError as exc:
logger.warning("Requete Alexa rejetee: %s", exc)
raise HTTPException(status_code=400, detail=str(exc))
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="JSON invalide") from exc
request_type = payload.get("request", {}).get("type", "")
session_attrs = payload.get("session", {}).get("attributes", {}) or {}
session_id = payload.get("session", {}).get("sessionId") or "alexa-anon"
user_id = (
payload.get("session", {}).get("user", {}).get("userId")
or payload.get("context", {}).get("System", {}).get("user", {}).get("userId")
or "alexa-anon"
)
conv_id = session_attrs.get("conversation_id")
if request_type in ("LaunchRequest", "SessionEndedRequest"):
if request_type == "SessionEndedRequest":
return alexa_mod.build_response("Au revoir !", True, session_attributes={})
return alexa_mod.build_response(
"Vektor est là ! Demande-moi l'état du homelab, un service, ou pose ta question.",
False,
reprompt="Que veux-tu savoir ?",
session_attributes=session_attrs,
)
if request_type != "IntentRequest":
return alexa_mod.build_response("Je n'ai pas compris ce type de demande.", True)
intent_name = payload.get("request", {}).get("intent", {}).get("name", "")
if intent_name in ("AMAZON.StopIntent", "AMAZON.CancelIntent"):
return alexa_mod.build_response("À bientôt !", True)
if intent_name == "AMAZON.HelpIntent":
return alexa_mod.build_response(
"Demande-moi l'état de Proxmox, vérifie si Jellyfin répond, "
"ou pose une question générale.",
False,
reprompt="Que veux-tu savoir ?",
session_attributes=session_attrs,
)
if intent_name == "AMAZON.FallbackIntent":
return alexa_mod.build_response(
"Je n'ai pas saisi la demande. Essaie par exemple : quel est l'état de Proxmox ?",
False,
reprompt="Que veux-tu savoir ?",
session_attributes=session_attrs,
)
slots = payload.get("request", {}).get("intent", {}).get("slots", {}) or {}
query = (slots.get("query", {}) or {}).get("value", "").strip()
if not query:
return alexa_mod.build_response(
"Quel est le sujet de ta question ?",
False,
reprompt="Pose ta question.",
session_attributes=session_attrs,
)
conversation_id = await memory.ensure_conversation(conv_id, user_id, "alexa")
await memory.add_message(conversation_id, "user", query)
# Alexa : canal vocal -> AUCUNE action d'écriture (pas de confirmation
# orale fiable). Les demandes d'action reçoivent la réponse de refus standard.
live = await infra_live_report(query)
if live:
answer = live
else:
# Le LLM peut prendre 15-60 s : Alexa coupe à ~8 s. On diffuse des
# messages intermédiaires (VoicePlayer.Speak) pour tenir la session.
api_token = (
payload.get("context", {})
.get("System", {})
.get("apiAccessToken", "")
)
request_id = payload.get("request", {}).get("requestId", "")
api_endpoint = payload.get("context", {}).get("System", {}).get("apiEndpoint", "")
progress = asyncio.create_task(
alexa_mod.progressive_loop(api_token, request_id, api_endpoint)
)
try:
history = await memory.history(conversation_id)
answer = await run_agent(memory, query, history)
finally:
progress.cancel()
await memory.add_message(conversation_id, "assistant", answer)
return alexa_mod.build_response(
alexa_mod._clean_for_voice(answer),
False,
reprompt="Autre chose ?",
session_attributes={**session_attrs, "conversation_id": str(conversation_id)},
)
@app.get("/api/model")
async def model_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Fiche technique réelle : modèle, hardware, latence. Aucun appel LLM."""
require_token(x_vektor_token)
return {"card": await model_card()}
@app.get("/api/seeds")
async def seeds_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Rapport seeds : qBittorrent live + staging récupérable. Lecture seule."""
require_token(x_vektor_token)
return {"report": await seeds_report()}
@app.get("/api/qb/status")
async def qb_status_endpoint(x_vektor_token: str | None = Header(default=None)):
"""État qBittorrent + lectures Jellyfin en cours. Lecture seule."""
require_token(x_vektor_token)
from .qbcontrol import jellyfin_playing_count, qb_state
total, paused = await qb_state()
playing = await jellyfin_playing_count()
return {"total": total, "paused": paused, "jellyfin_playing": playing}
@app.get("/api/qb/{verb}/proposal")
async def qb_proposal_endpoint(
verb: str,
x_vektor_token: str | None = Header(default=None),
user_id: str = "default",
channel: str = "telegram",
):
"""Texte de proposition /pause|/resume + enregistrement du registre OUI.
Aucune exécution ici : le « OUI » envoyé ensuite passe par le chemin
normal du chat (graph -> confirm_pending -> canal d'actions SSH)."""
if verb not in ("pause", "resume"):
raise HTTPException(status_code=404, detail="verb inconnu")
require_token(x_vektor_token)
from .qbcontrol import proposal_for
proposal = await proposal_for(verb, f"{channel}:{user_id}")
return {"proposal": proposal}
@app.post("/api/qb/{verb}/cancel")
async def qb_cancel_endpoint(
verb: str,
x_vektor_token: str | None = Header(default=None),
user_id: str = "default",
channel: str = "telegram",
):
"""Annule la proposition en attente (bouton ❌ du bot)."""
require_token(x_vektor_token)
from . import actions
actions._PENDING.pop(f"{channel}:{user_id}", None)
return {"result": "annulée"}
@app.post("/api/qb/{verb}")
async def qb_action_endpoint(
verb: str,
x_vektor_token: str | None = Header(default=None),
user_id: str = "default",
channel: str = "telegram",
):
"""Confirmation pause|resume : n'exécute QUE l'action en attente.
Miroir de confirm_pending (registre partagé avec le chat) : sans
proposition enregistrée au préalable via /proposal, le POST refuse —
pas d'exécution sans la double confirmation."""
if verb not in ("pause", "resume"):
raise HTTPException(status_code=404, detail="verb inconnu")
require_token(x_vektor_token)
from . import actions
user_key = f"{channel}:{user_id}"
pending = actions._PENDING.get(user_key)
if pending is None:
return {"result": f"Aucune action en attente. Envoie /{verb} puis réponds OUI."}
expected = f"qb_{verb}_103"
if pending[0] != expected:
return {
"result": f"Une autre action est en attente ({pending[0]}) — "
"confirme-la dans la conversation ou attends 2 minutes."
}
reply = await actions.confirm_pending("OUI", user_key)
if reply is None:
return {"result": "Confirmation refusée."}
return {"result": reply}
@app.get("/api/snap")
async def snap_endpoint(
ct: str = "103",
x_vektor_token: str | None = Header(default=None),
):
"""Snapshot thin du CT demandé — classe AUTONOME (pas de OUI).
Réversible par construction : le snapshot expire tout seul (7 jours,
sweep PVE horaire). Aucun service n'est interrompu. Le CT est validé
ici pour un message clair ; le script PVE re-valide de toute façon."""
require_token(x_vektor_token)
if ct not in {"101", "102", "103", "104", "105", "106", "107"}:
raise HTTPException(status_code=400, detail="CT inconnu (autorisés : 101-107)")
from . import actions
return {"report": await actions.execute_autonomous(f"lxc_snap_{ct}_pre-update")}
@app.get("/api/snapls")
async def snapls_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Inventaire des snapshots à expiration automatique. Lecture seule."""
require_token(x_vektor_token)
from . import actions
return {"report": await actions.execute_autonomous("lxc_snapls")}
@app.get("/api/ping")
async def ping_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Diagnostic du chemin LLM complet, maillon par maillon. Lecture seule."""
require_token(x_vektor_token)
return {"report": await ping_report()}
@app.post("/api/reload-doc")
async def reload_doc_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Ré-indexe knowledge/ (volume knowledge-live) sans redémarrer.
replace_knowledge fait TRUNCATE + réinsertion en une transaction :
un chat en cours pendant le reload ne voit jamais un index vide."""
require_token(x_vektor_token)
try:
count = await index_knowledge(memory)
except Exception:
logger.exception("reload-doc : réindexation échouée")
raise HTTPException(status_code=500, detail="Réindexation échouée — index précédent conservé")
return {
"report": (
f"🔄 Documentation réindexée : {count} extraits chargés depuis knowledge/.\n"
"Le prochain chat utilisera directement la nouvelle version — aucun redémarrage nécessaire."
)
}
@app.get("/api/status")
async def status(x_vektor_token: str | None = Header(default=None)):
require_token(x_vektor_token)
report = await full_status_report()
return {"report": report}
@app.get("/api/backups")
async def backups_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Derniers backups vzdump vus par l'API Proxmox. Lecture seule."""
require_token(x_vektor_token)
return {"report": await watch_mod.backups_report()}
@app.get("/api/dns")
async def dns_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Sonde multi-transport (UDP wire/DoH) des résolveurs. Lecture seule."""
require_token(x_vektor_token)
return {"report": await watch_mod.dns_report()}
@app.get("/api/monitoring")
async def monitoring_endpoint(x_vektor_token: str | None = Header(default=None)):
"""État des checks Healthchecks.io (token lecture dédié). Lecture seule."""
require_token(x_vektor_token)
return {"report": await watch_mod.monitoring_report()}
@app.get("/api/majlist")
async def majlist_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Inventaire des versions des conteneurs Docker (actuelle vs dispo).
Lecture seule, mais pull les refs d'images : plusieurs minutes possibles."""
require_token(x_vektor_token)
return {"report": await __import__("app.actions", fromlist=["_execute"])._execute("docker_updates_list")}
@app.get("/api/morning")
async def morning_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Rapport matinal narratif (chiffres live, sans LLM)."""
require_token(x_vektor_token)
from .morning import morning_report
return {"report": await morning_report()}
@app.get("/api/ops")
async def ops_endpoint(x_vektor_token: str | None = Header(default=None)):
"""Exploitation de Vektor : version déployée, conteneur, supervision.
Lecture seule, sans LLM — répond même si le chemin d'inférence est cassé."""
require_token(x_vektor_token)
return {"report": await ops_report()}
@app.post("/api/forget")
async def forget(request: ForgetRequest, x_vektor_token: str | None = Header(default=None)):
require_token(x_vektor_token)
deleted = await memory.forget_user(request.user_id)
return {"deleted_conversations": deleted}
# ── Canal web (roadmap V2 #4) ──────────────────────────────────────────
# Le token sert ici de mot de passe client : la page le garde en
# sessionStorage et l'envoie à chaque message. Aucun secret dans le dépôt.
_WEB_PAGE = """<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vektor</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, 'Segoe UI', sans-serif;
background: #0f1420; color: #e8ecf4; display: flex;
flex-direction: column; height: 100dvh; }
header { padding: 14px 18px; border-bottom: 1px solid #232b3d;
font-weight: 600; letter-spacing: .5px; }
header span { color: #6ea8ff; }
#log { flex: 1; overflow-y: auto; padding: 18px; display: flex;
flex-direction: column; gap: 10px; }
.msg { max-width: 78%; padding: 10px 14px; border-radius: 14px;
white-space: pre-wrap; line-height: 1.45; }
.user { align-self: flex-end; background: #2b5fd9; }
.bot { align-self: flex-start; background: #1b2334; }
.err { align-self: flex-start; background: #4a1f24; color: #ffb4b4; }
form { display: flex; gap: 8px; padding: 12px; border-top: 1px solid #232b3d; }
textarea { flex: 1; resize: none; background: #161d2c; color: #e8ecf4;
border: 1px solid #2a3550; border-radius: 10px; padding: 10px;
font: inherit; min-height: 44px; max-height: 140px; }
button { background: #2b5fd9; color: white; border: 0; border-radius: 10px;
padding: 0 18px; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; }
#gate { margin: auto; display: flex; flex-direction: column; gap: 10px;
width: min(360px, 90%); }
#gate input { background: #161d2c; color: #e8ecf4; border: 1px solid #2a3550;
border-radius: 10px; padding: 12px; font: inherit; }
#chips { display: flex; gap: 6px; padding: 8px 12px 0; overflow-x: auto; }
.chip { background: #1b2334; color: #9fb6dd; border: 1px solid #2a3550;
border-radius: 999px; padding: 6px 12px; font-size: 13px;
cursor: pointer; white-space: nowrap; }
.chip:hover { background: #233046; color: #e8ecf4; }
</style>
</head>
<body>
<header>Vektor <span>· canal web</span></header>
<div id="gate">
<p>Entre le token d'accès (variable <code>VEKTOR_API_TOKEN</code> du serveur).</p>
<input id="tok" type="password" placeholder="token" autofocus>
<button onclick="enter()">Entrer</button>
</div>
<div id="log" hidden></div>
<div id="chips" hidden>
<button class="chip" data-cmd="/status" title="Rapport homelab complet">📊 Status</button>
<button class="chip" data-cmd="/dns" title="Sonde des résolveurs DNS">🌐 DNS</button>
<button class="chip" data-cmd="/ping" title="Diagnostic du chemin LLM">🏓 Ping</button>
<button class="chip" data-cmd="/seeds" title="Top seeding qBittorrent">🌱 Seeds</button>
<button class="chip" data-cmd="/backups" title="Derniers backups vzdump">💾 Backups</button>
<button class="chip" data-cmd="/monitoring" title="Checks Healthchecks">🩺 Monitoring</button>
<button class="chip" data-cmd="/ops" title="Exploitation de Vektor (version, conteneur, supervision)">🛰️ Ops</button>
<button class="chip" data-cmd="/model" title="Fiche technique du modèle">🧠 Model</button>
<button class="chip" data-cmd="/matin" title="La nuit en résumé">🌅 Matin</button>
<button class="chip" data-cmd="/pause" title="Pause globale des téléchargements (confirmation OUI)">⏸️ Pause</button>
<button class="chip" data-cmd="/resume" title="Reprise des téléchargements (confirmation OUI)">▶️ Resume</button>
<button class="chip" data-cmd="/snap" title="Snapshot réversible du CT 103 (purge auto 7 jours)">📸 Snap</button>
<button class="chip" data-cmd="/snapls" title="Snapshots à expiration automatique">📋 Snapls</button>
<button class="chip" data-cmd="/majlist" title="Versions des conteneurs : actuelle vs disponible">📦 MajList</button>
</div>
<form id="f" hidden>
<textarea id="t" placeholder="Demande-moi quelque chose… (ou clique un raccourci)"></textarea>
<button id="send">Envoyer</button>
</form>
<script>
const $ = id => document.getElementById(id);
let token = sessionStorage.vektorToken || '';
function show() {
const gate = $('gate');
if (gate) gate.remove();
$('log').hidden = false; $('chips').hidden = false; $('f').hidden = false;
$('t').focus();
}
function enter() {
const field = $('tok');
if (!field) return;
token = field.value.trim();
if (!token) return;
sessionStorage.vektorToken = token;
show();
}
// Les listeners sont attachés AVANT tout retrait du formulaire de
// connexion : avec un token déjà en session, #gate n'existe plus au
// chargement — un listener sur un élément absent tuerait tout le script.
const tokField = $('tok');
if (tokField) tokField.addEventListener('keydown', e => { if (e.key === 'Enter') enter(); });
function bubble(text, cls) {
const d = document.createElement('div');
d.className = 'msg ' + cls;
d.textContent = text;
$('log').appendChild(d);
$('log').scrollTop = $('log').scrollHeight;
return d;
}
async function ask(text) {
bubble(text, 'user');
const waiting = bubble('Vektor réfléchit… (le LLM local peut prendre 1-3 min)', 'bot');
$('send').disabled = true;
try {
const r = await fetch('/api/web/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Vektor-Token': token },
body: JSON.stringify({ text })
});
if (r.status === 401) { waiting.textContent = 'Token refusé — recharge la page.'; return; }
const d = await r.json();
waiting.textContent = d.response || JSON.stringify(d);
// Confirmation en attente ? Ajoute un bouton OUI (même registre que TG).
if (/OUI/.test(d.response || '') && /Confirme/.test(d.response || '')) {
const b = document.createElement('button');
b.textContent = '✅ Confirmer (OUI)';
b.className = 'chip';
b.onclick = () => { b.remove(); ask('OUI'); };
waiting.appendChild(document.createElement('br'));
waiting.appendChild(b);
}
} catch (e) {
waiting.className = 'msg err';
waiting.textContent = 'Erreur : ' + e;
} finally {
$('send').disabled = false; $('t').focus();
}
}
$('f').addEventListener('submit', e => {
e.preventDefault();
const text = $('t').value.trim();
if (!text) return;
$('t').value = '';
ask(text);
});
// Raccourcis : envoient la commande telle quelle (réponse directe côté
// serveur, sans inférence LLM).
document.querySelectorAll('.chip').forEach(button => {
button.addEventListener('click', () => ask(button.dataset.cmd));
});
if (token) show();
</script>
</body>
</html>"""
@app.get("/web", response_class=HTMLResponse)
async def web_page():
"""Mini client web : token en sessionStorage, chat direct vers l'API."""
return _WEB_PAGE
class WebChatRequest(BaseModel):
text: str = Field(min_length=1, max_length=4000)
# Commandes directes du canal web (mêmes rapports que les commandes
# Telegram, sans inférence) : appel paresseux -> monkeypatchable en test.
_WEB_COMMANDS: dict[str, object] = {
"/status": lambda: full_status_report(),
"/dns": lambda: watch_mod.dns_report(),
"/ping": lambda: ping_report(),
"/seeds": lambda: seeds_report(),
"/backups": lambda: watch_mod.backups_report(),
"/monitoring": lambda: watch_mod.monitoring_report(),
"/ops": lambda: ops_report(),
"/model": lambda: model_card(),
"/matin": lambda: __import__("app.morning", fromlist=["morning_report"]).morning_report(),
"/snapls": lambda: __import__("app.actions", fromlist=["execute_autonomous"]).execute_autonomous("lxc_snapls"),
# /snap : snapshot thin CT 103 par défaut — classe autonome (réversible,
# purge auto 7 jours). Aucune confirmation : rien d'irréversible ici.
"/snap": lambda: __import__("app.actions", fromlist=["execute_autonomous"]).execute_autonomous("lxc_snap_103_pre-update"),
# /majlist : inventaire versions (lecture seule) via le canal d'actions —
# pull des refs d'images : jusqu'à quelques minutes.
"/majlist": lambda: __import__("app.actions", fromlist=["_execute"])._execute("docker_updates_list"),
# /diag : diagnostics lecture seule (v1.8.0) — classe autonome, sans
# confirmation, rapport direct nommant le maillon défaillant.
"/diag": lambda: __import__("app.actions", fromlist=["execute_autonomous"]).execute_autonomous("diagnose_pve"),
# /pause et /resume ne sont PAS des commandes directes : ils passent
# par le chemin agent (proposition + confirmation OUI, registre partagé
# avec Telegram). Délibéré : une écriture ne doit jamais être un simple
# rapport.
}
@app.post("/api/web/chat", response_model=ChatResponse)
async def web_chat(request: WebChatRequest, x_vektor_token: str | None = Header(default=None)):
"""Chat depuis la page /web : user_id fixe 'web-local', même agent que Telegram.
Une commande (« /dns »...) renvoie le rapport direct — pas de LLM,
pas de mémoire : même contrat que les commandes Telegram."""
require_token(x_vektor_token)
first_word = request.text.strip().split()[0].lower() if request.text.strip() else ""
command = _WEB_COMMANDS.get(first_word)
if command is not None:
return ChatResponse(response=await command(), conversation_id="commande")
conversation_id = await memory.ensure_conversation(None, "web-local", "web")
await memory.add_message(conversation_id, "user", request.text)
history = await memory.history(conversation_id)
response = await run_agent(memory, request.text, history, user_key="web:web-local")
await memory.add_message(conversation_id, "assistant", response)
return ChatResponse(response=response, conversation_id=str(conversation_id))