Files
BotVPS/templates/index.html
admtracksteel 9762e9b76c feat: add VNC toggle to dashboard
- Add x11vnc + noVNC container with Traefik reverse proxy
- Add /api/vnc_status and toggle_vnc action to FastAPI
- Add VNC toggle button to BotVPS dashboard
- VNC off by default, controlled via dashboard
2026-05-03 19:42:00 +00:00

1613 lines
62 KiB
HTML

<!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,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%A4%96%3C/text%3E%3C/svg%3E">
<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;
--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;
}
/* 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 / Modern Browsers */
@supports (scrollbar-width: thin) {
* {
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;
}
}
[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);
}
/* Global SVG Presentation */
svg {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
* {
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 & 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;
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 {
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;
}
}
#login-overlay {
position: fixed;
inset: 0;
background: #060a12;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
.login-card {
width: 90%;
max-width: 400px;
padding: 2.5rem;
text-align: center;
border: 1px solid var(--accent);
box-shadow: 0 0 30px var(--accent-glow);
background: var(--bg-card);
}
.login-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.login-title {
margin-bottom: 1rem;
font-size: 1.5rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-desc {
color: var(--text-muted);
margin-bottom: 1.5rem;
font-size: 0.85rem;
}
.login-input {
margin-bottom: 1rem;
text-align: center;
font-size: 1.1rem;
letter-spacing: 0.2rem;
}
/* Utilities */
.hidden { display: none !important; }
.w-full { width: 100%; }
.text-center { text-align: center; }
.mt-2 { margin-top: 0.5rem; }
.opacity-60 { opacity: 0.6; }
.theme-icon { width: 18px; height: 18px; }
.recording-dot {
width: 8px;
height: 8px;
background: var(--danger);
border-radius: 50%;
display: inline-block;
animation: pulse 1s infinite;
}
.mic-active { color: var(--danger) !important; border-color: var(--danger) !important; }
.fade-in { animation: fadeIn 0.4s ease-out; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Novas classes para substituir estilos inline */
.login-error { margin-top: 10px; font-size: 0.75rem; color: var(--danger); font-weight: bold; text-transform: uppercase; }
.status-bar-info { margin-bottom: 1rem; padding: 0.75rem; background: var(--bg-input); border-radius: 8px; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.status-bar-success { margin-bottom: 1rem; padding: 0.5rem 0.75rem; background: var(--success); opacity: 0.15; border-radius: 6px; font-size: 0.75rem; }
.btn-xs { padding: 0.4rem 0.75rem; font-size: 0.75rem; }
.config-section-card { padding: 1rem; background: var(--bg-input); border-radius: 8px; border: 1px solid var(--border); }
.config-section-title { margin-bottom: 0.75rem; font-size: 0.9rem; display: flex; align-items: center; gap: 0.25rem; }
.config-section-title.accent { color: var(--accent); }
.config-section-title.success { color: var(--success); }
.config-section-icon { vertical-align: middle; }
.mt-4 { margin-top: 1rem; }
.flex-between { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.text-sm-muted { font-size: 0.85rem; color: var(--text-muted); }
.scroll-y-auto { overflow-y: auto; }
.max-h-300 { max-height: 300px; }
.p-4 { padding: 1rem; }
.chat-input-container { padding: 0.75rem; gap: 0.5rem; }
</style>
</head>
<body>
<!-- Login Suspenso -->
<div id="login-overlay">
<div class="login-card fade-in">
<div class="login-icon">🔒</div>
<h2 class="login-title">BOT VPS TERMINAL</h2>
<p class="login-desc">DNS restrito. Digite a senha de acesso.</p>
<input type="password" id="login-pass" class="form-input login-input" placeholder="••••••••" autofocus>
<button type="button" class="btn btn-primary w-full mt-2" onclick="checkAuth()">ACESSAR HUB</button>
<div id="login-error" class="hidden login-error">Senha Incorreta</div>
</div>
</div>
<div class="container">
<header>
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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 type="button" class="theme-toggle" onclick="toggleTheme()" aria-label="Alternar tema">
<svg id="icon-sun" class="theme-icon hidden" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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" class="theme-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</button>
</div>
</header>
<main>
<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"></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"></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"></div>
</div>
</div>
</div>
<div class="section-title">Ações Rápidas</div>
<div class="actions-grid">
<button type="button" class="btn" onclick="executeAction('ping')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
Ping
</button>
<button type="button" class="btn" id="btn-test-llm" onclick="testLLMSpeed()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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 type="button" class="btn" onclick="executeAction('restart_bot')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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 type="button" class="btn" onclick="executeAction('clear_cache')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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 type="button" class="btn btn-danger" onclick="executeAction('reboot_vps')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
Reboot VPS
</button>
<button type="button" class="btn" id="vnc-toggle-btn" onclick="executeAction('toggle_vnc')">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span id="vnc-toggle-text">Ligar VNC</span>
</button>
</div>
<div class="section-title">Configuração AI</div>
<div class="card">
<div class="config-grid">
<div class="form-group">
<label for="active_provider">Provider Ativo</label>
<select id="active_provider" aria-label="Provider Ativo" class="form-input" onchange="toggleProviderFields()">
<option value="minimax">MiniMax (Hermes - Externo)</option>
<option value="openrouter">OpenRouter (Externo)</option>
<option value="ollama">Ollama (Local-Interno)</option>
</select>
</div>
<div class="form-group" id="group-openrouter-model">
<label for="openrouter_model">Modelo OpenRouter</label>
<input type="text" id="openrouter_model" class="form-input" placeholder="ex: qwen/qwen-2.5-72b-instruct">
</div>
</div>
<button type="button" class="btn btn-primary" onclick="saveConfiguration()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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>
<!-- ORCHESTRATOR CONFIG -->
<div class="section-title">Orquestrador AI (Planner + Executor)</div>
<div class="card">
<div class="status-bar-info">
<strong>Status:</strong> <span id="orchestrator-status">Carregando...</span>
<button type="button" class="btn btn-xs" onclick="syncFromRepo()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14"><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>
Sync Repo
</button>
<button type="button" class="btn btn-xs" onclick="syncCredentials()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14"><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>
Sync Local
</button>
</div>
<div class="status-bar-success">
<strong>Repo Gitea:</strong> <span id="repo-status">Carregando...</span>
</div>
<div class="config-grid">
<!-- PLANNER CONFIG -->
<div class="config-section-card">
<h4 class="config-section-title accent">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" class="config-section-icon"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Planner (Planejador)
</h4>
<div class="form-group">
<label for="planner_provider">Provider</label>
<select id="planner_provider" aria-label="Provider do Planner" class="form-input" onchange="loadPlannerModels()">
<option value="gemini">Gemini (Google)</option>
<option value="openai">OpenAI (GPT-4)</option>
<option value="anthropic">Anthropic (Claude)</option>
<option value="ollama">Ollama (Local)</option>
</select>
</div>
<div class="form-group">
<label for="planner_model">Modelo</label>
<select id="planner_model" aria-label="Modelo do Planner" class="form-input">
<option value="">Carregando...</option>
</select>
</div>
</div>
<!-- EXECUTOR CONFIG -->
<div class="config-section-card">
<h4 class="config-section-title success">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" class="config-section-icon"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Executor (Executor)
</h4>
<div class="form-group">
<label for="executor_provider">Provider</label>
<select id="executor_provider" aria-label="Provider do Executor" class="form-input" onchange="loadExecutorModels()">
<option value="ollama" selected>Ollama (Local)</option>
<option value="gemini">Gemini (Google)</option>
</select>
</div>
<div class="form-group">
<label for="executor_model">Modelo</label>
<select id="executor_model" aria-label="Modelo do Executor" class="form-input">
<option value="">Carregando...</option>
</select>
</div>
</div>
</div>
<div class="mt-4">
<button type="button" class="btn btn-primary" onclick="saveLLMConfig()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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 Config LLM
</button>
</div>
</div>
<!-- CREDENCIAIS CARREGADAS -->
<div class="section-title">Credenciais Carregadas</div>
<div class="card">
<div class="flex-between">
<span class="text-sm-muted">
Credenciais sincronizadas dos serviços (Coolify, Gitea, Supabase, etc)
</span>
<button type="button" class="btn btn-xs" onclick="loadCredentials()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14">
<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>
Atualizar
</button>
</div>
<div id="credentials-container" class="hidden">
<div id="credentials-list" class="max-h-300 scroll-y-auto"></div>
</div>
<div id="credentials-loading" class="text-center p-4 text-sm-muted">
Clique em "Atualizar" para carregar as credenciais
</div>
</div>
<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 chat-input-container">
<input type="text" id="chat-input" aria-label="Comando do chat" class="chat-input"
placeholder="Comande sua VPS aqui..." onkeypress="handleKeyPress(event)">
<button type="button" class="btn" id="audio-btn" aria-label="Gravar Áudio"
onclick="toggleRecording()" title="Gravar Áudio">
<svg id="mic-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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>
<span id="recording-dot" class="recording-dot hidden"></span>
</button>
<button type="button" class="btn btn-primary" aria-label="Enviar" onclick="sendChat()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<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>
<!-- Coluna 2: Painel de Insights (Refinado) -->
<div class="insights-wrapper">
<div class="insights-header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 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>
Painel de Insights Visuais
</div>
<div class="insights-content" id="insights-panel">
<div class="insights-placeholder">
<div class="login-icon" role="img" aria-label="Gráfico de Insights">📊</div>
<p>Aguardando dados estruturados...</p>
<small class="mt-2 opacity-60">Peça algo como "status dos containers" para ver o refinamento aqui.</small>
</div>
</div>
</div>
</main>
</div>
<div id="toast">Ação executada!</div>
<script>
let webPassword = '1234'; // Senha padrão suspensa
let chatHistory = []; // Memória da conversa na Web
// Helper para chamadas de API com autenticação
async function apiFetch(url, options = {}) {
if (!options.headers) options.headers = {};
options.headers['X-Web-Password'] = webPassword;
const res = await fetch(url, options);
return res;
}
function initDashboard() {
fetchStats();
loadConfig();
loadOrchestratorStatus();
loadVNCStatus();
loadLLMModels().then(function() {
loadLLMConfig();
});
}
async function loadVNCStatus() {
try {
const res = await apiFetch('/api/vnc_status');
const data = await res.json();
const btn = document.getElementById('vnc-toggle-btn');
const txt = document.getElementById('vnc-toggle-text');
if (data.vnc_status === 'on') {
txt.textContent = 'Desligar VNC';
btn.classList.add('btn-success');
} else {
txt.textContent = 'Ligar VNC';
btn.classList.remove('btn-success');
}
} catch (e) {}
}
// Theme management
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(isLight) {
const sun = document.getElementById('icon-sun');
const moon = document.getElementById('icon-moon');
if (isLight) {
sun.classList.remove('hidden');
moon.classList.add('hidden');
} else {
sun.classList.add('hidden');
moon.classList.remove('hidden');
}
}
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.dataset.theme = savedTheme;
updateThemeIcon(savedTheme === 'light');
}
// Stats
async function fetchStats() {
try {
const res = await apiFetch('/api/status');
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);
// Actions
async function executeAction(type) {
const messages = {
reboot_vps: '⚠️ Confirma reboot CRÍTICO da VPS?',
clear_cache: 'Limpar cache do servidor?',
restart_bot: 'Reiniciar o agente AI?',
toggle_vnc: null
};
if (messages[type] && !confirm(messages[type])) return;
try {
const res = await apiFetch('/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');
if (!toast) return;
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 apiFetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, history: chatHistory })
});
const data = await res.json();
processAIReply(data.reply);
} catch (e) {
addBubble('Erro ao contatar servidor.', 'ai');
}
}
function processAIReply(fullText) {
// Separa a parte técnica do resumo (RESUMO:)
const refinedMatch = fullText.match(/RESUMO:\s*([\s\S]*)/i);
let technicalPart = fullText;
if (refinedMatch) {
technicalPart = fullText.substring(0, refinedMatch.index).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;
// Garante que temos a senha (tenta pegar do global ou do localStorage se precisar)
const activePwd = webPassword || localStorage.getItem('vps_web_password') || '';
// Transforma caminhos da VPS (![alt](/host_root/...) em links da nossa API segura
// Ex: /host_root/root/img.jpg -> /api/host_file?pwd=...&path=/host_root/root/img.jpg
let mdWithAuth = markdown.replace(/!\[(.*?)\]\((\/host_root\/.*?)\)/g, (match, alt, path) => {
return `![${alt}](/api/host_file?pwd=${encodeURIComponent(activePwd)}&path=${encodeURIComponent(path)})`;
});
// Fallback para qualquer outro caso que tenha sobrado do prefixo antigo
mdWithAuth = mdWithAuth.replace(/\/api\/host_file\?path=/g, '/api/host_file?pwd=' + encodeURIComponent(activePwd) + '&path=');
// Renderiza o Markdown para HTML usando marked.js
panel.innerHTML = `<div class="animate-fade-in">${marked.parse(mdWithAuth)}</div>`;
}
function handleKeyPress(e) {
if (e.key === 'Enter') sendChat();
}
function addBubble(text, sender) {
const box = document.getElementById('chat-box');
if (!box) return;
const div = document.createElement('div');
div.className = 'chat-bubble bubble-' + sender;
div.textContent = text;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
// Atualiza o histórico local (limita a 10 mensagens)
if (sender === 'user') {
chatHistory.push({ user: text, bot: "" });
} else if (chatHistory.length > 0) {
chatHistory[chatHistory.length - 1].bot = text;
}
if (chatHistory.length > 10) chatHistory.shift();
}
async function loadConfig() {
try {
const res = await apiFetch('/api/config');
const data = await res.json();
const provider = document.getElementById('active_provider');
const model = document.getElementById('openrouter_model');
if (provider) provider.value = data.active_provider || 'minimax';
if (model) model.value = data.openrouter_model || 'qwen/qwen-2.5-72b-instruct';
toggleProviderFields();
} catch (e) { }
}
function toggleProviderFields() {
const provider = document.getElementById('active_provider').value;
const modelGroup = document.getElementById('group-openrouter-model');
if (modelGroup) {
modelGroup.style.display = (provider === 'openrouter') ? 'block' : 'none';
}
}
async function saveConfig() {
const provider = document.getElementById('active_provider').value;
const model = document.getElementById('openrouter_model').value.trim();
try {
const res = await apiFetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active_provider: provider, openrouter_model: model })
});
if (res.ok) {
showToast('Configurações salvas!');
loadConfig();
} else {
throw new Error();
}
} catch (e) {
showToast('Erro ao salvar.', true);
}
}
// ================== ORCHESTRATOR LLM CONFIG ==================
let availableModels = {};
async function loadOrchestratorStatus() {
try {
const res = await apiFetch('/api/orchestrator-status');
const data = await res.json();
const statusEl = document.getElementById('orchestrator-status');
if (statusEl) {
statusEl.innerHTML = '<strong>Planner:</strong> ' + data.planner.name + ' | <strong>Executor:</strong> ' + data.executor.name + ' | <strong>Ferramentas:</strong> ' + data.available_tools;
}
// Repo status
const repoEl = document.getElementById('repo-status');
if (repoEl) {
const creds = data.credentials || {};
const giteaRepo = creds.gitea_repo || {};
if (giteaRepo.available) {
repoEl.innerHTML = '<span style="color: var(--success);">✅ Online</span> - ' + giteaRepo.services_count + ' serviço(s) disponível(is)';
} else {
repoEl.innerHTML = '<span style="color: var(--text-muted);">⏳ Sincronizando...</span>';
}
}
} catch (e) {
const statusEl = document.getElementById('orchestrator-status');
if (statusEl) statusEl.textContent = 'Erro ao carregar';
}
}
async function syncFromRepo() {
try {
const res = await apiFetch('/api/sync-from-repo', { method: 'POST' });
const data = await res.json();
if (data.status === 'synced') {
showToast('Repo sincronizado! ' + data.services_count + ' serviços carregados');
} else {
showToast('Erro ao sincronizar repo: ' + data.status, true);
}
loadOrchestratorStatus();
loadCredentials();
} catch (e) {
showToast('Erro ao sincronizar repo.', true);
}
}
async function loadLLMModels() {
try {
const res = await apiFetch('/api/llm-models');
availableModels = await res.json();
} catch (e) {
console.error('Erro ao carregar modelos:', e);
}
}
async function loadLLMConfig() {
try {
const res = await apiFetch('/api/llm-config');
const data = await res.json();
// Planner
const plannerProvider = document.getElementById('planner_provider');
const plannerModel = document.getElementById('planner_model');
if (plannerProvider) plannerProvider.value = data.planner.provider;
await loadPlannerModels();
if (plannerModel && data.planner.model) {
plannerModel.value = data.planner.model;
}
// Executor
const executorProvider = document.getElementById('executor_provider');
const executorModel = document.getElementById('executor_model');
if (executorProvider) executorProvider.value = data.executor.provider;
await loadExecutorModels();
if (executorModel && data.executor.model) {
executorModel.value = data.executor.model;
}
} catch (e) {
console.error('Erro ao carregar config LLM:', e);
}
}
function populateModelSelect(selectEl, models) {
if (!selectEl) return;
selectEl.innerHTML = '';
if (!models || models.length === 0) {
selectEl.innerHTML = '<option value="">Nenhum modelo</option>';
return;
}
models.forEach(function(model) {
const opt = document.createElement('option');
opt.value = model;
opt.textContent = model;
selectEl.appendChild(opt);
});
}
async function loadPlannerModels() {
const provider = document.getElementById('planner_provider')?.value;
const modelSelect = document.getElementById('planner_model');
if (!modelSelect) return;
modelSelect.innerHTML = '<option value="">Carregando...</option>';
if (provider === 'ollama') {
const models = availableModels.models?.find(function(p) { return p.provider === 'ollama'; })?.models || [];
populateModelSelect(modelSelect, models.length > 0 ? models : ['qwen2.5-coder:1.5b', 'llama3.1:8b', 'codellama:13b']);
} else {
const fixedModels = {
gemini: ['gemini-2.5-flash', 'gemini-2.0-pro', 'gemini-1.5-flash'],
openai: ['gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229']
};
populateModelSelect(modelSelect, fixedModels[provider] || []);
}
}
async function loadExecutorModels() {
const provider = document.getElementById('executor_provider')?.value;
const modelSelect = document.getElementById('executor_model');
if (!modelSelect) return;
modelSelect.innerHTML = '<option value="">Carregando...</option>';
if (provider === 'ollama') {
const models = availableModels.models?.find(function(p) { return p.provider === 'ollama'; })?.models || [];
populateModelSelect(modelSelect, models.length > 0 ? models : ['qwen2.5-coder:1.5b', 'llama3.1:8b', 'codellama:13b']);
} else if (provider === 'gemini') {
populateModelSelect(modelSelect, ['gemini-2.5-flash', 'gemini-2.0-pro']);
} else {
populateModelSelect(modelSelect, []);
}
}
async function saveLLMConfig() {
const plannerProvider = document.getElementById('planner_provider')?.value;
const plannerModel = document.getElementById('planner_model')?.value;
const executorProvider = document.getElementById('executor_provider')?.value;
const executorModel = document.getElementById('executor_model')?.value;
try {
const res = await apiFetch('/api/llm-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
planner_provider: plannerProvider,
planner_model: plannerModel,
executor_provider: executorProvider,
executor_model: executorModel
})
});
if (res.ok) {
showToast('Config LLM salva! Planner: ' + plannerProvider + ', Executor: ' + executorProvider);
loadOrchestratorStatus();
} else {
throw new Error();
}
} catch (e) {
showToast('Erro ao salvar config LLM.', true);
}
}
async function syncCredentials() {
try {
const res = await apiFetch('/api/sync-credentials', {
method: 'POST'
});
const data = await res.json();
showToast('Credenciais sincronizadas! Services: ' + Object.keys(data.services || {}).length);
} catch (e) {
showToast('Erro ao sincronizar.', true);
}
}
async function loadCredentials() {
const loadingEl = document.getElementById('credentials-loading');
const containerEl = document.getElementById('credentials-container');
const listEl = document.getElementById('credentials-list');
if (loadingEl) loadingEl.innerHTML = 'Carregando...';
try {
// Sync first
await apiFetch('/api/sync-credentials', { method: 'POST' });
// Get orchestrator status which has services info
const res = await apiFetch('/api/orchestrator-status');
const data = await res.json();
const services = data.credentials || {};
const serviceNames = {
coolify: 'Coolify (Orquestrador)',
supabase: 'Supabase (BaaS)',
gitea: 'Gitea (Git Server)',
logto: 'Logto (Autenticação)'
};
let html = '<div style="display: grid; gap: 1rem;">';
for (const [key, info] of Object.entries(services)) {
const name = serviceNames[key] || key;
const status = info.exists ? '<span style="color: var(--success);">Disponivel</span>' : '<span style="color: var(--danger);">Nao disponivel</span>';
const keysCount = info.keys_count || 0;
html += `
<div style="padding: 1rem; background: var(--bg-input); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<strong style="color: var(--accent);">${name}</strong>
${status}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.5rem;">
Caminho: <code style="background: var(--bg-base); padding: 0.1rem 0.3rem; border-radius: 4px;">${info.path || 'N/A'}</code>
</div>
<div style="font-size: 0.75rem; color: var(--text-muted);">
${keysCount} chave(s) encontrada(s)
</div>
</div>
`;
}
html += '</div>';
if (loadingEl) loadingEl.style.display = 'none';
if (containerEl) containerEl.style.display = 'block';
if (listEl) listEl.innerHTML = html;
} catch (e) {
if (loadingEl) {
loadingEl.innerHTML = 'Erro ao carregar credenciais';
loadingEl.style.color = 'var(--danger)';
}
console.error('Erro ao carregar credenciais:', e);
}
}
async function copyToClipboard(text, label) {
try {
await navigator.clipboard.writeText(text);
showToast(label + ' copiado!');
} catch (e) {
showToast('Erro ao copiar.', true);
}
}
async function testLLMSpeed() {
const btn = document.getElementById('btn-test-llm');
const originalContent = btn.innerHTML;
btn.innerHTML = '⚡ Testando...';
btn.disabled = true;
try {
const res = await apiFetch('/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.saveConfiguration = saveConfig;
window.sendMessage = sendChat;
window.executeAction = executeAction;
window.testLLMSpeed = testLLMSpeed;
// --- 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 btn = document.getElementById('audio-btn');
if (active) {
dot.classList.remove('hidden');
btn.classList.add('mic-active');
} else {
dot.classList.add('hidden');
btn.classList.remove('mic-active');
}
}
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;
function checkAuth() {
const pass = document.getElementById('login-pass').value;
if (pass === '@@Gi05Br;;') {
sessionStorage.setItem('botAuth', 'true');
document.getElementById('login-overlay').classList.add('hidden');
initDashboard();
} else {
document.getElementById('login-error').classList.remove('hidden');
setTimeout(() => document.getElementById('login-error').classList.add('hidden'), 2000);
}
}
// Enter key support
document.getElementById('login-pass').addEventListener('keypress', (e) => {
if (e.key === 'Enter') checkAuth();
});
// Skip auth if already logged in via session
if (sessionStorage.getItem('botAuth') === 'true') {
document.getElementById('login-overlay').classList.add('hidden');
initDashboard();
}
</script>
</body>
</html>