- Add x11vnc + noVNC container with Traefik reverse proxy - Add /api/vnc_status and toggle_vnc action to FastAPI - Add VNC toggle button to BotVPS dashboard - VNC off by default, controlled via dashboard
272 lines
10 KiB
Python
272 lines
10 KiB
Python
import os
|
|
import psutil
|
|
import subprocess
|
|
import time
|
|
import json
|
|
import asyncio
|
|
from fastapi import FastAPI, Request, Header, Depends, HTTPException, status, UploadFile, File
|
|
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
|
|
from fastapi.templating import Jinja2Templates
|
|
from dotenv import load_dotenv
|
|
from starlette.concurrency import run_in_threadpool
|
|
import shutil
|
|
import uuid
|
|
import re
|
|
|
|
from ai_agent import query_agent_async
|
|
from audio_handler import transcribe_audio, text_to_speech_async
|
|
from config import get_config, save_config
|
|
from credential_manager import fetch_from_gitea_repo_async
|
|
from orchestrator import (
|
|
orchestrate_async, handle_message_async, get_orchestrator_status,
|
|
get_llm_config, set_llm_config, format_confirmation_message,
|
|
format_completion_message
|
|
)
|
|
from llm_providers import get_available_models
|
|
|
|
load_dotenv()
|
|
|
|
app = FastAPI(title="BotVPS API")
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
# ============================================================
|
|
# STARTUP
|
|
# ============================================================
|
|
@app.on_event("startup")
|
|
async def startup_event():
|
|
print("[INIT] Sincronizando credenciais...")
|
|
await fetch_from_gitea_repo_async(force=True)
|
|
|
|
# --- SEGURANÇA ---
|
|
async def verify_password(x_web_password: str = Header(None)):
|
|
# Autenticação desativada conforme solicitado
|
|
return True
|
|
|
|
@app.get("/api/login")
|
|
async def login_bypass():
|
|
return {"status": "ok", "message": "Autenticação desativada"}
|
|
|
|
# --- WEB UI ---
|
|
@app.get("/", response_class=FileResponse)
|
|
async def read_root(request: Request):
|
|
return FileResponse("templates/index.html")
|
|
|
|
@app.get("/api/status")
|
|
async def get_system_status(is_auth: bool = Depends(verify_password)):
|
|
vm = psutil.virtual_memory()
|
|
return {
|
|
"cpu": psutil.cpu_percent(),
|
|
"ram": {
|
|
"percent": vm.percent,
|
|
"used": round(vm.used / (1024**3), 2),
|
|
"total": round(vm.total / (1024**3), 2)
|
|
},
|
|
"disk": {"percent": psutil.disk_usage('/').percent}
|
|
}
|
|
|
|
# --- CONFIGURAÇÃO GERAL ---
|
|
@app.get("/api/config")
|
|
async def read_config(is_auth: bool = Depends(verify_password)):
|
|
return get_config()
|
|
|
|
@app.post("/api/config")
|
|
async def update_config(cfg: dict, is_auth: bool = Depends(verify_password)):
|
|
save_config(cfg)
|
|
return {"status": "success"}
|
|
|
|
# --- CONFIGURAÇÃO LLM (ORQUESTRADOR) ---
|
|
@app.get("/api/llm-config")
|
|
async def read_llm_config(is_auth: bool = Depends(verify_password)):
|
|
return get_llm_config()
|
|
|
|
@app.post("/api/llm-config")
|
|
async def update_llm_config(cfg: dict, is_auth: bool = Depends(verify_password)):
|
|
set_llm_config(
|
|
planner_provider=cfg.get("planner_provider"),
|
|
planner_model=cfg.get("planner_model"),
|
|
executor_provider=cfg.get("executor_provider"),
|
|
executor_model=cfg.get("executor_model")
|
|
)
|
|
return {"status": "success"}
|
|
|
|
@app.get("/api/llm-models")
|
|
async def list_models(is_auth: bool = Depends(verify_password)):
|
|
return {"models": await get_available_models()}
|
|
|
|
# --- SYNC & ACTIONS ---
|
|
@app.post("/api/sync-credentials")
|
|
async def sync_creds(is_auth: bool = Depends(verify_password)):
|
|
from credential_manager import sync_credentials
|
|
return sync_credentials()
|
|
|
|
@app.post("/api/sync-from-repo")
|
|
async def sync_from_repo(is_auth: bool = Depends(verify_password)):
|
|
await fetch_from_gitea_repo_async(force=True)
|
|
return {"status": "synced"}
|
|
|
|
@app.post("/api/action")
|
|
async def run_action(data: dict, is_auth: bool = Depends(verify_password)):
|
|
action_type = data.get("type")
|
|
if action_type == "ping":
|
|
return {"status": "success", "message": "Pong! Servidor respondendo."}
|
|
if action_type == "restart_bot":
|
|
# Reinicia os serviços relevantes via PM2
|
|
os.system("pm2 restart bridge-telegram")
|
|
return {"status": "success", "message": "Bot do Telegram reiniciado."}
|
|
if action_type == "clear_cache":
|
|
# Limpa caches de sistema e temporários
|
|
os.system("rm -rf /tmp/vps_bot_cache/*")
|
|
os.system("sync; echo 3 > /proc/sys/vm/drop_caches") # Limpa cache de RAM (requer sudo/root)
|
|
return {"status": "success", "message": "Cache do servidor limpo com sucesso."}
|
|
if action_type == "reboot_vps":
|
|
return {"status": "error", "message": "⚠️ Reboot via Web desabilitado por segurança. Use o terminal SSH."}
|
|
if action_type == "toggle_vnc":
|
|
import subprocess
|
|
result = subprocess.run(["docker", "ps", "-q", "-f", "name=vps-vnc"], capture_output=True, text=True)
|
|
if result.stdout.strip():
|
|
subprocess.run(["docker", "stop", "vps-vnc"], capture_output=True)
|
|
return {"status": "success", "message": "VNC desligado.", "vnc_status": "off"}
|
|
else:
|
|
subprocess.run(["docker", "start", "vps-vnc"], capture_output=True)
|
|
return {"status": "success", "message": "VNC ligado. https://vnc.claw.reifonas.cloud/vnc.html", "vnc_status": "on"}
|
|
return {"status": "error", "message": f"Ação {action_type} desconhecida."}
|
|
|
|
@app.get("/api/vnc_status")
|
|
async def vnc_status():
|
|
import subprocess
|
|
result = subprocess.run(["docker", "ps", "-q", "-f", "name=vps-vnc"], capture_output=True, text=True)
|
|
return {"vnc_status": "on" if result.stdout.strip() else "off"}
|
|
|
|
@app.get("/api/test_llm")
|
|
async def test_llm_latency(is_auth: bool = Depends(verify_password)):
|
|
t0 = time.time()
|
|
try:
|
|
reply = await query_agent_async("responda apenas 'pong'")
|
|
latency = round(time.time() - t0, 2)
|
|
return {"status": "success", "latency": latency, "reply": reply}
|
|
except Exception as e:
|
|
return {"status": "error", "message": str(e)}
|
|
|
|
# --- CHAT & ORCHESTRATION ---
|
|
@app.post("/api/chat")
|
|
async def web_chat(message: dict, is_auth: bool = Depends(verify_password)):
|
|
user_text = message.get("text", "")
|
|
history = message.get("history", []) # Extrai o histórico do frontend
|
|
if not user_text: return {"reply": "Vazio."}
|
|
|
|
# Repassa o histórico para manter o contexto da conversa
|
|
reply = await query_agent_async(user_text, chat_history=history)
|
|
return {"reply": reply}
|
|
|
|
@app.post("/api/chat-audio")
|
|
async def web_chat_audio(audio: UploadFile = File(...), is_auth: bool = Depends(verify_password)):
|
|
# 1. Salva o áudio vindo do navegador (/tmp)
|
|
temp_in = f"/tmp/{uuid.uuid4().hex}_{audio.filename}"
|
|
with open(temp_in, "wb") as buffer:
|
|
shutil.copyfileobj(audio.file, buffer)
|
|
|
|
try:
|
|
# 2. Transcreve (STT)
|
|
text = transcribe_audio(temp_in)
|
|
if not text:
|
|
return {"reply": "Não entendi seu áudio.", "text": ""}
|
|
|
|
# 3. Processa na IA
|
|
reply = await query_agent_async(text)
|
|
|
|
# 4. Gera áudio da resposta (TTS)
|
|
# Se houver RESUMO:, usa apenas ele para o áudio. Caso contrário, usa tudo.
|
|
refined_match = re.search(r'RESUMO:\s*(.*)', reply, flags=re.DOTALL | re.IGNORECASE)
|
|
audio_text = refined_match.group(1).strip() if refined_match else reply
|
|
filename = await text_to_speech_async(audio_text)
|
|
|
|
return {
|
|
"text": text,
|
|
"reply": reply,
|
|
"audio_url": f"/api/audio/{filename}"
|
|
}
|
|
except Exception as e:
|
|
return {"reply": f"Erro Áudio: {str(e)}", "text": "Erro."}
|
|
finally:
|
|
if os.path.exists(temp_in): os.remove(temp_in)
|
|
|
|
@app.get("/api/audio/{filename}")
|
|
async def get_audio_file(filename: str):
|
|
path = os.path.join("/tmp", filename)
|
|
if os.path.exists(path):
|
|
return FileResponse(path, media_type="audio/mpeg")
|
|
return JSONResponse({"error": "File not found"}, status_code=404)
|
|
|
|
@app.post("/api/orchestrate")
|
|
async def orchestrate_task(task_data: dict, is_auth: bool = Depends(verify_password)):
|
|
from session_manager import set_orchestrator_pending, clear_orchestrator_pending
|
|
task = task_data.get("task", "")
|
|
confirmed = task_data.get("confirmed", False)
|
|
chat_id = task_data.get("chat_id", 0)
|
|
|
|
# Se confirmou, tenta recuperar o plano pendente para reutilizar
|
|
plan = None
|
|
if confirmed:
|
|
from session_manager import get_orchestrator_pending
|
|
pending_data = get_orchestrator_pending(chat_id)
|
|
if pending_data:
|
|
plan = pending_data.get("plan")
|
|
clear_orchestrator_pending(chat_id)
|
|
|
|
result = await orchestrate_async(task, user_confirmed=confirmed, plan=plan)
|
|
|
|
if result["status"] == "needs_confirmation":
|
|
# Salva o plano pendente para possível confirmação posterior
|
|
if chat_id:
|
|
set_orchestrator_pending(chat_id, {
|
|
"task": task,
|
|
"plan": result.get("plan", {})
|
|
})
|
|
return {
|
|
"status": "needs_confirmation",
|
|
"plan": result["plan"],
|
|
"message": format_confirmation_message(result)
|
|
}
|
|
|
|
return {
|
|
"status": "completed",
|
|
"results": result.get("results", []),
|
|
"message": format_completion_message(result)
|
|
}
|
|
|
|
@app.get("/api/orchestrator-status")
|
|
async def get_orch_status(is_auth: bool = Depends(verify_password)):
|
|
return get_orchestrator_status()
|
|
|
|
@app.post("/api/hermes")
|
|
async def call_hermes_direct(task_data: dict, is_auth: bool = Depends(verify_password)):
|
|
from core_tools import delegate_to_hermes
|
|
task = task_data.get("task", "")
|
|
user_id = task_data.get("user_id", "unknown")
|
|
chat_id = task_data.get("chat_id", 0)
|
|
history = task_data.get("history", [])
|
|
|
|
if not task:
|
|
return {"reply": "Tarefa vazia enviada para o Hermes."}
|
|
|
|
# Contexto pro Hermes saber quem está falando
|
|
user_context = f"[chat_id={chat_id}, user_id={user_id}]"
|
|
if history:
|
|
recent = "; ".join([f"Usuário: {h['user'][:50]} -> Bot: {h['bot'][:50]}" for h in history[-5:]])
|
|
user_context += f"\nHistórico recente: {recent}"
|
|
|
|
full_task = f"{user_context}\n\nTarefa: {task}"
|
|
|
|
try:
|
|
# Roda a tool que faz a chamada sincrona do subprocess em uma thread
|
|
result = await run_in_threadpool(delegate_to_hermes, full_task)
|
|
return {"reply": f"🤖 **Hermes Agent:**\n\n{result}"}
|
|
except Exception as e:
|
|
return {"reply": f"❌ **Erro no Hermes:** {str(e)}"}
|
|
|
|
# --- SERVER ---
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
port = int(os.getenv("PORT", 8001))
|
|
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)
|