THCLLM / index.html
Wilker
feat: adiciona autenticaΓ§Γ£o Google OAuth com whitelist de e-mails e API key pessoal para a CLI
bb5add3
Raw
History Blame Contribute Delete
50.1 kB
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>THC LLM</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
:root {
--bg: #0a0a0a;
--surface: #111111;
--surface2: #1a1a1a;
--border: #222222;
--accent: #00e5a0;
--accent2: #00b37e;
--text: #e8e8e8;
--muted: #555555;
--danger: #ff4d4d;
--sidebar-w: 260px;
}
html, body { height: 100%; overflow: hidden; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
display: flex;
height: 100vh;
height: 100dvh;
}
button, .conv-item, .suggestion { -webkit-user-select: none; user-select: none; }
/* ══════════ SIDEBAR ══════════ */
#sidebar {
width: var(--sidebar-w); min-width: var(--sidebar-w);
background: var(--surface);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
transition: margin-left 0.3s ease;
z-index: 100;
}
#sidebar.collapsed { margin-left: calc(-1 * var(--sidebar-w)); }
.sidebar-header { padding: 20px 16px 12px; border-bottom: 1px solid var(--border); }
.sidebar-header h2 { font-size: 15px; font-weight: 700; color: var(--accent); margin-bottom: 12px; }
#new-chat-btn {
width: 100%; background: var(--accent); color: #000;
border: none; border-radius: 8px; padding: 10px 12px;
font-size: 13px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; gap: 6px;
}
#new-chat-btn:active { background: var(--accent2); }
#conversations { flex: 1; overflow-y: auto; padding: 8px; -webkit-overflow-scrolling: touch; }
.conv-item {
padding: 12px; border-radius: 8px; cursor: pointer;
font-size: 13px; display: flex; align-items: center;
justify-content: space-between; gap: 8px;
transition: background 0.15s; margin-bottom: 2px;
}
.conv-item:active, .conv-item.active { background: var(--surface2); }
.conv-item.active { border-left: 2px solid var(--accent); }
.conv-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.conv-delete {
background: none; border: none; color: var(--danger);
cursor: pointer; font-size: 16px; padding: 4px 8px; border-radius: 4px;
opacity: 0.6;
}
/* ══════════ SETTINGS ══════════ */
.settings-panel {
padding: 12px 16px; border-top: 1px solid var(--border);
display: flex; flex-direction: column; gap: 14px;
overflow-y: auto; -webkit-overflow-scrolling: touch;
}
.settings-panel h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }
.slider-group label { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 6px; }
.slider-group label span { color: var(--accent); font-weight: 600; }
input[type=range] { width: 100%; accent-color: var(--accent); height: 20px; }
.settings-panel textarea {
width: 100%; background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: 13px;
padding: 8px 10px; resize: none; outline: none; height: 72px; font-family: inherit;
}
.settings-panel textarea:focus { border-color: var(--accent); }
.toggle-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; }
.toggle {
width: 40px; height: 24px; background: var(--border);
border-radius: 20px; position: relative; cursor: pointer;
transition: background 0.2s; border: none; flex-shrink: 0;
}
.toggle.on { background: var(--accent); }
.toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 20px; height: 20px; border-radius: 50%; background: #fff;
transition: left 0.2s;
}
.toggle.on::after { left: 18px; }
select {
width: 100%; background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 8px; padding: 8px; font-size: 13px; outline: none;
}
.ios-notice {
font-size: 11px; color: #d9a441; background: #2a2010;
border: 1px solid #4a3a1a; border-radius: 8px; padding: 8px 10px;
display: none; line-height: 1.5;
}
.ios-notice.show { display: block; }
/* ══════════ SIDEBAR OVERLAY (mobile) ══════════ */
#overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.5); z-index: 90;
}
#overlay.show { display: block; }
/* ══════════ MAIN ══════════ */
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
#topbar {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 12px 16px; display: flex; align-items: center; gap: 10px;
flex-shrink: 0;
}
#toggle-sidebar {
background: none; border: none; color: var(--muted); cursor: pointer;
font-size: 20px; border-radius: 6px; padding: 6px; flex-shrink: 0;
}
.topbar-info { flex: 1; min-width: 0; }
.topbar-info h1 { font-size: 14px; font-weight: 700; color: var(--accent); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topbar-info p { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--accent); box-shadow: 0 0 6px var(--accent);
animation: pulse 2s infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
#stats-bar {
background: var(--surface2); border-bottom: 1px solid var(--border);
padding: 6px 16px; display: flex; gap: 16px; font-size: 10px; color: var(--muted);
flex-wrap: wrap; flex-shrink: 0; overflow-x: auto; white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
#stats-bar span { color: var(--accent); font-weight: 600; }
/* ══════════ QUICK BAR (Model + Mode) ══════════ */
#quick-bar {
display: flex; gap: 8px; align-items: center;
padding: 8px 12px; background: var(--surface);
border-bottom: 1px solid var(--border);
flex-wrap: wrap; flex-shrink: 0;
}
#model-select {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 8px; padding: 7px 10px;
font-size: 12px; outline: none; cursor: pointer; flex: 1; min-width: 140px;
}
#model-select:focus { border-color: var(--accent); }
.mode-pills { display: flex; gap: 4px; flex-shrink: 0; flex-wrap: wrap; }
.mode-pill {
background: var(--surface2); border: 1px solid var(--border); color: var(--muted);
border-radius: 8px; padding: 7px 10px; font-size: 11px; cursor: pointer;
transition: all 0.15s; white-space: nowrap;
}
.mode-pill.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
#web-toggle.active { background: #2b7fff; color: #fff; border-color: #2b7fff; }
.model-loading-hint {
font-size: 10px; color: var(--muted); padding: 0 12px 6px;
display: none; font-style: italic; background: var(--surface); flex-shrink: 0;
}
.model-loading-hint.show { display: block; }
/* ══════════ CHAT ══════════ */
#chat {
flex: 1; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 16px;
-webkit-overflow-scrolling: touch;
}
#chat::-webkit-scrollbar { width: 4px; }
#chat::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.msg-wrapper { display: flex; flex-direction: column; gap: 4px; max-width: 100%; }
.msg-wrapper.user { align-items: flex-end; }
.msg-wrapper.assistant { align-items: flex-start; }
.msg-meta { font-size: 10px; color: var(--muted); padding: 0 4px; display: flex; gap: 8px; align-items: center; }
.msg {
max-width: 85%; padding: 12px 14px; border-radius: 16px;
font-size: 14px; line-height: 1.6; white-space: pre-wrap; word-break: break-word;
}
.msg.user { background: var(--accent); color: #000; border-bottom-right-radius: 4px; font-weight: 500; }
.msg.assistant { background: var(--surface2); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 4px; }
.msg.thinking { background: var(--surface2); color: var(--muted); border: 1px solid var(--border); border-bottom-left-radius: 4px; font-style: italic; }
.dots::after { content:''; animation: dots 1.2s steps(3,end) infinite; }
@keyframes dots { 0%{content:''} 33%{content:'.'} 66%{content:'..'} 100%{content:'...'} }
.action-btn {
background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 12px; padding: 4px 8px;
border-radius: 4px;
}
.action-btn:active { color: var(--accent); }
.welcome { margin: auto; text-align: center; color: var(--muted); padding: 20px; }
.welcome h2 { font-size: 28px; color: var(--accent); margin-bottom: 8px; }
.welcome p { font-size: 13px; margin-bottom: 20px; }
.suggestions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 480px; }
.suggestion {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 20px; padding: 10px 14px; font-size: 12px;
color: var(--text); cursor: pointer;
}
.suggestion:active { border-color: var(--accent); color: var(--accent); }
/* ══════════ INPUT ══════════ */
#input-area {
padding: 12px; background: var(--surface);
border-top: 1px solid var(--border);
display: flex; gap: 8px; align-items: flex-end;
flex-shrink: 0;
padding-bottom: max(12px, env(safe-area-inset-bottom));
}
.input-wrap { flex: 1; display: flex; flex-direction: column; min-width: 0; }
#msg {
flex: 1; background: var(--surface2); border: 1px solid var(--border);
border-radius: 12px; color: var(--text); padding: 12px 14px;
font-size: 16px;
resize: none; outline: none; max-height: 120px;
line-height: 1.5; font-family: inherit;
}
#msg:focus { border-color: var(--accent); }
.input-hint { font-size: 10px; color: var(--muted); padding: 4px 4px 0; display: none; }
#mic-transcript { font-size: 11px; color: var(--muted); padding: 4px 4px 0; min-height: 14px; font-style: italic; }
.action-buttons { display: flex; gap: 8px; align-items: flex-end; flex-shrink: 0; }
#mic-btn, #send-btn {
width: 44px; height: 44px; border-radius: 12px;
font-size: 19px; cursor: pointer; display: flex;
align-items: center; justify-content: center;
flex-shrink: 0; border: none;
}
#mic-btn { background: var(--surface2); border: 1px solid var(--border); color: var(--text); }
#mic-btn.listening { background: var(--danger); border-color: var(--danger); color: #fff; animation: mic-pulse 1s infinite; }
#mic-btn.unsupported { opacity: 0.35; }
@keyframes mic-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,77,77,.4)} 50%{box-shadow:0 0 0 8px rgba(255,77,77,0)} }
#send-btn { background: var(--accent); color: #000; }
#send-btn:disabled { opacity: 0.35; }
/* ══════════ SPEAKING BAR ══════════ */
#speaking-bar {
display: none; align-items: center; gap: 8px;
padding: 8px 16px; background: #0d1f17;
border-top: 1px solid #0a3326; font-size: 12px; color: var(--accent);
flex-shrink: 0;
}
#speaking-bar.active { display: flex; }
.wave { display: flex; gap: 3px; align-items: center; }
.wave span { width: 3px; background: var(--accent); border-radius: 2px; animation: wave 0.8s ease-in-out infinite; }
.wave span:nth-child(1){height:8px;animation-delay:0s}
.wave span:nth-child(2){height:16px;animation-delay:.1s}
.wave span:nth-child(3){height:12px;animation-delay:.2s}
.wave span:nth-child(4){height:20px;animation-delay:.3s}
.wave span:nth-child(5){height:10px;animation-delay:.4s}
@keyframes wave { 0%,100%{transform:scaleY(1)} 50%{transform:scaleY(.3)} }
#stop-speak-btn {
margin-left: auto; background: none; border: 1px solid var(--accent);
color: var(--accent); border-radius: 6px; padding: 5px 12px;
font-size: 11px; cursor: pointer;
}
/* ══════════ TABS (Chat / Imagem) ══════════ */
.mode-tabs { display: flex; gap: 4px; margin-left: 8px; flex-shrink: 0; }
.mode-tab {
background: none; border: 1px solid var(--border); color: var(--muted);
border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer;
transition: all 0.2s; white-space: nowrap;
}
.mode-tab.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
#image-view, #audio-view { display: none; flex: 1; flex-direction: column; overflow: hidden; }
#image-view.active, #audio-view.active { display: flex; }
#image-prompt-area, #audio-prompt-area {
padding: 16px; background: var(--surface); border-bottom: 1px solid var(--border);
display: flex; flex-direction: column; gap: 10px; flex-shrink: 0;
}
#image-prompt, #audio-prompt {
width: 100%; background: var(--surface2); border: 1px solid var(--border);
border-radius: 12px; color: var(--text); padding: 12px 14px;
font-size: 16px; resize: none; outline: none; font-family: inherit; height: 60px;
}
#image-prompt:focus, #audio-prompt:focus { border-color: var(--accent); }
.image-controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.image-controls .slider-group { flex: 1; min-width: 140px; }
.image-controls label { font-size: 11px; }
#generate-img-btn, #generate-audio-btn {
background: var(--accent); color: #000; border: none; border-radius: 10px;
padding: 10px 20px; font-size: 13px; font-weight: 600; cursor: pointer; flex-shrink: 0;
}
#generate-img-btn:disabled, #generate-audio-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#image-gallery {
flex: 1; overflow-y: auto; padding: 16px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px; align-content: start; -webkit-overflow-scrolling: touch;
}
#audio-gallery {
flex: 1; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 12px;
-webkit-overflow-scrolling: touch;
}
.audio-card {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 12px; padding: 12px; display: flex; flex-direction: column; gap: 8px;
}
.audio-card audio { width: 100%; }
.audio-card-prompt { font-size: 12px; color: var(--muted); }
.image-card {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 12px; overflow: hidden; display: flex; flex-direction: column;
}
.image-card img { width: 100%; display: block; aspect-ratio: 1; object-fit: cover; }
.image-card-footer {
padding: 8px 10px; display: flex; justify-content: space-between;
align-items: center; gap: 8px;
}
.image-card-prompt {
font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; flex: 1;
}
.image-card-actions { display: flex; gap: 4px; flex-shrink: 0; }
.img-loading-card {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 12px; aspect-ratio: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 10px; color: var(--muted); font-size: 12px;
}
.spinner {
width: 32px; height: 32px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.img-welcome { margin: auto; text-align: center; color: var(--muted); padding: 20px; grid-column: 1/-1; }
.img-welcome h2 { font-size: 28px; color: var(--accent); margin-bottom: 8px; }
/* ══════════ RESPONSIVE β€” TABLET / MOBILE ══════════ */
@media (max-width: 860px) {
#sidebar {
position: fixed; top: 0; bottom: 0; left: 0;
margin-left: calc(-1 * var(--sidebar-w));
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
}
#sidebar.mobile-open { margin-left: 0; }
.input-hint { display: none; }
.msg { max-width: 90%; }
.welcome h2 { font-size: 24px; }
#stats-bar { font-size: 9px; gap: 12px; padding: 6px 12px; }
#topbar { padding: 10px 12px; flex-wrap: wrap; }
.mode-tabs { order: 3; width: 100%; margin-left: 0; margin-top: 4px; }
}
@media (max-width: 480px) {
.msg { max-width: 92%; font-size: 13.5px; }
.sidebar-header, .settings-panel { padding-left: 14px; padding-right: 14px; }
#chat { padding: 12px; gap: 12px; }
#stats-bar > div:nth-child(n+4) { display: none; }
#image-gallery { grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px; }
.image-controls { flex-direction: column; align-items: stretch; }
#generate-img-btn { width: 100%; }
#quick-bar { padding: 8px; }
#model-select { font-size: 11px; }
.mode-pill { padding: 6px 8px; font-size: 10px; }
}
</style>
</head>
<body>
<div id="overlay" onclick="closeSidebarMobile()"></div>
<!-- SIDEBAR -->
<div id="sidebar">
<div class="sidebar-header">
<h2>πŸ€– THC LLM</h2>
<button id="new-chat-btn" onclick="newChat()">οΌ‹ Nova conversa</button>
</div>
<div id="conversations"></div>
<div class="settings-panel">
<h3>βš™οΈ ConfiguraΓ§Γ΅es</h3>
<div class="slider-group">
<label>Temperature <span id="temp-val">0.7</span></label>
<input type="range" id="temperature" min="0" max="2" step="0.1" value="0.7"
oninput="document.getElementById('temp-val').textContent=this.value">
</div>
<div class="slider-group">
<label>Max Tokens <span id="tokens-val">8192</span></label>
<input type="range" id="max-tokens" min="256" max="16384" step="256" value="8192"
oninput="document.getElementById('tokens-val').textContent=this.value">
</div>
<div>
<label style="font-size:12px;color:var(--muted);display:block;margin-bottom:6px;">System Prompt</label>
<textarea id="system-prompt" placeholder="Ex: VocΓͺ Γ© o assistente da Tec Haze Circuit..."></textarea>
</div>
<h3>πŸ”Š Voz</h3>
<div class="toggle-row">
<span>Auto-falar respostas</span>
<button class="toggle on" id="toggle-tts" onclick="toggleTTS(this)"></button>
</div>
<h3>πŸ”“ Modo Livre</h3>
<div class="toggle-row">
<span>Pular RAG e persona</span>
<button class="toggle" id="free-toggle-sidebar" onclick="setFreeMode(!freeModeEnabled)"></button>
</div>
<div class="slider-group">
<label>Velocidade <span id="rate-val">1.0</span>x</label>
<input type="range" id="voice-rate" min="0.5" max="2" step="0.1" value="1.0"
oninput="document.getElementById('rate-val').textContent=this.value">
</div>
<div class="slider-group">
<label>Tom <span id="pitch-val">1.0</span></label>
<input type="range" id="voice-pitch" min="0.5" max="2" step="0.1" value="1.0"
oninput="document.getElementById('pitch-val').textContent=this.value">
</div>
<div>
<label style="font-size:12px;color:var(--muted);display:block;margin-bottom:6px;">Voz</label>
<select id="voice-select"><option>Carregando vozes...</option></select>
</div>
<div class="ios-notice" id="ios-notice">
πŸ“± No iPhone, o reconhecimento de voz (microfone) nΓ£o Γ© suportado pelo Safari β€” mas a fala do assistente funciona normalmente. Para ditar mensagens, use o microfone do teclado do iOS.
</div>
<h3>⬇ CLI THC</h3>
<p style="font-size:12px;color:var(--muted);margin-bottom:10px;">Use o THC direto do seu terminal Linux.</p>
<button onclick="navigator.clipboard.writeText('curl -sSL https://hulktoigo-thcllm.hf.space/install.sh | bash').then(()=>alert('Comando copiado!'))" style="width:100%;margin-bottom:8px;">πŸ“‹ Copiar comando de instalaΓ§Γ£o</button>
<a href="/download/thc-cli.tar.gz" download style="display:block;text-align:center;font-size:12px;color:var(--muted);text-decoration:underline;">ou baixar .tar.gz manualmente</a>
<a href="/me" target="_blank" style="display:block;text-align:center;font-size:12px;color:var(--accent);margin-top:8px;">πŸ”‘ Ver minha API key pessoal</a>
</div>
</div>
<!-- MAIN -->
<div id="main">
<div id="topbar">
<button id="toggle-sidebar" onclick="toggleSidebar()">☰</button>
<div class="topbar-info">
<h1>Tec Haze Circuit</h1>
<p id="model-label">google/gemma-3-1b-it β€’ CPU</p>
</div>
<div class="mode-tabs">
<button class="mode-tab active" id="tab-chat" onclick="switchMode('chat')">πŸ’¬ Chat</button>
<button class="mode-tab" id="tab-image" onclick="switchMode('image')">🎨 Imagem</button>
<button class="mode-tab" id="tab-audio" onclick="switchMode('audio')">🎡 Áudio</button>
</div>
<div class="status-dot"></div>
</div>
<div id="stats-bar">
<div>Msgs: <span id="stat-msgs">0</span></div>
<div>Enviados: <span id="stat-prompt">0</span></div>
<div>Gerados: <span id="stat-completion">0</span></div>
<div>Total: <span id="stat-total">0</span></div>
<div>Tempo: <span id="stat-time">β€”</span></div>
</div>
<!-- CHAT VIEW -->
<div id="chat-view" style="display:flex; flex-direction:column; flex:1; overflow:hidden;">
<div id="quick-bar">
<select id="model-select" onchange="onModelChange()">
<option value="gemma-1b">Gemma 3 1B</option>
</select>
<div class="mode-pills">
<button class="mode-pill" id="web-toggle" onclick="toggleWeb()">🌐 Web</button>
<button class="mode-pill" id="free-toggle" onclick="toggleFreeMode()">πŸ”“ Livre</button>
<button class="mode-pill" data-mode="fast" onclick="setMode('fast')">⚑ Fast</button>
<button class="mode-pill active" data-mode="medium" onclick="setMode('medium')">βš–οΈ MΓ©dio</button>
<button class="mode-pill" data-mode="thinking" onclick="setMode('thinking')">🧠 Thinking</button>
</div>
</div>
<div class="model-loading-hint" id="model-loading-hint">⏳ Trocando modelo β€” a primeira resposta pode demorar mais...</div>
<div id="chat">
<div class="welcome">
<h2>THC LLM πŸŽ™οΈ</h2>
<p>Fale ou escreva β€” o assistente responde em voz e texto!</p>
<div class="suggestions">
<div class="suggestion" onclick="suggest(this)">Quais produtos vocΓͺs vendem?</div>
<div class="suggestion" onclick="suggest(this)">Me ajude a escolher um produto</div>
<div class="suggestion" onclick="suggest(this)">Formas de pagamento?</div>
<div class="suggestion" onclick="suggest(this)">Como funciona a entrega?</div>
</div>
</div>
</div>
<div id="speaking-bar">
<div class="wave"><span></span><span></span><span></span><span></span><span></span></div>
<span>Assistente falando...</span>
<button id="stop-speak-btn" onclick="stopSpeaking()">β–  Parar</button>
</div>
<div id="input-area">
<div class="input-wrap">
<textarea id="msg" rows="1" placeholder="Digite ou fale..."></textarea>
<div id="mic-transcript"></div>
<div class="input-hint">Enter ↡ envia β€’ Shift+Enter quebra linha</div>
</div>
<div class="action-buttons">
<button id="mic-btn" onclick="toggleMic()" title="Falar">πŸŽ™οΈ</button>
<button id="send-btn" onclick="sendMessage()" title="Enviar">➀</button>
</div>
</div>
</div>
<!-- IMAGE VIEW -->
<div id="image-view">
<div id="image-prompt-area">
<textarea id="image-prompt" placeholder="Descreva a imagem que quer gerar... ex: logo minimalista de tabacaria, estilo neon verde"></textarea>
<div class="image-controls">
<div class="slider-group">
<label>Passos <span id="steps-val">2</span></label>
<input type="range" id="img-steps" min="1" max="4" step="1" value="2"
oninput="document.getElementById('steps-val').textContent=this.value">
</div>
<div class="slider-group">
<label>Tamanho <span id="size-val">512</span>px</label>
<input type="range" id="img-size" min="256" max="768" step="128" value="512"
oninput="document.getElementById('size-val').textContent=this.value">
</div>
<button id="generate-img-btn" onclick="generateImage()">🎨 Gerar</button>
</div>
</div>
<div id="image-gallery">
<div class="img-welcome">
<h2>🎨 Gerador de Imagens</h2>
<p>Descreva o que quer criar acima. No CPU, cada imagem leva ~30-90s.</p>
</div>
</div>
</div>
<!-- AUDIO VIEW -->
<div id="audio-view">
<div id="audio-prompt-area">
<textarea id="audio-prompt" placeholder="Descreva a mΓΊsica... ex: lo-fi hip hop instrumental, chuva ao fundo, clima noturno"></textarea>
<div class="image-controls">
<select id="audio-model">
<option value="lyria-pro-preview">Lyria 3 Pro (mΓΊsica completa) β€” pago ~$0.08</option>
<option value="lyria-clip-preview">Lyria 3 Clip (30s) β€” pago ~$0.04</option>
</select>
<button id="generate-audio-btn" onclick="generateAudio()">🎡 Gerar</button>
</div>
</div>
<div id="audio-gallery">
<div class="img-welcome">
<h2>🎡 Gerador de Música</h2>
<p>Modelos Lyria sΓ£o pagos β€” confirme o custo por geraΓ§Γ£o antes de usar.</p>
</div>
</div>
</div>
</div>
<script>
// ── DetecΓ§Γ£o de plataforma ───────────────────────────────────────────────────
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
const isMobile = window.innerWidth <= 860;
const hasSTT = ('webkitSpeechRecognition' in window) || ('SpeechRecognition' in window);
if (isIOS) document.getElementById('ios-notice').classList.add('show');
if (!hasSTT) {
document.getElementById('mic-btn').classList.add('unsupported');
}
// ── Estado ────────────────────────────────────────────────────────────────────
let conversations = JSON.parse(localStorage.getItem('thc-convs') || '[]');
let activeId = null;
let quotaData = {};
let history = [];
let totalPrompt = 0, totalCompletion = 0;
let ttsEnabled = true;
let recognition = null;
let isListening = false;
let currentUtterance = null;
let currentMode = 'medium';
let currentModelKey = 'gemma-1b';
let modelJustChanged = false;
let webEnabled = false;
let freeModeEnabled = false;
// ── Web toggle ───────────────────────────────────────────────────────────────
function toggleWeb() {
webEnabled = !webEnabled;
document.getElementById('web-toggle').classList.toggle('active', webEnabled);
}
// ── Free mode toggle ───────────────────────────────────────────────────────────
function setFreeMode(value) {
freeModeEnabled = value;
document.getElementById('free-toggle').classList.toggle('active', freeModeEnabled);
document.getElementById('free-toggle-sidebar').classList.toggle('on', freeModeEnabled);
}
function toggleFreeMode() {
setFreeMode(!freeModeEnabled);
}
// ── Sidebar (desktop collapse / mobile drawer) ──────────────────────────────
function toggleSidebar() {
if (isMobile) {
const sb = document.getElementById('sidebar');
const ov = document.getElementById('overlay');
sb.classList.toggle('mobile-open');
ov.classList.toggle('show');
} else {
document.getElementById('sidebar').classList.toggle('collapsed');
}
}
function closeSidebarMobile() {
document.getElementById('sidebar').classList.remove('mobile-open');
document.getElementById('overlay').classList.remove('show');
}
// ── Troca de modo (Chat / Imagem) ───────────────────────────────────────────
function switchMode(mode) {
document.getElementById('tab-chat').classList.toggle('active', mode==='chat');
document.getElementById('tab-image').classList.toggle('active', mode==='image');
document.getElementById('tab-audio').classList.toggle('active', mode==='audio');
document.getElementById('chat-view').style.display = mode==='chat' ? 'flex' : 'none';
document.getElementById('image-view').classList.toggle('active', mode==='image');
document.getElementById('audio-view').classList.toggle('active', mode==='audio');
if (mode === 'image' || mode === 'audio') stopSpeaking();
}
// ── Modelos e Modo de raciocΓ­nio (Fast/MΓ©dio/Thinking) ───────────────────────
function setMode(mode) {
currentMode = mode;
document.querySelectorAll('.mode-pill[data-mode]').forEach(el => {
el.classList.toggle('active', el.dataset.mode === mode);
});
}
// ── Quota Data ─────────────────────────────────────────────────────────────
async function loadQuotaData() {
try {
const res = await fetch('/v1/quota');
const data = await res.json();
data.quotas.forEach(q => {
quotaData[q.model] = {used: q.used, limit: q.limit};
});
} catch (err) {
console.error('Erro ao carregar quota:', err);
}
}
async function loadModelList() {
try {
await loadQuotaData();
const res = await fetch('/v1/models');
const data = await res.json();
const sel = document.getElementById('model-select');
sel.innerHTML = '';
data.text_models.filter(m => !m.paid).forEach(m => {
const opt = document.createElement('option');
opt.value = m.key;
const quota = quotaData[m.key] || {};
const quotaBadge = quota.limit ? ` (${quota.used}/${quota.limit})` : '';
opt.textContent = `${m.label}${quotaBadge} β€” ${m.desc}`;
if (m.active) { opt.selected = true; currentModelKey = m.key; }
sel.appendChild(opt);
});
updateModelLabel();
} catch (err) {
console.error('Erro ao carregar modelos:', err);
}
}
// ── Update Quota Badge ────────────────────────────────────────────────────────
async function updateQuotaBadge(modelKey) {
try {
const res = await fetch('/v1/quota');
const data = await res.json();
const quota = data.quotas.find(q => q.model === modelKey);
if (!quota) return;
const opt = document.querySelector(`option[value="${modelKey}"]`);
if (!opt) return;
const label = opt.textContent.split(' β€” ')[0].trim();
const rest = opt.textContent.split(' β€” ')[1] || '';
const quotaBadge = ` (${quota.used}/${quota.limit})`;
opt.textContent = `${label}${quotaBadge} β€” ${rest}`;
} catch (err) {
console.error('Erro ao atualizar quota:', err);
}
}
function onModelChange() {
currentModelKey = document.getElementById('model-select').value;
modelJustChanged = true;
document.getElementById('model-loading-hint').classList.add('show');
updateModelLabel();
}
function updateModelLabel() {
const sel = document.getElementById('model-select');
const label = sel.options[sel.selectedIndex]?.textContent.split(' β€” ')[0] || '';
document.getElementById('model-label').textContent = `${label} β€’ CPU`;
}
// ── TTS ───────────────────────────────────────────────────────────────────────
function loadVoices() {
const sel = document.getElementById('voice-select');
const voices = speechSynthesis.getVoices();
if (!voices.length) return;
sel.innerHTML = '';
const ptVoices = voices.filter(v => v.lang.startsWith('pt'));
const otherVoices = voices.filter(v => !v.lang.startsWith('pt'));
[...ptVoices, ...otherVoices].forEach((v,i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = `${v.name} (${v.lang})`;
if (v.lang === 'pt-BR') opt.selected = true;
sel.appendChild(opt);
});
}
speechSynthesis.onvoiceschanged = loadVoices;
loadVoices();
setTimeout(loadVoices, 500);
function speak(text) {
if (!ttsEnabled) return;
stopSpeaking();
const clean = text.replace(/\*\*(.*?)\*\*/g,'$1').replace(/\*(.*?)\*/g,'$1')
.replace(/#{1,6}\s/g,'').replace(/`[^`]*`/g,'').trim();
const voices = speechSynthesis.getVoices();
const sel = document.getElementById('voice-select');
const voice = voices[parseInt(sel.value)] || voices.find(v=>v.lang==='pt-BR') || voices[0];
currentUtterance = new SpeechSynthesisUtterance(clean);
if (voice) currentUtterance.voice = voice;
currentUtterance.lang = 'pt-BR';
currentUtterance.rate = parseFloat(document.getElementById('voice-rate').value);
currentUtterance.pitch = parseFloat(document.getElementById('voice-pitch').value);
currentUtterance.onstart = () => document.getElementById('speaking-bar').classList.add('active');
currentUtterance.onend = () => document.getElementById('speaking-bar').classList.remove('active');
currentUtterance.onerror = () => document.getElementById('speaking-bar').classList.remove('active');
speechSynthesis.speak(currentUtterance);
}
function stopSpeaking() {
speechSynthesis.cancel();
document.getElementById('speaking-bar').classList.remove('active');
}
function toggleTTS(btn) {
ttsEnabled = !ttsEnabled;
btn.classList.toggle('on', ttsEnabled);
if (!ttsEnabled) stopSpeaking();
}
// ── STT ──────────────────────────────────────────────────────────────────────
function toggleMic() {
if (!hasSTT) {
alert(isIOS
? 'No iPhone, use o microfone do teclado do iOS para ditar β€” o Safari nΓ£o suporta reconhecimento de voz via navegador.'
: 'Seu navegador nΓ£o suporta reconhecimento de voz. Use Chrome ou Edge.');
return;
}
if (isListening) { recognition.stop(); return; }
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SpeechRecognition();
recognition.lang = 'pt-BR';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
isListening = true;
document.getElementById('mic-btn').classList.add('listening');
document.getElementById('mic-btn').textContent = '⏹';
document.getElementById('mic-transcript').textContent = 'πŸŽ™οΈ Ouvindo...';
stopSpeaking();
};
recognition.onresult = (e) => {
let interim='', final='';
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript;
if (e.results[i].isFinal) final += t; else interim += t;
}
document.getElementById('mic-transcript').textContent = 'πŸŽ™οΈ ' + (final || interim);
if (final) document.getElementById('msg').value = final;
};
recognition.onend = () => {
isListening = false;
document.getElementById('mic-btn').classList.remove('listening');
document.getElementById('mic-btn').textContent = 'πŸŽ™οΈ';
const text = document.getElementById('msg').value.trim();
document.getElementById('mic-transcript').textContent = '';
if (text) sendMessage();
};
recognition.onerror = (e) => {
isListening = false;
document.getElementById('mic-btn').classList.remove('listening');
document.getElementById('mic-btn').textContent = 'πŸŽ™οΈ';
document.getElementById('mic-transcript').textContent = '❌ Erro: ' + e.error;
};
recognition.start();
}
function suggest(el) {
document.getElementById('msg').value = el.textContent;
sendMessage();
}
// ── Conversas ────────────────────────────────────────────────────────────────
function saveConvs(){ localStorage.setItem('thc-convs', JSON.stringify(conversations)); }
function renderConvList() {
const el = document.getElementById('conversations');
el.innerHTML = '';
[...conversations].reverse().forEach(c => {
const div = document.createElement('div');
div.className = 'conv-item' + (c.id===activeId ? ' active' : '');
div.innerHTML = `<span class="conv-title">${c.title}</span>
<button class="conv-delete" onclick="deleteConv('${c.id}',event)">βœ•</button>`;
div.onclick = () => { loadConv(c.id); if (isMobile) closeSidebarMobile(); };
el.appendChild(div);
});
}
function newChat() {
if (history.length) saveCurrentConv();
activeId = null; history = [];
totalPrompt = 0; totalCompletion = 0;
updateStats(0,0,0);
document.getElementById('stat-msgs').textContent = '0';
document.getElementById('stat-time').textContent = 'β€”';
document.getElementById('chat').innerHTML = `
<div class="welcome">
<h2>THC LLM πŸŽ™οΈ</h2>
<p>Fale ou escreva β€” o assistente responde em voz e texto!</p>
<div class="suggestions">
<div class="suggestion" onclick="suggest(this)">Quais produtos vocΓͺs vendem?</div>
<div class="suggestion" onclick="suggest(this)">Me ajude a escolher um produto</div>
<div class="suggestion" onclick="suggest(this)">Formas de pagamento?</div>
<div class="suggestion" onclick="suggest(this)">Como funciona a entrega?</div>
</div>
</div>`;
renderConvList();
if (isMobile) closeSidebarMobile();
}
function saveCurrentConv() {
if (!history.length) return;
const title = history[0].content.slice(0,40) + (history[0].content.length>40?'…':'');
if (activeId) {
const c = conversations.find(c=>c.id===activeId);
if (c) { c.history=[...history]; c.title=title; }
} else {
activeId = Date.now().toString();
conversations.push({id:activeId, title, history:[...history]});
}
saveConvs(); renderConvList();
}
function loadConv(id) {
if (history.length) saveCurrentConv();
const c = conversations.find(c=>c.id===id);
if (!c) return;
activeId = id; history = [...c.history];
totalPrompt = 0; totalCompletion = 0;
document.getElementById('chat').innerHTML = '';
history.forEach(m => appendMessage(m.role, m.content));
renderConvList();
}
function deleteConv(id, e) {
e.stopPropagation();
conversations = conversations.filter(c=>c.id!==id);
saveConvs();
if (activeId===id) newChat(); else renderConvList();
}
// ── Mensagens ────────────────────────────────────────────────────────────────
function appendMessage(role, content, meta='') {
document.querySelector('.welcome')?.remove();
const wrapper = document.createElement('div');
wrapper.className = `msg-wrapper ${role}`;
const metaDiv = document.createElement('div');
metaDiv.className = 'msg-meta';
metaDiv.innerHTML = `<span>${role==='user'?'πŸ§‘ VocΓͺ':'πŸ€– THC LLM'}</span>${meta?`<span>${meta}</span>`:''}`;
const div = document.createElement('div');
div.className = `msg ${role}`;
div.textContent = content;
wrapper.appendChild(metaDiv);
wrapper.appendChild(div);
if (role === 'assistant' && content !== '⏳ Pensando') {
const actions = document.createElement('div');
actions.className = 'msg-meta';
const copyBtn = document.createElement('button');
copyBtn.className = 'action-btn';
copyBtn.textContent = '⎘ Copiar';
copyBtn.onclick = () => {
navigator.clipboard.writeText(content);
copyBtn.textContent = 'βœ“ Copiado!';
setTimeout(()=>copyBtn.textContent='⎘ Copiar', 1500);
};
const speakBtn = document.createElement('button');
speakBtn.className = 'action-btn';
speakBtn.textContent = 'πŸ”Š Ouvir';
speakBtn.onclick = () => speak(content);
actions.appendChild(copyBtn);
actions.appendChild(speakBtn);
wrapper.appendChild(actions);
}
document.getElementById('chat').appendChild(wrapper);
document.getElementById('chat').scrollTop = 99999;
return div;
}
function updateStats(prompt, completion, ms) {
totalPrompt += prompt; totalCompletion += completion;
document.getElementById('stat-prompt').textContent = totalPrompt;
document.getElementById('stat-completion').textContent = totalCompletion;
document.getElementById('stat-total').textContent = totalPrompt + totalCompletion;
if (ms) document.getElementById('stat-time').textContent = (ms/1000).toFixed(1)+'s';
}
// ── Input ────────────────────────────────────────────────────────────────────
const msgInput = document.getElementById('msg');
const sendBtn = document.getElementById('send-btn');
msgInput.addEventListener('keydown', e => {
if (e.key==='Enter' && !e.shiftKey && !isMobile) { e.preventDefault(); sendMessage(); }
});
msgInput.addEventListener('input', () => {
msgInput.style.height = 'auto';
msgInput.style.height = Math.min(msgInput.scrollHeight,120)+'px';
});
// ── Send (chat) ──────────────────────────────────────────────────────────────
async function sendMessage() {
const text = msgInput.value.trim();
if (!text || sendBtn.disabled) return;
appendMessage('user', text);
history.push({role:'user', content:text});
document.getElementById('stat-msgs').textContent = history.filter(m=>m.role==='user').length;
msgInput.value = ''; msgInput.style.height = 'auto';
sendBtn.disabled = true;
const thinkingWrapper = document.createElement('div');
thinkingWrapper.className = 'msg-wrapper assistant';
const thinkingDiv = document.createElement('div');
thinkingDiv.className = 'msg thinking dots';
thinkingDiv.textContent = webEnabled ? '🌐 Buscando na web' : '⏳ Pensando';
thinkingWrapper.appendChild(thinkingDiv);
document.getElementById('chat').appendChild(thinkingWrapper);
document.getElementById('chat').scrollTop = 99999;
const t0 = Date.now();
try {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
messages: history,
model: currentModelKey,
mode: currentMode,
web: webEnabled,
free_mode: freeModeEnabled,
max_tokens: parseInt(document.getElementById('max-tokens').value),
temperature: parseFloat(document.getElementById('temperature').value),
})
});
const data = await res.json();
const ms = Date.now() - t0;
thinkingWrapper.remove();
if (!res.ok) {
appendMessage('assistant', '❌ Erro: ' + (data.detail || 'Erro desconhecido'));
return;
}
const reply = data.choices[0].message.content;
const usage = data.usage || {};
history.push({role:'assistant', content:reply});
saveCurrentConv();
const tokMeta = usage.total_tokens
? `${usage.completion_tokens} tok β€’ ${(ms/1000).toFixed(1)}s${webEnabled ? ' β€’ 🌐' : ''}`
: `${(ms/1000).toFixed(1)}s`;
appendMessage('assistant', reply, tokMeta);
updateStats(usage.prompt_tokens||0, usage.completion_tokens||0, ms);
speak(reply);
updateQuotaBadge(currentModelKey);
if (modelJustChanged) {
document.getElementById('model-loading-hint').classList.remove('show');
modelJustChanged = false;
}
} catch(err) {
thinkingWrapper.remove();
appendMessage('assistant', '❌ Erro de conexão: ' + err.message);
} finally {
sendBtn.disabled = false;
if (!isMobile) msgInput.focus();
}
}
// ── GeraΓ§Γ£o de imagem ────────────────────────────────────────────────────────
async function generateImage() {
const promptEl = document.getElementById('image-prompt');
const prompt = promptEl.value.trim();
if (!prompt) return;
const btn = document.getElementById('generate-img-btn');
btn.disabled = true;
btn.textContent = '⏳ Gerando...';
const gallery = document.getElementById('image-gallery');
document.querySelector('.img-welcome')?.remove();
const loadingCard = document.createElement('div');
loadingCard.className = 'img-loading-card';
loadingCard.innerHTML = `<div class="spinner"></div><span>Gerando... (~30-90s no CPU)</span>`;
gallery.prepend(loadingCard);
try {
const res = await fetch('/v1/images/generations', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
prompt,
steps: parseInt(document.getElementById('img-steps').value),
size: parseInt(document.getElementById('img-size').value),
})
});
const data = await res.json();
loadingCard.remove();
if (!res.ok) {
alert('Erro ao gerar imagem: ' + (data.detail || 'Erro desconhecido'));
return;
}
const b64 = data.data[0].b64_json;
const card = document.createElement('div');
card.className = 'image-card';
card.innerHTML = `
<img src="data:image/png;base64,${b64}" alt="${prompt}">
<div class="image-card-footer">
<span class="image-card-prompt" title="${prompt}">${prompt}</span>
<div class="image-card-actions">
<button class="action-btn" title="Baixar" onclick="downloadImage('${b64}', '${prompt.replace(/'/g,"")}')">⬇</button>
</div>
</div>`;
gallery.prepend(card);
} catch (err) {
loadingCard.remove();
alert('Erro de conexΓ£o: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = '🎨 Gerar';
}
}
function downloadImage(b64, prompt) {
const link = document.createElement('a');
link.href = 'data:image/png;base64,' + b64;
link.download = `thc-${prompt.slice(0,20).replace(/\s+/g,'-')}.png`;
link.click();
}
// ── GeraΓ§Γ£o de Γ‘udio ─────────────────────────────────────────────────────────
async function generateAudio() {
const promptEl = document.getElementById('audio-prompt');
const prompt = promptEl.value.trim();
if (!prompt) return;
const model = document.getElementById('audio-model').value;
const btn = document.getElementById('generate-audio-btn');
btn.disabled = true;
btn.textContent = '⏳ Gerando...';
const gallery = document.getElementById('audio-gallery');
document.querySelector('#audio-gallery .img-welcome')?.remove();
const loadingCard = document.createElement('div');
loadingCard.className = 'img-loading-card';
loadingCard.style.aspectRatio = 'auto';
loadingCard.style.padding = '20px';
loadingCard.innerHTML = `<div class="spinner"></div><span>Gerando mΓΊsica... (pode levar atΓ© 2min)</span>`;
gallery.prepend(loadingCard);
try {
const res = await fetch('/v1/audio/generations', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ prompt, model })
});
const data = await res.json();
loadingCard.remove();
if (!res.ok) {
alert('Erro ao gerar Γ‘udio: ' + (data.detail || 'Erro desconhecido'));
return;
}
const b64 = data.data[0].b64_json;
const card = document.createElement('div');
card.className = 'audio-card';
card.innerHTML = `
<audio controls src="data:audio/mp3;base64,${b64}"></audio>
<span class="audio-card-prompt" title="${prompt}">${prompt}</span>`;
gallery.prepend(card);
} catch (err) {
loadingCard.remove();
alert('Erro de conexΓ£o: ' + err.message);
} finally {
btn.disabled = false;
btn.textContent = '🎡 Gerar';
}
}
// ── Init ─────────────────────────────────────────────────────────────────────
renderConvList();
loadModelList();
if (isMobile) document.getElementById('sidebar').classList.add('collapsed');
</script>
</body>
</html>