Add embedded UI interface for LCD/keyboard devices (IoT, Raspberry Pi, ESP32)

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-12 13:36:42 -03:00
parent a63d0bdd34
commit d929cfb525

288
ui/embedded/index.html Normal file
View file

@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=320, height=240">
<title>GB Embedded</title>
<style>
/* Optimized for LCD displays: 320x240, 128x64, 16x2 character displays */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #000;
--fg: #0f0;
--border: #0a0;
--font-size: 14px;
--line-height: 1.2;
}
/* High contrast mode for outdoor/industrial displays */
.high-contrast {
--bg: #000;
--fg: #fff;
--border: #fff;
}
/* E-ink mode */
.eink {
--bg: #fff;
--fg: #000;
--border: #000;
}
body {
font-family: 'Courier New', monospace;
background: var(--bg);
color: var(--fg);
font-size: var(--font-size);
line-height: var(--line-height);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header: 1 line */
header {
padding: 2px 4px;
border-bottom: 1px solid var(--border);
font-weight: bold;
display: flex;
justify-content: space-between;
height: 20px;
}
.status {
font-size: 10px;
}
/* Messages area */
#messages {
flex: 1;
overflow-y: auto;
padding: 4px;
}
.msg {
margin-bottom: 4px;
word-wrap: break-word;
}
.msg-user {
text-align: right;
}
.msg-user::before {
content: "> ";
}
.msg-bot::before {
content: "< ";
}
/* Input area */
footer {
border-top: 1px solid var(--border);
padding: 2px 4px;
display: flex;
gap: 4px;
height: 24px;
}
#input {
flex: 1;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
font-family: inherit;
font-size: var(--font-size);
padding: 0 4px;
}
#input:focus {
outline: none;
border-color: var(--fg);
}
button {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
font-family: inherit;
font-size: var(--font-size);
padding: 0 8px;
cursor: pointer;
}
button:active {
background: var(--fg);
color: var(--bg);
}
/* Scrollbar minimal */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--border); }
/* Loading indicator */
.loading::after {
content: "...";
animation: dots 1s infinite;
}
@keyframes dots {
0%, 33% { content: "."; }
34%, 66% { content: ".."; }
67%, 100% { content: "..."; }
}
</style>
</head>
<body>
<header>
<span>GB</span>
<span class="status" id="status">--</span>
</header>
<div id="messages"></div>
<footer>
<input type="text" id="input" placeholder=">" autofocus>
<button id="send">OK</button>
</footer>
<script>
// Configuration
const CONFIG = {
serverUrl: window.BOTSERVER_URL || 'http://localhost:8088',
maxMessages: 10, // Keep memory low
maxMsgLen: 100, // Truncate long messages
};
// State
let ws = null;
let sessionId = null;
let userId = null;
// DOM
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const statusEl = document.getElementById('status');
const sendBtn = document.getElementById('send');
// Truncate text for small displays
function truncate(text, len) {
return text.length > len ? text.substring(0, len - 3) + '...' : text;
}
// Add message to display
function addMsg(type, text) {
const div = document.createElement('div');
div.className = `msg msg-${type}`;
div.textContent = truncate(text, CONFIG.maxMsgLen);
messagesEl.appendChild(div);
// Remove old messages to save memory
while (messagesEl.children.length > CONFIG.maxMessages) {
messagesEl.removeChild(messagesEl.firstChild);
}
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// Update status
function setStatus(s) {
statusEl.textContent = s;
}
// Connect to server
async function connect() {
setStatus('...');
try {
// Get auth
const res = await fetch(`${CONFIG.serverUrl}/api/auth?bot_name=default`);
const auth = await res.json();
sessionId = auth.session_id;
userId = auth.user_id;
// Connect WebSocket
const wsUrl = CONFIG.serverUrl.replace('http', 'ws');
ws = new WebSocket(`${wsUrl}/ws?session_id=${sessionId}&user_id=${userId}`);
ws.onopen = () => {
setStatus('OK');
addMsg('bot', 'Ready');
};
ws.onclose = () => {
setStatus('--');
setTimeout(connect, 3000);
};
ws.onerror = () => {
setStatus('ERR');
};
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.content && data.is_complete) {
addMsg('bot', data.content);
} else if (data.content) {
// Streaming - show partial
const last = messagesEl.lastChild;
if (last && last.classList.contains('msg-bot') && last.classList.contains('loading')) {
last.textContent = truncate(data.content, CONFIG.maxMsgLen);
} else {
const div = document.createElement('div');
div.className = 'msg msg-bot loading';
div.textContent = truncate(data.content, CONFIG.maxMsgLen);
messagesEl.appendChild(div);
}
}
} catch (err) {
// Ignore parse errors
}
};
} catch (err) {
setStatus('ERR');
setTimeout(connect, 5000);
}
}
// Send message
function send() {
const text = inputEl.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
addMsg('user', text);
ws.send(JSON.stringify({
bot_id: 'default_bot',
user_id: userId,
session_id: sessionId,
channel: 'embedded',
content: text,
message_type: 1, // USER
timestamp: new Date().toISOString()
}));
inputEl.value = '';
}
// Event listeners
sendBtn.onclick = send;
inputEl.onkeypress = (e) => { if (e.key === 'Enter') send(); };
// Keyboard navigation for devices without touch
document.onkeydown = (e) => {
if (e.key === 'Escape') {
inputEl.value = '';
}
};
// Start
connect();
</script>
</body>
</html>