| <!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> |
|
|