File size: 7,081 Bytes
15b8951 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | <!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VLM Console · Unitree Go2</title>
<style>
:root { --glass: rgba(255,255,255,0.08); --line: rgba(255,255,255,0.15); }
* { box-sizing: border-box; }
body {
margin: 0; height: 100vh; font-family: "Segoe UI", system-ui, sans-serif;
color: #eef; overflow: hidden;
background: radial-gradient(1200px 800px at 10% 10%, #1b2a5e, transparent),
radial-gradient(1000px 700px at 90% 90%, #3a1b5e, transparent),
linear-gradient(135deg, #0a0e27, #0e1430);
}
.app { display: grid; grid-template-columns: 1.2fr 1fr; gap: 18px;
height: 100vh; padding: 18px; }
.panel {
background: var(--glass); border: 1px solid var(--line);
border-radius: 22px; backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: 0 8px 40px rgba(0,0,0,0.4); overflow: hidden;
display: flex; flex-direction: column;
}
.panel-head { padding: 16px 20px; display: flex; align-items: center;
justify-content: space-between; border-bottom: 1px solid var(--line); }
.panel-head h1 { font-size: 16px; margin: 0; letter-spacing: .5px; font-weight: 600; }
.badge { font-size: 12px; padding: 5px 12px; border-radius: 999px;
display: flex; align-items: center; gap: 7px; }
.dot { width: 9px; height: 9px; border-radius: 50%; }
.badge.on { background: rgba(0,255,150,.12); color: #7fffc4; }
.badge.on .dot { background: #2bff9c; box-shadow: 0 0 10px #2bff9c; }
.badge.off { background: rgba(255,80,80,.12); color: #ff9d9d; }
.badge.off .dot { background: #ff5b5b; box-shadow: 0 0 10px #ff5b5b; }
.video-wrap { flex: 1; display: flex; align-items: center; justify-content: center;
padding: 16px; }
.video-wrap img { max-width: 100%; max-height: 100%; border-radius: 14px;
border: 1px solid var(--line); }
.chat { flex: 1; overflow-y: auto; padding: 18px; display: flex;
flex-direction: column; gap: 14px; }
.msg { max-width: 88%; padding: 12px 16px; border-radius: 16px; line-height: 1.5;
font-size: 14px; white-space: pre-wrap; animation: rise .25s ease; }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
.msg.user { align-self: flex-end; background: linear-gradient(135deg,#5b8cff,#8a5bff);
color: #fff; border-bottom-right-radius: 4px; }
.msg.bot { align-self: flex-start; background: rgba(255,255,255,0.06);
border: 1px solid var(--line); border-bottom-left-radius: 4px; }
.msg img { display: block; margin-top: 10px; max-width: 100%; border-radius: 12px; }
.cursor { display: inline-block; width: 8px; height: 15px; background: #8a5bff;
margin-left: 2px; animation: blink 1s steps(2) infinite; vertical-align: -2px; }
@keyframes blink { 50% { opacity: 0; } }
.composer { padding: 16px; border-top: 1px solid var(--line); display: flex; gap: 10px; }
.composer input {
flex: 1; background: rgba(0,0,0,0.25); border: 1px solid var(--line);
border-radius: 14px; padding: 13px 16px; color: #eef; font-size: 14px; outline: none;
}
.composer input:focus { border-color: #8a5bff; box-shadow: 0 0 0 3px rgba(138,91,255,.2); }
.composer button {
background: linear-gradient(135deg,#5b8cff,#8a5bff); border: 0; color: #fff;
border-radius: 14px; padding: 0 22px; font-size: 14px; font-weight: 600;
cursor: pointer; transition: transform .1s, filter .2s;
}
.composer button:hover { filter: brightness(1.1); }
.composer button:active { transform: scale(.96); }
.composer button:disabled { opacity: .5; cursor: not-allowed; }
.empty { margin: auto; color: rgba(255,255,255,.4); font-size: 14px; text-align: center; }
</style>
</head>
<body>
<div class="app">
<section class="panel">
<div class="panel-head">
<h1>🐾 GO2 · LIVE CAMERA</h1>
<span id="badge" class="badge off"><span class="dot"></span><span id="badge-txt">…</span></span>
</div>
<div class="video-wrap"><img id="feed" src="/video_feed" alt="live feed"></div>
</section>
<section class="panel">
<div class="panel-head"><h1>💬 VLM REASONING</h1></div>
<div id="chat" class="chat">
<div class="empty">Nhập lệnh để hỏi VLM về cảnh robot đang nhìn thấy.</div>
</div>
<form id="composer" class="composer">
<input id="prompt" placeholder="vd: tìm điện thoại, mô tả cảnh, có người không?" autocomplete="off">
<button id="send" type="submit">Gửi</button>
</form>
</section>
</div>
<script>
const chat = document.getElementById('chat');
const form = document.getElementById('composer');
const input = document.getElementById('prompt');
const sendBtn = document.getElementById('send');
const badge = document.getElementById('badge');
const badgeTxt = document.getElementById('badge-txt');
async function pollStatus() {
try {
const s = await (await fetch('/status')).json();
const on = s.connected;
badge.className = 'badge ' + (on ? 'on' : 'off');
badgeTxt.textContent = on ? 'CONNECTED' : (s.mock ? 'MOCK MODE' : 'DISCONNECTED');
} catch (e) { badge.className = 'badge off'; badgeTxt.textContent = 'OFFLINE'; }
}
pollStatus(); setInterval(pollStatus, 3000);
function clearEmpty() { const e = chat.querySelector('.empty'); if (e) e.remove(); }
function addMsg(cls) {
clearEmpty();
const d = document.createElement('div'); d.className = 'msg ' + cls;
chat.appendChild(d); chat.scrollTop = chat.scrollHeight; return d;
}
let ws, busy = false;
function connectWS() {
ws = new WebSocket(`ws://${location.host}/ws`);
ws.onclose = () => setTimeout(connectWS, 1500);
}
connectWS();
form.addEventListener('submit', (e) => {
e.preventDefault();
const prompt = input.value.trim();
if (!prompt || busy || ws.readyState !== 1) return;
addMsg('user').textContent = prompt;
input.value = '';
busy = true; sendBtn.disabled = true;
const bot = addMsg('bot');
const span = document.createElement('span'); bot.appendChild(span);
const cursor = document.createElement('span'); cursor.className = 'cursor';
bot.appendChild(cursor);
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.type === 'token') {
span.textContent += m.text; chat.scrollTop = chat.scrollHeight;
} else if (m.type === 'image') {
const img = new Image(); img.src = 'data:image/jpeg;base64,' + m.data;
bot.appendChild(img); chat.scrollTop = chat.scrollHeight;
} else if (m.type === 'busy') {
span.textContent = '⏳ Đang xử lý lệnh trước, thử lại sau.';
finish(bot, cursor);
} else if (m.type === 'error') {
span.textContent += '\n⚠️ ' + m.message; finish(bot, cursor);
} else if (m.type === 'done') {
finish(bot, cursor);
}
};
ws.send(JSON.stringify({ prompt }));
});
function finish(bot, cursor) {
cursor.remove(); busy = false; sendBtn.disabled = false; input.focus();
}
</script>
</body>
</html>
|