Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions agents/bot/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3427,6 +3427,17 @@ const httpServer = http.createServer(async (req, res) => {
}
}

if (path === '/voice') {
const htmlPath = new URL('voice-chat.html', import.meta.url).pathname;
try {
const html = fs.readFileSync(htmlPath, 'utf8');
res.writeHead(200, { 'Content-Type': 'text/html', 'Access-Control-Allow-Origin': '*' });
return res.end(html);
} catch {
return respond(res, 500, { ok: false, error: 'voice-chat.html not found' });
}
}

if (path === '/blueprints') {
try {
const files = fs.readdirSync(BLUEPRINTS_DIR).filter(f => f.endsWith('.json'));
Expand Down
362 changes: 362 additions & 0 deletions agents/bot/voice-chat.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DaemonCraft Voice Chat</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #fff;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
padding: 12px 16px;
background: rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
header h1 { font-size: 1.1rem; color: #00d4aa; }
.status {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: #aaa;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: #444;
transition: background 0.3s;
}
.status-dot.connected { background: #00d4aa; box-shadow: 0 0 8px #00d4aa; }
.status-dot.disconnected { background: #ff4444; }
.status-dot.listening { background: #ffaa00; animation: pulse 1s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

#chat {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.msg {
max-width: 85%;
padding: 10px 14px;
border-radius: 16px;
font-size: 1rem;
line-height: 1.4;
word-break: break-word;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.msg.me {
align-self: flex-end;
background: #00d4aa;
color: #000;
border-bottom-right-radius: 4px;
}
.msg.agent {
align-self: flex-start;
background: rgba(255,255,255,0.12);
color: #fff;
border-bottom-left-radius: 4px;
}
.msg .from {
font-size: 0.75rem;
font-weight: 700;
margin-bottom: 4px;
opacity: 0.8;
}

.input-area {
padding: 12px 16px 20px;
background: rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.mic-btn {
width: 80px; height: 80px;
border-radius: 50%;
border: none;
background: #00d4aa;
color: #000;
font-size: 2rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
box-shadow: 0 4px 20px rgba(0,212,170,0.3);
}
.mic-btn:hover { transform: scale(1.05); }
.mic-btn:active { transform: scale(0.95); }
.mic-btn.listening {
background: #ffaa00;
box-shadow: 0 4px 20px rgba(255,170,0,0.4);
}
.mic-btn:disabled {
background: #444;
cursor: not-allowed;
box-shadow: none;
}
.hint {
font-size: 0.85rem;
color: #888;
text-align: center;
}
.transcript {
font-size: 0.9rem;
color: #ccc;
min-height: 1.2rem;
text-align: center;
}

.settings {
position: absolute;
top: 12px; right: 12px;
background: rgba(0,0,0,0.5);
padding: 8px 12px;
border-radius: 8px;
font-size: 0.8rem;
display: flex;
flex-direction: column;
gap: 4px;
}
.settings input {
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
width: 140px;
}
</style>
</head>
<body>
<header>
<h1>🎙️ DaemonCraft Voice</h1>
<div class="status">
<div class="status-dot" id="statusDot"></div>
<span id="statusText">Conectando...</span>
</div>
</header>

<div id="chat"></div>

<div class="input-area">
<div class="transcript" id="transcript"></div>
<button class="mic-btn" id="micBtn" title="Mantené presionado para hablar">🎤</button>
<div class="hint">Mantené presionado el botón, hablá, y soltá</div>
</div>

<div class="settings">
<label>Bot API URL</label>
<input id="apiUrl" value="http://localhost:3002" />
<label>WS URL</label>
<input id="wsUrl" value="ws://localhost:3002/ws" />
<label>Tu nombre en Minecraft</label>
<input id="playerName" value="JereC4str0" />
</div>

<script>
const apiUrlEl = document.getElementById('apiUrl');
const wsUrlEl = document.getElementById('wsUrl');
const playerNameEl = document.getElementById('playerName');
const API_URL = () => apiUrlEl.value.trim();
const WS_URL = () => wsUrlEl.value.trim();
const PLAYER_NAME = () => playerNameEl.value.trim() || 'Jugador';
const chatEl = document.getElementById('chat');
const micBtn = document.getElementById('micBtn');
const statusDot = document.getElementById('statusDot');
const statusText = document.getElementById('statusText');
const transcriptEl = document.getElementById('transcript');

// ── WebSocket ──
let ws = null;
let reconnectTimer = null;

function connectWs() {
if (ws && ws.readyState <= 1) {
try { ws.close(); } catch (e) {}
}
clearTimeout(reconnectTimer);
const url = WS_URL();
try {
ws = new WebSocket(url);
} catch (e) {
setStatus('disconnected', 'URL inválida');
return;
}

ws.onopen = () => {
setStatus('connected', 'Conectado');
clearTimeout(reconnectTimer);
};

ws.onmessage = (ev) => {
try {
const { type, data } = JSON.parse(ev.data);
if (type === 'chat' && Array.isArray(data)) {
// Server sends full history slice — clear and re-render to avoid duplicates
chatEl.innerHTML = '';
data.forEach(msg => addMsg(msg.from, msg.message, msg.self ? 'me' : 'agent'));
} else if (type === 'chat' && data && data.messages) {
chatEl.innerHTML = '';
data.messages.forEach(msg => addMsg(msg.from, msg.message, msg.self ? 'me' : 'agent'));
}
} catch (e) {}
};

ws.onclose = () => {
setStatus('disconnected', 'Desconectado');
reconnectTimer = setTimeout(connectWs, 3000);
};

ws.onerror = () => {
setStatus('disconnected', 'Error de conexión');
};
}

// Reconnect when URLs change
apiUrlEl.addEventListener('change', connectWs);
wsUrlEl.addEventListener('change', connectWs);

function setStatus(state, text) {
statusDot.className = 'status-dot ' + state;
statusText.textContent = text;
}

// ── Chat UI ──
function addMsg(from, text, cls) {
const div = document.createElement('div');
div.className = 'msg ' + cls;
div.innerHTML = `<div class="from">${escapeHtml(from)}</div><div>${escapeHtml(text)}</div>`;
chatEl.appendChild(div);
chatEl.scrollTop = chatEl.scrollHeight;

// TTS disabled — too annoying
// if (cls === 'agent' && window.speechSynthesis) {
// speak(text);
// }
}

function escapeHtml(str) {
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[m]));
}

// ── Speech Recognition ──
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition = null;

if (SpeechRecognition) {
recognition = new SpeechRecognition();
recognition.lang = 'es-ES';
recognition.interimResults = true;
recognition.continuous = false;

recognition.onstart = () => {
micBtn.classList.add('listening');
setStatus('listening', 'Escuchando...');
};

recognition.onresult = (e) => {
let final = '';
let interim = '';
for (let i = 0; i < e.results.length; i++) {
if (e.results[i].isFinal) {
final += e.results[i][0].transcript;
} else {
interim += e.results[i][0].transcript;
}
}
transcriptEl.textContent = final || interim;
};

recognition.onend = () => {
micBtn.classList.remove('listening');
const text = transcriptEl.textContent.trim();
if (text) {
sendChat(text);
transcriptEl.textContent = '';
}
setStatus(ws && ws.readyState === 1 ? 'connected' : 'disconnected',
ws && ws.readyState === 1 ? 'Conectado' : 'Desconectado');
};

recognition.onerror = (e) => {
micBtn.classList.remove('listening');
setStatus('disconnected', 'Error: ' + e.error);
};
} else {
micBtn.disabled = true;
micBtn.title = 'Tu navegador no soporta reconocimiento de voz';
statusText.textContent = 'Speech API no disponible';
}

// Botón de micrófono: mantener presionado
micBtn.addEventListener('mousedown', startListening);
micBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startListening(); });
micBtn.addEventListener('mouseup', stopListening);
micBtn.addEventListener('touchend', (e) => { e.preventDefault(); stopListening(); });
micBtn.addEventListener('mouseleave', stopListening);

function startListening() {
if (!recognition) return;
try { recognition.start(); } catch (e) {}
}
function stopListening() {
if (!recognition) return;
try { recognition.stop(); } catch (e) {}
}

// ── Enviar mensaje al bot ──
async function sendChat(text) {
addMsg('Vos', text, 'me');
try {
const resp = await fetch(API_URL() + '/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, as: PLAYER_NAME() }),
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
} catch (err) {
addMsg('Sistema', 'No se pudo enviar: ' + err.message, 'agent');
}
}

// ── TTS ──
function speak(text) {
if (!window.speechSynthesis) return;
// Cancelar speech anterior para no acumular
window.speechSynthesis.cancel();
const utter = new SpeechSynthesisUtterance(text);
utter.lang = 'es-ES';
utter.rate = 1.1;
utter.pitch = 1;
window.speechSynthesis.speak(utter);
}

// ── Init ──
connectWs();
</script>
</body>
</html>
Loading