| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>HBL Internal Assistant</title> |
| <style> |
| :root { |
| --bg: #14181a; |
| --panel: #1b2124; |
| --panel-2: #222a2d; |
| --line: #2c3538; |
| --text: #ece9e2; |
| --text-dim: #8fa998; |
| --accent: #1f7a4d; |
| --accent-bright: #2fa869; |
| --danger: #b2503f; |
| } |
| * { box-sizing: border-box; } |
| html, body { |
| height: 100%; margin: 0; |
| background: var(--bg); |
| color: var(--text); |
| font-family: -apple-system, "Segoe UI", Inter, Roboto, sans-serif; |
| } |
| #app { display: flex; flex-direction: column; height: 100vh; } |
| |
| header { |
| display: flex; align-items: center; justify-content: space-between; |
| padding: 14px 20px; |
| border-bottom: 1px solid var(--line); |
| background: var(--panel); |
| } |
| .brand { display: flex; align-items: center; gap: 10px; } |
| .brand-mark { |
| width: 26px; height: 26px; border-radius: 7px; |
| background: linear-gradient(135deg, var(--accent-bright), var(--accent)); |
| flex-shrink: 0; |
| } |
| .brand-text { font-size: 15px; font-weight: 600; letter-spacing: 0.2px; } |
| .brand-sub { font-size: 11px; color: var(--text-dim); margin-top: 1px; } |
| .status { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-dim); } |
| .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-bright); } |
| .dot.offline { background: var(--danger); } |
| button.reset { |
| background: transparent; border: 1px solid var(--line); color: var(--text-dim); |
| padding: 6px 12px; border-radius: 7px; font-size: 12px; cursor: pointer; |
| } |
| button.reset:hover { border-color: var(--accent-bright); color: var(--text); } |
| |
| main { flex: 1; overflow-y: auto; padding: 24px 0; } |
| .thread { max-width: 760px; margin: 0 auto; padding: 0 20px; display: flex; flex-direction: column; gap: 18px; } |
| |
| .empty-state { text-align: center; color: var(--text-dim); margin-top: 18vh; font-size: 14px; line-height: 1.7; } |
| .empty-state b { color: var(--text); } |
| |
| .msg { display: flex; gap: 12px; } |
| .msg .avatar { |
| width: 28px; height: 28px; border-radius: 6px; flex-shrink: 0; |
| display: flex; align-items: center; justify-content: center; |
| font-size: 11px; font-weight: 700; |
| } |
| .msg.user .avatar { background: var(--panel-2); color: var(--text-dim); } |
| .msg.assistant .avatar { background: var(--accent); color: #fff; } |
| .msg .bubble { |
| padding: 10px 14px; border-radius: 10px; line-height: 1.55; font-size: 14.5px; |
| white-space: pre-wrap; word-wrap: break-word; max-width: 100%; |
| } |
| .msg.user .bubble { background: var(--panel-2); } |
| .msg.assistant .bubble { background: transparent; padding-left: 0; } |
| .cursor { display: inline-block; width: 7px; height: 15px; background: var(--accent-bright); margin-left: 2px; animation: blink 1s step-start infinite; vertical-align: text-bottom; } |
| @keyframes blink { 50% { opacity: 0; } } |
| |
| form#composer { |
| border-top: 1px solid var(--line); background: var(--panel); |
| padding: 14px 20px 18px; |
| } |
| .composer-inner { max-width: 760px; margin: 0 auto; display: flex; gap: 10px; align-items: flex-end; } |
| textarea#input { |
| flex: 1; resize: none; max-height: 160px; min-height: 44px; |
| background: var(--panel-2); color: var(--text); border: 1px solid var(--line); |
| border-radius: 10px; padding: 11px 14px; font-size: 14.5px; font-family: inherit; |
| outline: none; line-height: 1.4; |
| } |
| textarea#input:focus { border-color: var(--accent-bright); } |
| button#send { |
| background: var(--accent); color: #fff; border: none; border-radius: 10px; |
| padding: 0 18px; height: 44px; font-size: 14px; font-weight: 600; cursor: pointer; |
| } |
| button#send:disabled { background: var(--panel-2); color: var(--text-dim); cursor: not-allowed; } |
| .hint { text-align: center; font-size: 11px; color: var(--text-dim); margin-top: 8px; } |
| </style> |
| </head> |
| <body> |
| <div id="app"> |
| <header> |
| <div class="brand"> |
| <div class="brand-mark"></div> |
| <div> |
| <div class="brand-text">HBL Internal Assistant</div> |
| <div class="brand-sub">Runs locally on the GPU server · not connected to the internet</div> |
| </div> |
| </div> |
| <div style="display:flex; align-items:center; gap:14px;"> |
| <div class="status"><span id="status-dot" class="dot"></span><span id="status-text">connecting…</span></div> |
| <button class="reset" id="reset-btn">Reset chat</button> |
| </div> |
| </header> |
|
|
| <main> |
| <div class="thread" id="thread"> |
| <div class="empty-state" id="empty-state"> |
| Ask about <b>HBL policies, products, or processes</b> —<br> |
| or ask it to draft or edit a professional email. |
| </div> |
| </div> |
| </main> |
|
|
| <form id="composer"> |
| <div class="composer-inner"> |
| <textarea id="input" placeholder="Message the assistant…" rows="1"></textarea> |
| <button id="send" type="submit">Send</button> |
| </div> |
| <div class="hint">Enter to send · Shift+Enter for a new line</div> |
| </form> |
| </div> |
|
|
| <script> |
| |
| |
| |
| |
| const API_BASE = ""; |
| |
| const threadEl = document.getElementById("thread"); |
| const emptyStateEl = document.getElementById("empty-state"); |
| const inputEl = document.getElementById("input"); |
| const sendBtn = document.getElementById("send"); |
| const composerEl = document.getElementById("composer"); |
| const resetBtn = document.getElementById("reset-btn"); |
| const statusDot = document.getElementById("status-dot"); |
| const statusText = document.getElementById("status-text"); |
| |
| let sessionId = localStorage.getItem("hbl_session_id"); |
| let generating = false; |
| |
| async function ensureSession() { |
| if (sessionId) return sessionId; |
| const res = await fetch(`${API_BASE}/session/new`, { method: "POST" }); |
| const data = await res.json(); |
| sessionId = data.session_id; |
| localStorage.setItem("hbl_session_id", sessionId); |
| return sessionId; |
| } |
| |
| async function checkHealth() { |
| try { |
| const res = await fetch(`${API_BASE}/health`); |
| const data = await res.json(); |
| statusDot.classList.remove("offline"); |
| statusText.textContent = `online \u00b7 ${data.device.toUpperCase()}`; |
| } catch (e) { |
| statusDot.classList.add("offline"); |
| statusText.textContent = "server unreachable"; |
| } |
| } |
| |
| function addMessage(role, text) { |
| emptyStateEl.style.display = "none"; |
| const wrap = document.createElement("div"); |
| wrap.className = `msg ${role}`; |
| const avatar = document.createElement("div"); |
| avatar.className = "avatar"; |
| avatar.textContent = role === "user" ? "You" : "AI"; |
| const bubble = document.createElement("div"); |
| bubble.className = "bubble"; |
| bubble.textContent = text; |
| wrap.appendChild(avatar); |
| wrap.appendChild(bubble); |
| threadEl.appendChild(wrap); |
| wrap.scrollIntoView({ behavior: "smooth", block: "end" }); |
| return bubble; |
| } |
| |
| function autosize() { |
| inputEl.style.height = "auto"; |
| inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + "px"; |
| } |
| inputEl.addEventListener("input", autosize); |
| |
| inputEl.addEventListener("keydown", (e) => { |
| if (e.key === "Enter" && !e.shiftKey) { |
| e.preventDefault(); |
| composerEl.requestSubmit(); |
| } |
| }); |
| |
| composerEl.addEventListener("submit", async (e) => { |
| e.preventDefault(); |
| const message = inputEl.value.trim(); |
| if (!message || generating) return; |
| |
| await ensureSession(); |
| |
| inputEl.value = ""; |
| autosize(); |
| addMessage("user", message); |
| |
| const assistantBubble = addMessage("assistant", ""); |
| const cursor = document.createElement("span"); |
| cursor.className = "cursor"; |
| assistantBubble.appendChild(cursor); |
| |
| generating = true; |
| sendBtn.disabled = true; |
| sendBtn.textContent = "Generating…"; |
| |
| try { |
| const res = await fetch(`${API_BASE}/chat`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ session_id: sessionId, message }), |
| }); |
| |
| if (!res.ok || !res.body) throw new Error(`Server returned ${res.status}`); |
| |
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| let full = ""; |
| |
| while (true) { |
| const { value, done } = await reader.read(); |
| if (done) break; |
| full += decoder.decode(value, { stream: true }); |
| assistantBubble.textContent = full; |
| assistantBubble.appendChild(cursor); |
| threadEl.scrollTop = threadEl.scrollHeight; |
| } |
| cursor.remove(); |
| } catch (err) { |
| cursor.remove(); |
| assistantBubble.textContent = `\u26a0\ufe0f Couldn't reach the assistant (${err.message}). Check the server is running and reachable.`; |
| } finally { |
| generating = false; |
| sendBtn.disabled = false; |
| sendBtn.textContent = "Send"; |
| inputEl.focus(); |
| } |
| }); |
| |
| resetBtn.addEventListener("click", async () => { |
| if (!sessionId) return; |
| await fetch(`${API_BASE}/session/${sessionId}/reset`, { method: "POST" }); |
| threadEl.innerHTML = ""; |
| threadEl.appendChild(emptyStateEl); |
| emptyStateEl.style.display = "block"; |
| }); |
| |
| checkHealth(); |
| ensureSession(); |
| inputEl.focus(); |
| </script> |
| </body> |
| </html> |