feat: upgrade interface web e suporte a áudio completo
This commit is contained in:
186
ai_agent.py
186
ai_agent.py
@@ -1,120 +1,90 @@
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
from tools import run_bash_command, get_system_health
|
||||
import json
|
||||
from tools import AVAILABLE_TOOLS
|
||||
from config import get_config
|
||||
|
||||
def get_llm_response(prompt: str, provider: str, cfg: dict) -> str:
|
||||
"""Invoca o provedor de LLM configurado."""
|
||||
if provider == "gemini":
|
||||
api_key = cfg.get("gemini_api_key")
|
||||
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={api_key}"
|
||||
payload = {"contents": [{"parts": [{"text": prompt}]}]}
|
||||
res = requests.post(url, json=payload)
|
||||
if res.status_code == 200:
|
||||
return res.json()["candidates"][0]["content"]["parts"][0]["text"]
|
||||
return f"Erro Gemini: {res.text}"
|
||||
|
||||
elif provider == "ollama":
|
||||
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": prompt,
|
||||
"stream": False
|
||||
})
|
||||
if res.status_code == 200:
|
||||
return res.json().get("response", "")
|
||||
return f"Erro Ollama: {res.text}"
|
||||
|
||||
return "Provedor desconhecido."
|
||||
|
||||
def query_agent(prompt: str, override_provider: str = None) -> str:
|
||||
"""
|
||||
Função principal que roteia pro LLM desejado, detecta intents e aciona as Tools.
|
||||
Motor Agente em Loop (ReAct): Pensamento -> Ação -> Observação -> Resposta Final.
|
||||
"""
|
||||
|
||||
# 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")
|
||||
provider = override_provider or cfg.get("active_provider", "gemini")
|
||||
|
||||
if provider == "gemini":
|
||||
gemini_api_key = cfg.get("gemini_api_key", "")
|
||||
if not gemini_api_key: return "Chave API do Gemini não configurada."
|
||||
# Contexto de Ferramentas para a IA
|
||||
tools_desc = "\n".join([f"- {k}: {v['description']}" for k,v in AVAILABLE_TOOLS.items()])
|
||||
|
||||
# Prompt especializado (sem chaves complexas)
|
||||
system_prompt_base = """Você é o [Antigravity VPS Agent].
|
||||
Sua missão é ser o SysAdmin de elite do Marcos. Você tem acesso root.
|
||||
|
||||
### REGRAS:
|
||||
1. Responda em PORTUGUÊS (Brasil).
|
||||
2. Para agir, use: [CMD] comando [/CMD]. Rode UM comando por vez.
|
||||
3. Seus comandos devem ser diretos (docker, git, ls, rm, mkdir).
|
||||
4. Após o comando, você receberá a saída. O seu objetivo é resolver a solicitação do usuário.
|
||||
5. Quando terminar, sua resposta FINAL deve ter:
|
||||
- Um resumo técnico rápido.
|
||||
- Uma seção entre tags <REFINED> ... </REFINED> com uma tabela Markdown limpa ou resumo em tópicos (Nome: Valor) para o usuário leigo.
|
||||
|
||||
### FERRAMENTAS DISPONÍVEIS:
|
||||
{TOOLS_LIST}
|
||||
|
||||
### EXEMPLO DE REFINAMENTO VISUAL:
|
||||
Relatório: Coletei os dados solicitados.
|
||||
<REFINED>
|
||||
### 📊 Status Global
|
||||
- **CPU**: 10%
|
||||
- **RAM**: 500MB livre
|
||||
</REFINED>
|
||||
"""
|
||||
system_prompt = system_prompt_base.replace("{TOOLS_LIST}", tools_desc)
|
||||
|
||||
history = f"\nUsuário: {prompt}\n"
|
||||
max_loops = 10
|
||||
|
||||
for _ in range(max_loops):
|
||||
full_prompt = system_prompt + history
|
||||
response = get_llm_response(full_prompt, provider, cfg)
|
||||
|
||||
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}"
|
||||
# Procura por comandos na resposta
|
||||
match = re.search(r"\[CMD\](.*?)\[/CMD\]", response, re.IGNORECASE | re.DOTALL)
|
||||
|
||||
if match:
|
||||
cmd = match.group(1).strip()
|
||||
# Executa a tool (por enquanto focada em bash que é a mais poderosa)
|
||||
print(f"Agente executando: {cmd}")
|
||||
observation = AVAILABLE_TOOLS["run_bash_command"]["func"](cmd)
|
||||
|
||||
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."
|
||||
# Adiciona ao histórico para a IA ler na próxima rodada
|
||||
history += f"\nAgente (Pensamento/Ação): {response}\nSISTEMA (Saída do Terminal): {observation}\n"
|
||||
else:
|
||||
# Se não tem comando, é a resposta final
|
||||
return response
|
||||
|
||||
return "O agente atingiu o limite de tentativas para esta tarefa."
|
||||
|
||||
42
audio_handler.py
Normal file
42
audio_handler.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import os
|
||||
import speech_recognition as sr
|
||||
from pydub import AudioSegment
|
||||
from gtts import gTTS
|
||||
import uuid
|
||||
import re
|
||||
|
||||
def transcribe_audio(file_path: str) -> str:
|
||||
"""Converte áudio (qualquer formato compatível com pydub) para WAV e transcreve com Google Speech."""
|
||||
recognizer = sr.Recognizer()
|
||||
|
||||
# Se não for wav, converte usando pydub (precisa de ffmpeg na VPS)
|
||||
temp_wav = f"/tmp/{uuid.uuid4()}.wav"
|
||||
try:
|
||||
audio = AudioSegment.from_file(file_path)
|
||||
audio.export(temp_wav, format="wav")
|
||||
|
||||
with sr.AudioFile(temp_wav) as source:
|
||||
audio_data = recognizer.record(source)
|
||||
text = recognizer.recognize_google(audio_data, language="pt-BR")
|
||||
return text
|
||||
finally:
|
||||
if os.path.exists(temp_wav):
|
||||
os.remove(temp_wav)
|
||||
|
||||
def text_to_speech(text: str) -> str:
|
||||
"""Sintetiza texto em áudio MP3, removendo tags visuais e emojis."""
|
||||
# Limpeza para narração
|
||||
texto_limpo = text.replace("🤖", "").replace("🧑🏫", "").replace("*", "").replace("`", "")
|
||||
# Remove o bloco <REFINED> se houver, pois ele é para leitura visual apenas
|
||||
texto_limpo = re.sub(r'<REFINED>.*?</REFINED>', '', texto_limpo, flags=re.DOTALL).strip()
|
||||
|
||||
# Se sobrar texto vazio após limpar o refinado (raro), fala algo genérico
|
||||
if not texto_limpo:
|
||||
texto_limpo = "Relatório processado. Os detalhes estão no painel visual."
|
||||
|
||||
filename = f"audio_reply_{uuid.uuid4().hex[:8]}.mp3"
|
||||
filepath = os.path.join("/tmp", filename)
|
||||
|
||||
tts = gTTS(text=texto_limpo, lang='pt-br', tld='com.br', slow=False)
|
||||
tts.save(filepath)
|
||||
return filename
|
||||
@@ -9,7 +9,8 @@ services:
|
||||
- "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
|
||||
- .:/app
|
||||
- /var/run/docker.sock:/var/run/docker.sock:rw
|
||||
- /:/host_root:ro # Acesso em leitura à VPS para análise
|
||||
- ./data:/app/data # Configs dinâmicas (API Keys, etc)
|
||||
env_file:
|
||||
|
||||
42
main.py
42
main.py
@@ -3,11 +3,12 @@ import psutil
|
||||
import subprocess
|
||||
import time
|
||||
import json
|
||||
from fastapi import FastAPI, Request, Header, Depends, HTTPException, status
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi import FastAPI, Request, Header, Depends, HTTPException, status, File, UploadFile
|
||||
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 audio_handler
|
||||
|
||||
from ai_agent import query_agent
|
||||
from config import get_config, save_config
|
||||
@@ -111,6 +112,43 @@ async def web_chat(message: dict, is_auth: bool = Depends(verify_password)):
|
||||
reply = await run_in_threadpool(query_agent, prompt=user_text)
|
||||
return JSONResponse(content={"reply": reply})
|
||||
|
||||
@app.post("/api/chat-audio")
|
||||
async def web_chat_audio(audio: UploadFile = File(...), is_auth: bool = Depends(verify_password)):
|
||||
"""Recebe áudio, transcreve, processa na IA e devolve texto + áudio de resposta."""
|
||||
temp_path = f"/tmp/{audio.filename}"
|
||||
with open(temp_path, "wb") as buffer:
|
||||
buffer.write(await audio.read())
|
||||
|
||||
try:
|
||||
# Transcrição (STT)
|
||||
user_text = await run_in_threadpool(audio_handler.transcribe_audio, temp_path)
|
||||
|
||||
# IA (Processamento)
|
||||
reply = await run_in_threadpool(query_agent, prompt=user_text)
|
||||
|
||||
# Síntese (TTS)
|
||||
audio_filename = await run_in_threadpool(audio_handler.text_to_speech, reply)
|
||||
audio_url = f"/api/audio-file/{audio_filename}"
|
||||
|
||||
return JSONResponse(content={
|
||||
"text": user_text,
|
||||
"reply": reply,
|
||||
"audio_url": audio_url
|
||||
})
|
||||
except Exception as e:
|
||||
return JSONResponse(content={"status": "error", "message": str(e)}, status_code=500)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
@app.get("/api/audio-file/{filename}")
|
||||
async def get_audio_file(filename: str):
|
||||
"""Serve os arquivos de áudio temporários gerados pelo TTS."""
|
||||
filepath = os.path.join("/tmp", filename)
|
||||
if os.path.exists(filepath):
|
||||
return FileResponse(filepath, media_type="audio/mpeg")
|
||||
raise HTTPException(status_code=404, detail="Arquivo de áudio não encontrado.")
|
||||
|
||||
@app.get("/api/test_llm")
|
||||
async def test_llm_speed(is_auth: bool = Depends(verify_password)):
|
||||
"""Mede a velocidade de resposta da IA ativa."""
|
||||
|
||||
3
start.sh
Executable file
3
start.sh
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000 --reload &
|
||||
python bot_logic.py
|
||||
@@ -7,6 +7,7 @@
|
||||
<!-- 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">
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-base: #0f172a;
|
||||
@@ -26,6 +27,36 @@
|
||||
--transition: 0.25s ease;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; }
|
||||
::-webkit-scrollbar-track { background: var(--bg-base); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; border: 2px solid var(--bg-base); }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
|
||||
|
||||
/* Scrollbar Fix for Firefox */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--accent) transparent; }
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.7; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-base);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-base: #f1f5f9;
|
||||
--bg-card: #ffffff;
|
||||
@@ -318,11 +349,77 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Chat */
|
||||
/* Chat & Insights Layout */
|
||||
.chat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
height: clamp(400px, 60vh, 800px);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.chat-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: min(350px, 45vh);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.insights-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
border: 2px solid var(--accent-glow);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.insights-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.insights-content {
|
||||
flex: 1;
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.insights-content h3 { margin-bottom: 1rem; color: var(--accent); }
|
||||
.insights-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
background: var(--bg-input);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.insights-content th, .insights-content td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.insights-content th { background: rgba(59, 130, 246, 0.1); color: var(--accent); font-size: 0.75rem; text-transform: uppercase; }
|
||||
.insights-placeholder {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
@@ -617,21 +714,52 @@
|
||||
</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 class="section-title">Terminal & Insights da IA</div>
|
||||
<div class="chat-layout">
|
||||
<!-- Coluna 1: Chat Técnico -->
|
||||
<div class="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? Tudo o que eu fizer aparecerá aqui no terminal técnico.
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area" style="padding: 0.75rem; gap: 0.5rem;">
|
||||
<input type="text" id="chat-input" class="chat-input" placeholder="Comande sua VPS aqui..." onkeypress="handleKeyPress(event)">
|
||||
|
||||
<button class="btn" id="audio-btn" onclick="toggleRecording()" title="Gravar Áudio">
|
||||
<svg id="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
||||
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v4M8 23h8"/>
|
||||
</svg>
|
||||
<div id="recording-dot" style="display:none; width:8px; height:8px; background:red; border-radius:50%; animation: pulse 1s infinite;"></div>
|
||||
</button>
|
||||
|
||||
<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 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"/>
|
||||
|
||||
<!-- Coluna 2: Painel de Insights (Refinado) -->
|
||||
<div class="insights-wrapper">
|
||||
<div class="insights-header">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
|
||||
<path d="M21 12V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h7"/>
|
||||
<line x1="16" y1="5" x2="16" y2="19"/>
|
||||
<line x1="2" x2="16" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
Painel de Insights Visuais
|
||||
</div>
|
||||
<div class="insights-content" id="insights-panel">
|
||||
<div class="insights-placeholder">
|
||||
<div style="font-size: 2.5rem; margin-bottom: 1rem;">📊</div>
|
||||
<p>Aguardando dados estruturados...</p>
|
||||
<small style="display:block; margin-top:0.5rem; opacity:0.6;">Peça algo como "status dos containers" para ver o refinamento aqui.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -794,12 +922,36 @@
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
const data = await res.json();
|
||||
addBubble(data.reply, 'ai');
|
||||
processAIReply(data.reply);
|
||||
} catch (e) {
|
||||
addBubble('Erro ao contatar servidor.', 'ai');
|
||||
}
|
||||
}
|
||||
|
||||
function processAIReply(fullText) {
|
||||
// Separa a parte técnica da parte refinada
|
||||
const refinedMatch = fullText.match(/<REFINED>([\s\S]*?)<\/REFINED>/i);
|
||||
|
||||
let technicalPart = fullText;
|
||||
if (refinedMatch) {
|
||||
technicalPart = fullText.replace(refinedMatch[0], '').trim();
|
||||
const refinedContent = refinedMatch[1].trim();
|
||||
updateInsightsPanel(refinedContent);
|
||||
}
|
||||
|
||||
if (technicalPart) {
|
||||
addBubble(technicalPart, 'ai');
|
||||
}
|
||||
}
|
||||
|
||||
function updateInsightsPanel(markdown) {
|
||||
const panel = document.getElementById('insights-panel');
|
||||
if(!panel) return;
|
||||
|
||||
// Renderiza o Markdown para HTML usando marked.js
|
||||
panel.innerHTML = `<div class="animate-fade-in">${marked.parse(markdown)}</div>`;
|
||||
}
|
||||
|
||||
function handleKeyPress(e) {
|
||||
if (e.key === 'Enter') sendChat();
|
||||
}
|
||||
@@ -880,6 +1032,81 @@
|
||||
window.testLLMSpeed = testLLMSpeed;
|
||||
window.attemptLogin = attemptLogin;
|
||||
|
||||
// --- AUDIO SYSTEM ---
|
||||
let mediaRecorder;
|
||||
let audioChunks = [];
|
||||
let isRecording = false;
|
||||
|
||||
async function toggleRecording() {
|
||||
if (!isRecording) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
audioChunks.push(event.data);
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
await uploadAudio(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
isRecording = true;
|
||||
updateMicUI(true);
|
||||
} catch (err) {
|
||||
showToast("Erro ao acessar microfone", true);
|
||||
}
|
||||
} else {
|
||||
mediaRecorder.stop();
|
||||
mediaRecorder.stream.getTracks().forEach(t => t.stop());
|
||||
isRecording = false;
|
||||
updateMicUI(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMicUI(active) {
|
||||
const dot = document.getElementById('recording-dot');
|
||||
const icon = document.getElementById('mic-icon');
|
||||
const btn = document.getElementById('audio-btn');
|
||||
|
||||
dot.style.display = active ? 'block' : 'none';
|
||||
icon.style.color = active ? 'red' : 'inherit';
|
||||
btn.style.borderColor = active ? 'red' : 'var(--border)';
|
||||
}
|
||||
|
||||
async function uploadAudio(blob) {
|
||||
const formData = new FormData();
|
||||
formData.append('audio', blob, 'voice.webm');
|
||||
|
||||
showToast("✨ Transcrevendo áudio...");
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-audio', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if(data.text) {
|
||||
addBubble(data.text, 'user');
|
||||
processAIReply(data.reply);
|
||||
|
||||
// Se o bot retornou áudio, toca ele
|
||||
if(data.audio_url) {
|
||||
const audio = new Audio(data.audio_url + '?t=' + Date.now());
|
||||
audio.play();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("Erro ao processar áudio", true);
|
||||
}
|
||||
}
|
||||
|
||||
window.toggleRecording = toggleRecording;
|
||||
|
||||
// Auto-login se já tiver senha salva
|
||||
if (webPassword) {
|
||||
(async () => {
|
||||
|
||||
33
tools.py
33
tools.py
@@ -5,13 +5,13 @@ 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
|
||||
# Executa comando 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
|
||||
timeout=120 # Aumentado para comandos mais pesados
|
||||
)
|
||||
output = result.stdout.strip()
|
||||
error = result.stderr.strip()
|
||||
@@ -32,14 +32,37 @@ def get_system_health() -> str:
|
||||
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
|
||||
def read_vps_file(filepath: str) -> str:
|
||||
"""Lê um arquivo do sistema de arquivos da VPS através do mapeamento /host_root."""
|
||||
host_path = f"/host_root{filepath}" if not filepath.startswith("/host_root") else filepath
|
||||
try:
|
||||
if not os.path.exists(host_path):
|
||||
return f"Erro: Arquivo {filepath} não encontrado no host."
|
||||
with open(host_path, 'r') as f:
|
||||
return f.read(2000) # Limite para não estourar o contexto
|
||||
except Exception as e:
|
||||
return f"Erro ao ler arquivo: {e}"
|
||||
|
||||
def get_docker_stats() -> str:
|
||||
"""Retorna o uso de CPU/RAM de todos os containers ativos via comando docker stats."""
|
||||
return run_bash_command('docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"')
|
||||
|
||||
# Mapeamento para o Agente entender quais tools ele possui (será usado no loop ReAct)
|
||||
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.",
|
||||
"description": "Executa comandos Linux na VPS. Use para docker, git, mkdir, touch, etc.",
|
||||
"func": run_bash_command
|
||||
},
|
||||
"get_system_health": {
|
||||
"description": "Verifica como está o uso de RAM, CPU e Disco agora.",
|
||||
"description": "Verifica RAM, CPU e Disco globais da VPS.",
|
||||
"func": get_system_health
|
||||
},
|
||||
"read_vps_file": {
|
||||
"description": "Lê o conteúdo de um arquivo na VPS (logs, configs).",
|
||||
"func": read_vps_file
|
||||
},
|
||||
"get_docker_stats": {
|
||||
"description": "Retorna uma tabela com o consumo de CPU e Memória de cada container.",
|
||||
"func": get_docker_stats
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user