feat: upgrade interface web e suporte a áudio completo

This commit is contained in:
2026-03-22 01:05:27 +00:00
parent 2d3da03ee6
commit 3e2e81bd64
7 changed files with 435 additions and 131 deletions

View File

@@ -1,120 +1,90 @@
import os import os
import re import re
import requests import requests
from tools import run_bash_command, get_system_health import json
from tools import AVAILABLE_TOOLS
from config import get_config 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: 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() cfg = get_config()
provider = override_provider or cfg.get("active_provider", "ollama") provider = override_provider or cfg.get("active_provider", "gemini")
if provider == "gemini": # Contexto de Ferramentas para a IA
gemini_api_key = cfg.get("gemini_api_key", "") tools_desc = "\n".join([f"- {k}: {v['description']}" for k,v in AVAILABLE_TOOLS.items()])
if not gemini_api_key: return "Chave API do Gemini não configurada."
try: # Prompt especializado (sem chaves complexas)
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key={gemini_api_key}" system_prompt_base = """Você é o [Antigravity VPS Agent].
payload = { Sua missão é ser o SysAdmin de elite do Marcos. Você tem acesso root.
"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 ### REGRAS:
match = re.search(r"\[.*?CMD\](.*?)\[/.*?CMD\]", raw_response, re.IGNORECASE | re.DOTALL) 1. Responda em PORTUGUÊS (Brasil).
if match: 2. Para agir, use: [CMD] comando [/CMD]. Rode UM comando por vez.
comando_bash = match.group(1).strip() 3. Seus comandos devem ser diretos (docker, git, ls, rm, mkdir).
resultado = run_bash_command(comando_bash) 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.
# Tradução via Gemini ### FERRAMENTAS DISPONÍVEIS:
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." {TOOLS_LIST}
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}" ### 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)
return raw_response history = f"\nUsuário: {prompt}\n"
except Exception as e: max_loops = 10
return f"Falha de Conexão com Gemini Pro: {e}"
elif provider == "ollama": for _ in range(max_loops):
try: full_prompt = system_prompt + history
ollama_host = os.getenv("OLLAMA_HOST", "http://ollama-lw4s8g4gc8gss4gkc4gg0wk4:11434") response = get_llm_response(full_prompt, provider, cfg)
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 # Procura por comandos na resposta
match = re.search(r"\[.*?CMD\](.*?)\[/.*?CMD\]", raw_response, re.IGNORECASE | re.DOTALL) match = re.search(r"\[CMD\](.*?)\[/CMD\]", response, re.IGNORECASE | re.DOTALL)
if match: if match:
comando_bash = match.group(1).strip() cmd = match.group(1).strip()
resultado = run_bash_command(comando_bash) # 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)
# ---------------------------------------------------- # Adiciona ao histórico para a IA ler na próxima rodada
# NOVA LÓGICA: Tradução Leiga (Segundo Prompt para a IA) history += f"\nAgente (Pensamento/Ação): {response}\nSISTEMA (Saída do Terminal): {observation}\n"
# ---------------------------------------------------- else:
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)." # Se não tem comando, é a resposta final
return response
try: return "O agente atingiu o limite de tentativas para esta tarefa."
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."

42
audio_handler.py Normal file
View 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

View File

@@ -9,7 +9,8 @@ services:
- "8000" - "8000"
# Monta as credenciais e o socket do docker para o Bot conseguir comandar a VPS raiz! # Monta as credenciais e o socket do docker para o Bot conseguir comandar a VPS raiz!
volumes: 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 - /:/host_root:ro # Acesso em leitura à VPS para análise
- ./data:/app/data # Configs dinâmicas (API Keys, etc) - ./data:/app/data # Configs dinâmicas (API Keys, etc)
env_file: env_file:

42
main.py
View File

@@ -3,11 +3,12 @@ import psutil
import subprocess import subprocess
import time import time
import json import json
from fastapi import FastAPI, Request, Header, Depends, HTTPException, status from fastapi import FastAPI, Request, Header, Depends, HTTPException, status, File, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from dotenv import load_dotenv from dotenv import load_dotenv
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
import audio_handler
from ai_agent import query_agent from ai_agent import query_agent
from config import get_config, save_config 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) reply = await run_in_threadpool(query_agent, prompt=user_text)
return JSONResponse(content={"reply": reply}) 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") @app.get("/api/test_llm")
async def test_llm_speed(is_auth: bool = Depends(verify_password)): async def test_llm_speed(is_auth: bool = Depends(verify_password)):
"""Mede a velocidade de resposta da IA ativa.""" """Mede a velocidade de resposta da IA ativa."""

3
start.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload &
python bot_logic.py

View File

@@ -7,6 +7,7 @@
<!-- Favicon --> <!-- 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 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"> <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> <style>
:root { :root {
--bg-base: #0f172a; --bg-base: #0f172a;
@@ -26,6 +27,36 @@
--transition: 0.25s ease; --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"] { [data-theme="light"] {
--bg-base: #f1f5f9; --bg-base: #f1f5f9;
--bg-card: #ffffff; --bg-card: #ffffff;
@@ -318,11 +349,77 @@
cursor: pointer; 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 { .chat-wrapper {
display: flex; display: flex;
flex-direction: column; 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 { .chat-messages {
@@ -617,21 +714,52 @@
</button> </button>
</div> </div>
<div class="section-title">Chat AI</div> <div class="section-title">Terminal & Insights da IA</div>
<div class="card chat-wrapper"> <div class="chat-layout">
<div class="chat-messages" id="chat-box"> <!-- Coluna 1: Chat Técnico -->
<div class="chat-bubble bubble-ai"> <div class="chat-wrapper">
Olá! Sou o VPS Agent. Como posso ajudar com seu servidor? <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> </div>
<div class="chat-input-area">
<input type="text" id="chat-input" class="chat-input" placeholder="Digite seu comando..." onkeypress="handleKeyPress(event)"> <!-- Coluna 2: Painel de Insights (Refinado) -->
<button class="btn btn-primary" onclick="sendMessage()"> <div class="insights-wrapper">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div class="insights-header">
<line x1="22" y1="2" x2="11" y2="13"/> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<polygon points="22 2 15 22 11 13 2 9 22 2"/> <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> </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> </div>
</div> </div>
@@ -794,12 +922,36 @@
body: JSON.stringify({ text }) body: JSON.stringify({ text })
}); });
const data = await res.json(); const data = await res.json();
addBubble(data.reply, 'ai'); processAIReply(data.reply);
} catch (e) { } catch (e) {
addBubble('Erro ao contatar servidor.', 'ai'); 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) { function handleKeyPress(e) {
if (e.key === 'Enter') sendChat(); if (e.key === 'Enter') sendChat();
} }
@@ -880,6 +1032,81 @@
window.testLLMSpeed = testLLMSpeed; window.testLLMSpeed = testLLMSpeed;
window.attemptLogin = attemptLogin; 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 // Auto-login se já tiver senha salva
if (webPassword) { if (webPassword) {
(async () => { (async () => {

View File

@@ -5,13 +5,13 @@ import psutil
def run_bash_command(command: str) -> str: def run_bash_command(command: str) -> str:
"""Executa um comando bash na VPS e retorna a saída.""" """Executa um comando bash na VPS e retorna a saída."""
try: try:
# Executa comando arriscado com root de forma segura dentro da VPS # Executa comando de forma segura dentro da VPS
result = subprocess.run( result = subprocess.run(
command, command,
shell=True, shell=True,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=60 # Limite de tempo de execução timeout=120 # Aumentado para comandos mais pesados
) )
output = result.stdout.strip() output = result.stdout.strip()
error = result.stderr.strip() error = result.stderr.strip()
@@ -32,14 +32,37 @@ def get_system_health() -> str:
disk = psutil.disk_usage('/').percent disk = psutil.disk_usage('/').percent
return f"CPU: {cpu}% | RAM Usada: {ram}% | Disco Usado: {disk}%" 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 = { AVAILABLE_TOOLS = {
"run_bash_command": { "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 "func": run_bash_command
}, },
"get_system_health": { "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 "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
} }
} }