🚀 Initial deploy to Gitea with fixes and dashboard enhancements
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
venv/
|
||||||
|
.DS_Store
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Instala dependências do sistema necessárias para áudio (ffmpeg e ALSA) e para o psutil compilar
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
|
gcc \
|
||||||
|
python3-dev \
|
||||||
|
docker.io \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expõe a porta do FastAPI
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Script de inicialização (Roda a API web no background e o Telegram Bot polling)
|
||||||
|
RUN echo '#!/bin/bash\nuvicorn main:app --host 0.0.0.0 --port 8000 & \npython bot_logic.py\n' > start.sh
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
|
||||||
|
CMD ["./start.sh"]
|
||||||
120
ai_agent.py
Normal file
120
ai_agent.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import requests
|
||||||
|
from tools import run_bash_command, get_system_health
|
||||||
|
from config import get_config
|
||||||
|
|
||||||
|
def query_agent(prompt: str, override_provider: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Função principal que roteia pro LLM desejado, detecta intents e aciona as Tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# SYSTEM PROMPT AVANÇADO: O "Ensinamento" da Inteligência Artificial
|
||||||
|
system_prompt = """Você é o [Antigravity VPS Agent], uma Inteligência Artificial autônoma de Dev/SysAdmin operando diretamente em uma máquina Ubuntu Linux do usuário (Marcos).
|
||||||
|
Sua missão é ajudar o Marcos a gerenciar o servidor, criar aplicações, analisar código e orquestrar o Docker. VOCÊ DEVE RESPONDER SEMPRE EM PORTUGUÊS FLUENTE DO BRASIL.
|
||||||
|
|
||||||
|
### SEUS PODERES E ARQUITETURA
|
||||||
|
1. **Nível Root**: Você roda em um Bot Python empacotado via Docker. Este container mapeia o `/var/run/docker.sock`, o que te dá o poder DIVINO sobre tudo. Você pode listar, deletar, parar e recriar qualquer container na máquina hospedeira inteira.
|
||||||
|
2. **Sistema de Arquivos**: O disco principal do servidor está montado de forma segura, o que permite que você leia logs nativos.
|
||||||
|
3. **Mecanismo de Ação (A Interface de Ferramentas)**: Você **não executa os comandos diretamente pela web**. Você possui uma interface conectada ao bot no Telegram e Web. Para interagir com o servidor, sempre que decidir que algo precisa ser lido (ex: `ls -la`, `docker network ls`, `cat arquivo.txt`) ou modificado, responda com a notação: [CMD] o_comando_aqui [/CMD].
|
||||||
|
4. O bot Python varrerá sua resposta via Regex, extrairá o comando que você colocou entre os blocos, abrirá um shell Bash real, e te devolverá na conversa o resultado do seu comando.
|
||||||
|
|
||||||
|
### SEUS LIMITES E REGRAS
|
||||||
|
- **Sem Comandos Interativos**: Jamais use comandos que exijam resposta humana travando o terminal (ex: `nano`, `top -d 1`, `apt-get install` sem `-y`). Se usar, o bash vai dar timeout (60s limit).
|
||||||
|
- **Nunca use systemctl**: Você roda dentro de um container Docker. Logo, o comando `systemctl` NÃO FUNCIONA. Para ver serviços, use invariavelmente o comando `docker ps -a`.
|
||||||
|
- **Aja, Não Explique Demais**: Se o usuário te der uma tarefa como "Veja meus containers", não explique o que é Docker. Apenas devolva [CMD] docker ps -a [/CMD] e diga "Estou olhando agora mesmo."
|
||||||
|
- **Erros**: Se o comando retornar um código 127, 1 ou falha de acesso, aceite o erro e sugira tentar um comando de diagnóstico em seguida.
|
||||||
|
|
||||||
|
Acorde, a VPS é sua para cuidar!"""
|
||||||
|
|
||||||
|
if "logs do nginx" in prompt.lower() or "nginx" in prompt.lower():
|
||||||
|
output = run_bash_command("systemctl status nginx || echo 'Nginx não parece ser gerenciado por systemctl aqui'")
|
||||||
|
return f"Executei a checagem no Nginx. Olha o resultado: \n\n{output}"
|
||||||
|
|
||||||
|
if any(palavra in prompt.lower() for palavra in ["status", "cpu", "saude", "saúde", "sauda", "memória", "disco", "hd"]):
|
||||||
|
health = get_system_health()
|
||||||
|
return f"A saúde atual pontual da sua máquina direta do Python está assim:\n{health}"
|
||||||
|
|
||||||
|
cfg = get_config()
|
||||||
|
provider = override_provider or cfg.get("active_provider", "ollama")
|
||||||
|
|
||||||
|
if provider == "gemini":
|
||||||
|
gemini_api_key = cfg.get("gemini_api_key", "")
|
||||||
|
if not gemini_api_key: return "Chave API do Gemini não configurada."
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={gemini_api_key}"
|
||||||
|
payload = {
|
||||||
|
"contents": [{"parts": [{"text": system_prompt + "\n\nUsuário: " + prompt}]}]
|
||||||
|
}
|
||||||
|
res = requests.post(url, json=payload)
|
||||||
|
if res.status_code == 200:
|
||||||
|
raw_response = res.json()["candidates"][0]["content"]["parts"][0]["text"]
|
||||||
|
else:
|
||||||
|
return f"Erro na API do Gemini: {res.text}"
|
||||||
|
|
||||||
|
# Regex Universal para processar Comandos
|
||||||
|
match = re.search(r"\[.*?CMD\](.*?)\[/.*?CMD\]", raw_response, re.IGNORECASE | re.DOTALL)
|
||||||
|
if match:
|
||||||
|
comando_bash = match.group(1).strip()
|
||||||
|
resultado = run_bash_command(comando_bash)
|
||||||
|
|
||||||
|
# Tradução via Gemini
|
||||||
|
traducao_prompt = f"O comando `{comando_bash}` retornou o seguinte dado: {resultado[:1500]}\nTraduza gentilmente para um formato leigo sem códigos de erro difíceis."
|
||||||
|
t_payload = {"contents": [{"parts": [{"text": system_prompt + "\n\n" + traducao_prompt}]}]}
|
||||||
|
t_res = requests.post(url, json=t_payload)
|
||||||
|
if t_res.status_code == 200:
|
||||||
|
texto_leigo = t_res.json()["candidates"][0]["content"]["parts"][0]["text"]
|
||||||
|
else:
|
||||||
|
texto_leigo = "Falha ao gerar resumo no Gemini."
|
||||||
|
|
||||||
|
return f"🤖 **Comando Técnico (Gemini):** `{comando_bash}`\n\n**🖥️ Log Nativo:**\n```\n{resultado[:1000]}\n```\n\n🧑🏫 **Tradução:**\n{texto_leigo}"
|
||||||
|
|
||||||
|
return raw_response
|
||||||
|
except Exception as e:
|
||||||
|
return f"Falha de Conexão com Gemini Pro: {e}"
|
||||||
|
|
||||||
|
elif provider == "ollama":
|
||||||
|
try:
|
||||||
|
ollama_host = os.getenv("OLLAMA_HOST", "http://ollama-lw4s8g4gc8gss4gkc4gg0wk4:11434")
|
||||||
|
res = requests.post(f"{ollama_host}/api/generate", json={
|
||||||
|
"model": os.getenv("OLLAMA_MODEL", "qwen2.5-coder:1.5b"),
|
||||||
|
"prompt": system_prompt + "\nUsuário: " + prompt,
|
||||||
|
"stream": False
|
||||||
|
})
|
||||||
|
if res.status_code == 200:
|
||||||
|
raw_response = res.json().get("response", "Erro vazio do Ollama")
|
||||||
|
|
||||||
|
# Motor de Tool Calling Tolerante: Detecta [CMD] ou [VCMD] ou variações que LLMs minúsculas inventam
|
||||||
|
match = re.search(r"\[.*?CMD\](.*?)\[/.*?CMD\]", raw_response, re.IGNORECASE | re.DOTALL)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
comando_bash = match.group(1).strip()
|
||||||
|
resultado = run_bash_command(comando_bash)
|
||||||
|
|
||||||
|
# ----------------------------------------------------
|
||||||
|
# NOVA LÓGICA: Tradução Leiga (Segundo Prompt para a IA)
|
||||||
|
# ----------------------------------------------------
|
||||||
|
traducao_prompt = f"O comando `{comando_bash}` retornou a seguinte saída do servidor:\n\n{resultado[:1500]}\n\nTraduza GENTILMENTE essa saída técnica explicando de forma amigável, gentil e em português muito simples (para um não-técnico) o que isso indica. SE a saída for um 'ERRO', acalme o usuário, resuma que um comando técnico falhou e sugira verbalmente o que de forma segura investigar a seguir (não mande blocos confusos, apenas explique em português fluente)."
|
||||||
|
|
||||||
|
try:
|
||||||
|
res_traducao = requests.post(f"{ollama_host}/api/generate", json={
|
||||||
|
"model": os.getenv("OLLAMA_MODEL", "qwen2.5-coder:1.5b"),
|
||||||
|
"prompt": system_prompt + "\n\n" + traducao_prompt,
|
||||||
|
"stream": False
|
||||||
|
})
|
||||||
|
if res_traducao.status_code == 200:
|
||||||
|
texto_leigo = res_traducao.json().get("response", "Erro ao processar resumo.")
|
||||||
|
else:
|
||||||
|
texto_leigo = "Falha ao gerar resumo na LLM."
|
||||||
|
except Exception as e_traducao:
|
||||||
|
texto_leigo = "Ocorreu uma falha ao tentar traduzir o log."
|
||||||
|
|
||||||
|
# Retorna na tela a versão técnica seguida da versão explicada
|
||||||
|
return f"🤖 **Comando Técnico:** `{comando_bash}`\n\n**🖥️ Log Nativo (Terminal):**\n```\n{resultado[:1000]}\n```\n\n🧑🏫 **Tradução:**\n{texto_leigo}"
|
||||||
|
|
||||||
|
return raw_response
|
||||||
|
except Exception as e:
|
||||||
|
return f"Falha ao conectar no Ollama local: {e}"
|
||||||
|
|
||||||
|
return "Ação não reconhecida pelo Agente no momento."
|
||||||
146
bot_logic.py
Normal file
146
bot_logic.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import asyncio
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from telegram import Update
|
||||||
|
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||||
|
from ai_agent import query_agent
|
||||||
|
import speech_recognition as sr
|
||||||
|
from pydub import AudioSegment
|
||||||
|
from gtts import gTTS
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||||
|
ALLOWED_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID")
|
||||||
|
ELEVENLABS_KEY = os.getenv("ELEVENLABS_API_KEY")
|
||||||
|
VOICE_ID = os.getenv("ELEVENLABS_VOICE_ID")
|
||||||
|
|
||||||
|
def synthesize_audio(text: str) -> str:
|
||||||
|
"""Gera áudio local/gratuito usando gTTS e retorna o arquivo."""
|
||||||
|
try:
|
||||||
|
# Remove caracteres indesejados e emojis que atrapalham a fala
|
||||||
|
texto_limpo = text.replace("🤖", "").replace("🧑🏫", "").replace("*", "").replace("`", "")
|
||||||
|
|
||||||
|
filepath = "/tmp/reply_audio.mp3"
|
||||||
|
tts = gTTS(text=texto_limpo, lang='pt-br', tld='com.br', slow=False)
|
||||||
|
tts.save(filepath)
|
||||||
|
return filepath
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Erro ao gerar voz tts: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
async def auth_check(update: Update) -> bool:
|
||||||
|
"""Verifica se o usuário que enviou a mensagem é o Marcos (Chat ID autorizado)."""
|
||||||
|
if str(update.message.chat_id) != ALLOWED_CHAT_ID:
|
||||||
|
await update.message.reply_text("Acesso negado. Você não tem permissão para controlar esta VPS.")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not await auth_check(update): return
|
||||||
|
await update.message.reply_text("👋 Olá, Marcos! Antigravity VPS Agent online e pronto para receber comandos.")
|
||||||
|
|
||||||
|
async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not await auth_check(update): return
|
||||||
|
|
||||||
|
user_msg = update.message.text
|
||||||
|
await update.message.reply_chat_action(action="typing")
|
||||||
|
|
||||||
|
# Aciona o Agente de IA para processar o prompt e executar Tools se precisar
|
||||||
|
from config import get_config
|
||||||
|
cfg = get_config()
|
||||||
|
reply = query_agent(prompt=user_msg, override_provider=cfg.get("active_provider"))
|
||||||
|
|
||||||
|
# Se o usuário pedir ativamente por áudio no texto
|
||||||
|
if "áudio" in user_msg.lower() or "audio" in user_msg.lower() or "voz" in user_msg.lower():
|
||||||
|
await update.message.reply_chat_action(action="record_voice")
|
||||||
|
audio_path = synthesize_audio(reply)
|
||||||
|
if audio_path:
|
||||||
|
await update.message.reply_voice(voice=open(audio_path, 'rb'))
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
reply += "\n\n*(Falha ao gerar áudio com a ElevenLabs. Serviço indisponível.)*"
|
||||||
|
|
||||||
|
# Responde no chat normalmente
|
||||||
|
await update.message.reply_text(reply)
|
||||||
|
|
||||||
|
async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not await auth_check(update): return
|
||||||
|
|
||||||
|
await update.message.reply_chat_action(action="record_voice")
|
||||||
|
|
||||||
|
# Baixa o aúdio do telegram
|
||||||
|
voice_file = await update.message.voice.get_file()
|
||||||
|
ogg_path = "/tmp/voice.ogg"
|
||||||
|
wav_path = "/tmp/voice.wav"
|
||||||
|
|
||||||
|
await voice_file.download_to_drive(ogg_path)
|
||||||
|
|
||||||
|
# Converte para WAV (Requer ffmpeg instalado na maquina)
|
||||||
|
try:
|
||||||
|
audio = AudioSegment.from_ogg(ogg_path)
|
||||||
|
audio.export(wav_path, format="wav")
|
||||||
|
except Exception as e:
|
||||||
|
await update.message.reply_text(f"Erro ao processar áudio (O ffmpeg está instalado na VPS?): {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Usando SpeechRecognition nativo para transcrever (pode usar Whisper no Ollama depois)
|
||||||
|
recognizer = sr.Recognizer()
|
||||||
|
with sr.AudioFile(wav_path) as source:
|
||||||
|
audio_data = recognizer.record(source)
|
||||||
|
try:
|
||||||
|
text = recognizer.recognize_google(audio_data, language="pt-BR")
|
||||||
|
await update.message.reply_text(f"🗣️ Reconhecido: _{text}_", parse_mode="Markdown")
|
||||||
|
|
||||||
|
# Envia o texto reconhecido para o Agente (respeitando a configuração ativa)
|
||||||
|
from config import get_config
|
||||||
|
cfg = get_config()
|
||||||
|
reply = query_agent(prompt=text, override_provider=cfg.get("active_provider"))
|
||||||
|
|
||||||
|
# Sintetiza com ElevenLabs e responde com Áudio
|
||||||
|
audio_path = synthesize_audio(reply)
|
||||||
|
if audio_path:
|
||||||
|
await update.message.reply_voice(voice=open(audio_path, 'rb'))
|
||||||
|
else:
|
||||||
|
await update.message.reply_text(reply)
|
||||||
|
|
||||||
|
except sr.UnknownValueError:
|
||||||
|
await update.message.reply_text("Não consegui entender o que foi dito no áudio.")
|
||||||
|
except sr.RequestError as e:
|
||||||
|
await update.message.reply_text(f"Erro no serviço de STT: {e}")
|
||||||
|
|
||||||
|
async def llm_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
if not await auth_check(update): return
|
||||||
|
args = context.args
|
||||||
|
from config import get_config, save_config
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
cfg = get_config()
|
||||||
|
await update.message.reply_text(f"Comando incompleto. Use: /llm gemini ou /llm ollama.\n*Status Atual:* {cfg.get('active_provider').upper()}")
|
||||||
|
return
|
||||||
|
|
||||||
|
new_model = args[0].lower()
|
||||||
|
if new_model in ["gemini", "ollama"]:
|
||||||
|
cfg = get_config()
|
||||||
|
cfg["active_provider"] = new_model
|
||||||
|
save_config(cfg)
|
||||||
|
await update.message.reply_text(f"✅ Inteligência Artificial comutada com sucesso para: *{new_model.upper()}*")
|
||||||
|
else:
|
||||||
|
await update.message.reply_text("Modelos disponíveis: gemini ou ollama.")
|
||||||
|
|
||||||
|
def get_telegram_app():
|
||||||
|
if not TOKEN:
|
||||||
|
raise ValueError("TELEGRAM_BOT_TOKEN não encontrado no .env")
|
||||||
|
app = Application.builder().token(TOKEN).build()
|
||||||
|
app.add_handler(CommandHandler("start", start))
|
||||||
|
app.add_handler(CommandHandler("llm", llm_command))
|
||||||
|
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text))
|
||||||
|
app.add_handler(MessageHandler(filters.VOICE, handle_voice))
|
||||||
|
return app
|
||||||
|
|
||||||
|
# Para testes rápidos se rodado standalone
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Iniciando Bot Polling para testes...")
|
||||||
|
app = get_telegram_app()
|
||||||
|
app.run_polling()
|
||||||
27
config.py
Normal file
27
config.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
CONFIG_FILE = "/app/data/config.json"
|
||||||
|
|
||||||
|
def get_config():
|
||||||
|
if not os.path.exists("/app/data"):
|
||||||
|
os.makedirs("/app/data", exist_ok=True)
|
||||||
|
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Configuração Padrão
|
||||||
|
return {
|
||||||
|
"active_provider": "gemini",
|
||||||
|
"gemini_api_key": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
def save_config(cfg):
|
||||||
|
if not os.path.exists("/app/data"):
|
||||||
|
os.makedirs("/app/data", exist_ok=True)
|
||||||
|
with open(CONFIG_FILE, "w") as f:
|
||||||
|
json.dump(cfg, f, indent=4)
|
||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
vps-agent:
|
||||||
|
build: .
|
||||||
|
container_name: vps-ai-agent
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
# Monta as credenciais e o socket do docker para o Bot conseguir comandar a VPS raiz!
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- /:/host_root:ro # Acesso em leitura à VPS para análise
|
||||||
|
- ./data:/app/data # Configs dinâmicas (API Keys, etc)
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
networks:
|
||||||
|
- coolify
|
||||||
|
- ollama_net
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.vps-agent.rule=Host(`claw.reifonas.cloud`)"
|
||||||
|
- "traefik.http.routers.vps-agent.entrypoints=https"
|
||||||
|
- "traefik.http.routers.vps-agent.tls=true"
|
||||||
|
- "traefik.http.routers.vps-agent.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.vps-agent.loadbalancer.server.port=8000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
coolify:
|
||||||
|
external: true
|
||||||
|
ollama_net:
|
||||||
|
name: lw4s8g4gc8gss4gkc4gg0wk4
|
||||||
|
external: true
|
||||||
126
main.py
Normal file
126
main.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import os
|
||||||
|
import psutil
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from ai_agent import query_agent
|
||||||
|
|
||||||
|
# Carrega as variáveis do .env
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = FastAPI(title="VpsTelegramBot API")
|
||||||
|
|
||||||
|
# Configura templates HTML
|
||||||
|
# Certifique-se de que a pasta 'templates' existe e tem o index.html
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
|
async def favicon():
|
||||||
|
return JSONResponse(content={"status": "ok"})
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def read_root(request: Request):
|
||||||
|
"""Renderiza o Dashboard Web."""
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
async def get_system_status():
|
||||||
|
"""Retorna o status do sistema (CPU, RAM, Disco) sem travar o loop."""
|
||||||
|
def get_stats():
|
||||||
|
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||||
|
vm = psutil.virtual_memory()
|
||||||
|
disk = psutil.disk_usage('/')
|
||||||
|
return {
|
||||||
|
"cpu": cpu_percent,
|
||||||
|
"ram": {
|
||||||
|
"total": round(vm.total / (1024**3), 2),
|
||||||
|
"used": round(vm.used / (1024**3), 2),
|
||||||
|
"percent": vm.percent
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"total": round(disk.total / (1024**3), 2),
|
||||||
|
"used": round(disk.used / (1024**3), 2),
|
||||||
|
"percent": disk.percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data = await run_in_threadpool(get_stats)
|
||||||
|
return JSONResponse(content=data)
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from config import get_config, save_config
|
||||||
|
|
||||||
|
@app.get("/api/config")
|
||||||
|
async def read_configuration():
|
||||||
|
return JSONResponse(content=get_config())
|
||||||
|
|
||||||
|
@app.post("/api/config")
|
||||||
|
async def update_configuration(req: dict):
|
||||||
|
save_config(req)
|
||||||
|
return JSONResponse(content={"status": "success"})
|
||||||
|
|
||||||
|
@app.post("/api/action")
|
||||||
|
async def execute_smart_action(action: dict):
|
||||||
|
"""Executa ações predefinidas no servidor (Smart Actions da Web UI)."""
|
||||||
|
action_type = action.get("type")
|
||||||
|
|
||||||
|
if action_type == "ping":
|
||||||
|
return JSONResponse(content={"status": "success", "message": "Pong! Servidor online e responsivo."})
|
||||||
|
|
||||||
|
elif action_type == "restart_bot":
|
||||||
|
# Dá um pequeno delay e depois reinicia o próprio container a partir de fora (pelo host docker)
|
||||||
|
subprocess.Popen("sleep 1 && docker restart vps-ai-agent", shell=True)
|
||||||
|
return JSONResponse(content={"status": "success", "message": "Reboot do Agente autorizado. Estará de volta em instantes!"})
|
||||||
|
|
||||||
|
elif action_type == "clear_cache":
|
||||||
|
# Roda um docker prune para deletar volumes perdidos e todos containers parados (limpeza profunda)
|
||||||
|
subprocess.Popen("docker system prune -af --volumes", shell=True)
|
||||||
|
return JSONResponse(content={"status": "success", "message": "Limpando caches obsoletos em background! Verifique o gráfico de disco em instantes."})
|
||||||
|
|
||||||
|
elif action_type == "reboot_vps":
|
||||||
|
# Hacker trick: Roda um container hiper-privilegiado descartável pra entrar no espaço do host (PID 1) e emitir comando de REBOOT físico
|
||||||
|
subprocess.Popen("sleep 2 && docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -u -n -i reboot", shell=True)
|
||||||
|
return JSONResponse(content={"status": "success", "message": "🚨 O REBOOT CRÍTICO COMEÇOU. A VPS inteira desligará e religará agora."})
|
||||||
|
|
||||||
|
return JSONResponse(content={"status": "error", "message": "Ação desconhecida."}, status_code=400)
|
||||||
|
|
||||||
|
@app.post("/api/chat")
|
||||||
|
async def web_chat(message: dict):
|
||||||
|
"""Endpoint para interagir com a IA via Web UI."""
|
||||||
|
user_text = message.get("text", "")
|
||||||
|
if not user_text:
|
||||||
|
return JSONResponse(content={"reply": "Por favor, digite um comando válido."})
|
||||||
|
|
||||||
|
# Executa a IA em uma thread separada para não travar a UI/API de status
|
||||||
|
reply = await run_in_threadpool(query_agent, prompt=user_text)
|
||||||
|
return JSONResponse(content={"reply": reply})
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
@app.get("/api/test_llm")
|
||||||
|
async def test_llm_speed():
|
||||||
|
"""Mede a velocidade de resposta da IA ativa."""
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
reply = await run_in_threadpool(query_agent, prompt="responda apenas com a palavra 'pong'")
|
||||||
|
latency = round(time.time() - start_time, 2)
|
||||||
|
return JSONResponse(content={"status": "success", "latency": latency, "reply": reply})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@app.post("/webhook")
|
||||||
|
async def telegram_webhook(request: Request):
|
||||||
|
"""Recebe as atualizações (mensagens) do Telegram."""
|
||||||
|
update = await request.json()
|
||||||
|
# O bot_logic.py lidará com o 'update' no futuro
|
||||||
|
print("Update recebido do Telegram:", update)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
# Executa o servidor na porta 8000 acessível de qualquer lugar na rede
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
15
requirements.txt
Normal file
15
requirements.txt
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
python-telegram-bot
|
||||||
|
langchain
|
||||||
|
google-genai
|
||||||
|
ollama
|
||||||
|
SpeechRecognition
|
||||||
|
requests
|
||||||
|
python-dotenv
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
psutil
|
||||||
|
pydub
|
||||||
|
jinja2
|
||||||
|
gTTS
|
||||||
787
templates/index.html
Normal file
787
templates/index.html
Normal file
@@ -0,0 +1,787 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="pt-BR" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>VPS AI Dashboard</title>
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤖</text></svg>">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-base: #0f172a;
|
||||||
|
--bg-card: rgba(30, 41, 59, 0.8);
|
||||||
|
--bg-input: rgba(15, 23, 42, 0.5);
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #60a5fa;
|
||||||
|
--accent-glow: rgba(59, 130, 246, 0.25);
|
||||||
|
--text-main: #f8fafc;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--danger-bg: rgba(239, 68, 68, 0.15);
|
||||||
|
--success: #10b981;
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
--radius: 12px;
|
||||||
|
--transition: 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg-base: #f1f5f9;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-input: #e2e8f0;
|
||||||
|
--border: rgba(0, 0, 0, 0.08);
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-hover: #1d4ed8;
|
||||||
|
--accent-glow: rgba(37, 99, 235, 0.2);
|
||||||
|
--text-main: #1e3a5f;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
color: var(--text-main);
|
||||||
|
min-height: 100vh;
|
||||||
|
transition: background-color var(--transition), color var(--transition);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: var(--success);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge::before {
|
||||||
|
content: '';
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--success);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 6px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline::before {
|
||||||
|
background: var(--danger);
|
||||||
|
box-shadow: 0 0 6px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar-bg {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar.warning {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Title */
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 1.25rem 0 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions Grid */
|
||||||
|
.actions-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config Form */
|
||||||
|
.config-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat */
|
||||||
|
.chat-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: min(350px, 45vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-ai {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||||
|
align-self: flex-start;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-user {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
align-self: flex-end;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-main);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
#toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: var(--success);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toast.error {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-wrapper {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.actions-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div class="logo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
<h1>VPS AI Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="status-badge" id="bot-status">Online</div>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Alternar tema">
|
||||||
|
<svg id="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: none;">
|
||||||
|
<circle cx="12" cy="12" r="5"/>
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||||
|
</svg>
|
||||||
|
<svg id="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="card stat-card">
|
||||||
|
<h3>CPU</h3>
|
||||||
|
<div class="stat-value" id="cpu-val">--%</div>
|
||||||
|
<div class="progress-bar-bg">
|
||||||
|
<div class="progress-bar" id="cpu-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<h3>RAM</h3>
|
||||||
|
<div class="stat-value" id="ram-val">-- / -- GB</div>
|
||||||
|
<div class="progress-bar-bg">
|
||||||
|
<div class="progress-bar" id="ram-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card stat-card">
|
||||||
|
<h3>Disk</h3>
|
||||||
|
<div class="stat-value" id="disk-val">--%</div>
|
||||||
|
<div class="progress-bar-bg">
|
||||||
|
<div class="progress-bar" id="disk-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Ações Rápidas</div>
|
||||||
|
<div class="actions-grid">
|
||||||
|
<button class="btn" onclick="executeAction('ping')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>
|
||||||
|
</svg>
|
||||||
|
Ping
|
||||||
|
</button>
|
||||||
|
<button class="btn" id="btn-test-llm" onclick="testLLMSpeed()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2a10 10 0 1 0 10 10H12V2z"/>
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10h-2a8 8 0 0 0-8-8V2z"/>
|
||||||
|
<path d="M12 12V2.5l5.5 5.5"/>
|
||||||
|
</svg>
|
||||||
|
Testar LLM
|
||||||
|
</button>
|
||||||
|
<button class="btn" onclick="executeAction('restart_bot')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M23 4v6h-6M1 20v-6h6"/>
|
||||||
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
||||||
|
</svg>
|
||||||
|
Reiniciar
|
||||||
|
</button>
|
||||||
|
<button class="btn" onclick="executeAction('clear_cache')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
Limpar Cache
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" onclick="executeAction('reboot_vps')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||||
|
</svg>
|
||||||
|
Reboot VPS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Configuração AI</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="config-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Provider Ativo</label>
|
||||||
|
<select id="active_provider" class="form-input">
|
||||||
|
<option value="ollama">Ollama (Local)</option>
|
||||||
|
<option value="gemini">Gemini Pro (Google)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Gemini API Key</label>
|
||||||
|
<input type="password" id="gemini_api_key" class="form-input" placeholder="AIzaSy...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="saveConfiguration()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||||
|
<polyline points="17 21 17 13 7 13 7 21"/>
|
||||||
|
<polyline points="7 3 7 8 15 8"/>
|
||||||
|
</svg>
|
||||||
|
Salvar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">Chat AI</div>
|
||||||
|
<div class="card chat-wrapper">
|
||||||
|
<div class="chat-messages" id="chat-box">
|
||||||
|
<div class="chat-bubble bubble-ai">
|
||||||
|
Olá! Sou o VPS Agent. Como posso ajudar com seu servidor?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-area">
|
||||||
|
<input type="text" id="chat-input" class="chat-input" placeholder="Digite seu comando..." onkeypress="handleKeyPress(event)">
|
||||||
|
<button class="btn btn-primary" onclick="sendMessage()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast">Ação executada!</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTheme() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const isDark = html.dataset.theme !== 'light';
|
||||||
|
html.dataset.theme = isDark ? 'light' : 'dark';
|
||||||
|
localStorage.setItem('theme', html.dataset.theme);
|
||||||
|
updateThemeIcon(!isDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThemeIcon(isDark) {
|
||||||
|
document.getElementById('icon-sun').style.display = isDark ? 'block' : 'none';
|
||||||
|
document.getElementById('icon-moon').style.display = isDark ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) {
|
||||||
|
document.documentElement.dataset.theme = savedTheme;
|
||||||
|
updateThemeIcon(savedTheme === 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/status');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const cpuVal = document.getElementById('cpu-val');
|
||||||
|
const cpuBar = document.getElementById('cpu-bar');
|
||||||
|
if (cpuVal) cpuVal.textContent = data.cpu + '%';
|
||||||
|
if (cpuBar) {
|
||||||
|
cpuBar.style.width = data.cpu + '%';
|
||||||
|
cpuBar.className = 'progress-bar' + (data.cpu > 80 ? ' danger' : data.cpu > 60 ? ' warning' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ramVal = document.getElementById('ram-val');
|
||||||
|
const ramBar = document.getElementById('ram-bar');
|
||||||
|
if (ramVal) ramVal.textContent = data.ram.used + ' / ' + data.ram.total + ' GB';
|
||||||
|
if (ramBar) ramBar.style.width = data.ram.percent + '%';
|
||||||
|
|
||||||
|
const diskVal = document.getElementById('disk-val');
|
||||||
|
const diskBar = document.getElementById('disk-bar');
|
||||||
|
if (diskVal) diskVal.textContent = data.disk.percent + '%';
|
||||||
|
if (diskBar) {
|
||||||
|
diskBar.style.width = data.disk.percent + '%';
|
||||||
|
diskBar.className = 'progress-bar' + (data.disk.percent > 90 ? ' danger' : data.disk.percent > 75 ? ' warning' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = document.getElementById('bot-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Online';
|
||||||
|
status.classList.remove('offline');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const status = document.getElementById('bot-status');
|
||||||
|
if (status) {
|
||||||
|
status.textContent = 'Offline';
|
||||||
|
status.classList.add('offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(fetchStats, 3000);
|
||||||
|
fetchStats();
|
||||||
|
|
||||||
|
async function executeAction(type) {
|
||||||
|
const messages = {
|
||||||
|
reboot_vps: '⚠️ Confirma reboot da VPS?',
|
||||||
|
clear_cache: 'Limpar cache do servidor?',
|
||||||
|
restart_bot: 'Reiniciar o agente AI?'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (messages[type] && !confirm(messages[type])) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(data.message || 'Ação executada!');
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Erro ao executar.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, isError = false) {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = msg;
|
||||||
|
toast.className = isError ? 'error show' : 'show';
|
||||||
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendChat() {
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
addBubble(text, 'user');
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
addBubble(data.reply, 'ai');
|
||||||
|
} catch (e) {
|
||||||
|
addBubble('Erro ao contatar servidor.', 'ai');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyPress(e) {
|
||||||
|
if (e.key === 'Enter') sendChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBubble(text, sender) {
|
||||||
|
const box = document.getElementById('chat-box');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'chat-bubble bubble-' + sender;
|
||||||
|
div.textContent = text;
|
||||||
|
box.appendChild(div);
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config');
|
||||||
|
const data = await res.json();
|
||||||
|
const provider = document.getElementById('active_provider');
|
||||||
|
const key = document.getElementById('gemini_api_key');
|
||||||
|
if (provider) provider.value = data.active_provider || 'ollama';
|
||||||
|
if (key) key.value = data.gemini_api_key || '';
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
const provider = document.getElementById('active_provider').value;
|
||||||
|
const key = document.getElementById('gemini_api_key').value.trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ active_provider: provider, gemini_api_key: key })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
showToast('Configurações salvas!');
|
||||||
|
loadConfig();
|
||||||
|
} else {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Erro ao salvar.', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.saveConfiguration = saveConfig;
|
||||||
|
window.sendMessage = sendChat;
|
||||||
|
async function testLLMSpeed() {
|
||||||
|
const btn = document.getElementById('btn-test-llm');
|
||||||
|
const originalContent = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⚡ Testando...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/test_llm');
|
||||||
|
const data = await res.json();
|
||||||
|
if(data.status === 'success') {
|
||||||
|
showToast(`✅ LLM Online! Resposta em ${data.latency}s`);
|
||||||
|
btn.innerHTML = `✅ ${data.latency}s`;
|
||||||
|
setTimeout(() => { btn.innerHTML = originalContent; btn.disabled = false; }, 5000);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
showToast("❌ Erro no Teste LLM: " + e.message, true);
|
||||||
|
btn.innerHTML = '❌ Falhou';
|
||||||
|
btn.classList.add('btn-danger');
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.innerHTML = originalContent;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('btn-danger');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.executeAction = executeAction;
|
||||||
|
window.testLLMSpeed = testLLMSpeed;
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
tools.py
Normal file
45
tools.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
def run_bash_command(command: str) -> str:
|
||||||
|
"""Executa um comando bash na VPS e retorna a saída."""
|
||||||
|
try:
|
||||||
|
# Executa comando arriscado com root de forma segura dentro da VPS
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60 # Limite de tempo de execução
|
||||||
|
)
|
||||||
|
output = result.stdout.strip()
|
||||||
|
error = result.stderr.strip()
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return f"ERRO ({result.returncode}):\n{error}"
|
||||||
|
|
||||||
|
return output if output else "Comando executado com sucesso (sem saída)."
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return "ERRO: O comando demorou muito e foi cancelado (timeout)."
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERRO fatal ao rodar bash: {str(e)}"
|
||||||
|
|
||||||
|
def get_system_health() -> str:
|
||||||
|
"""Retorna um texto base rápido da saúde atual do servidor para a IA analisar."""
|
||||||
|
cpu = psutil.cpu_percent(interval=0.1)
|
||||||
|
ram = psutil.virtual_memory().percent
|
||||||
|
disk = psutil.disk_usage('/').percent
|
||||||
|
return f"CPU: {cpu}% | RAM Usada: {ram}% | Disco Usado: {disk}%"
|
||||||
|
|
||||||
|
# Mapeamento estático para o LLM entender quais tools ele possui
|
||||||
|
AVAILABLE_TOOLS = {
|
||||||
|
"run_bash_command": {
|
||||||
|
"description": "Executa qualquer comando Linux no terminal da VPS. Use para criar arquivos, rodar git, verificar logs do docker, etc.",
|
||||||
|
"func": run_bash_command
|
||||||
|
},
|
||||||
|
"get_system_health": {
|
||||||
|
"description": "Verifica como está o uso de RAM, CPU e Disco agora.",
|
||||||
|
"func": get_system_health
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user