| <!DOCTYPE html> |
| <html lang="vi"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Research Agent UI</title> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --bg: #0f1117; |
| --surface: #1a1d27; |
| --surface2: #22263a; |
| --border: #2e3348; |
| --accent: #5c7cfa; |
| --accent2: #38d9a9; |
| --warn: #f6a623; |
| --error: #ff6b6b; |
| --text: #e2e8f0; |
| --muted: #7a849e; |
| --tool-bg: #0d1f2d; |
| --result-bg: #0d2218; |
| --round-bg: #1c1a2e; |
| } |
| |
| body { |
| font-family: 'Inter', system-ui, sans-serif; |
| background: var(--bg); |
| color: var(--text); |
| height: 100vh; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| |
| header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 12px 20px; |
| background: var(--surface); |
| border-bottom: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| header h1 { font-size: 15px; font-weight: 600; color: var(--accent); letter-spacing: .3px; } |
| header span { font-size: 12px; color: var(--muted); } |
| #resetBtn { |
| background: none; border: 1px solid var(--border); color: var(--muted); |
| border-radius: 6px; padding: 5px 12px; font-size: 12px; cursor: pointer; |
| transition: border-color .15s, color .15s; |
| } |
| #resetBtn:hover { border-color: var(--error); color: var(--error); } |
| |
| |
| main { |
| display: flex; |
| flex: 1; |
| overflow: hidden; |
| } |
| |
| |
| #chatPane { |
| width: 420px; |
| flex-shrink: 0; |
| display: flex; |
| flex-direction: column; |
| border-right: 1px solid var(--border); |
| } |
| |
| #messages { |
| flex: 1; |
| overflow-y: auto; |
| padding: 16px; |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| |
| .msg { |
| max-width: 88%; |
| padding: 10px 14px; |
| border-radius: 12px; |
| font-size: 14px; |
| line-height: 1.55; |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| .msg.user { |
| align-self: flex-end; |
| background: var(--accent); |
| color: #fff; |
| border-bottom-right-radius: 3px; |
| } |
| .msg.assistant { |
| align-self: flex-start; |
| background: var(--surface2); |
| border-bottom-left-radius: 3px; |
| } |
| .msg.system-note { |
| align-self: center; |
| background: none; |
| color: var(--muted); |
| font-size: 12px; |
| font-style: italic; |
| } |
| .msg.waiting { |
| align-self: flex-start; |
| background: var(--surface2); |
| color: var(--warn); |
| font-size: 13px; |
| border-left: 3px solid var(--warn); |
| } |
| |
| |
| .typing { display: flex; gap: 5px; align-items: center; padding: 12px 16px; } |
| .typing span { |
| width: 7px; height: 7px; border-radius: 50%; |
| background: var(--muted); animation: bounce .9s infinite; |
| } |
| .typing span:nth-child(2) { animation-delay: .15s; } |
| .typing span:nth-child(3) { animation-delay: .30s; } |
| @keyframes bounce { |
| 0%,80%,100% { transform: translateY(0); } |
| 40% { transform: translateY(-6px); } |
| } |
| |
| |
| #inputRow { |
| display: flex; |
| gap: 8px; |
| padding: 12px 16px; |
| border-top: 1px solid var(--border); |
| background: var(--surface); |
| } |
| #userInput { |
| flex: 1; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 9px 12px; |
| color: var(--text); |
| font-size: 14px; |
| resize: none; |
| outline: none; |
| line-height: 1.4; |
| max-height: 120px; |
| overflow-y: auto; |
| } |
| #userInput:focus { border-color: var(--accent); } |
| #sendBtn { |
| background: var(--accent); |
| border: none; |
| border-radius: 8px; |
| color: #fff; |
| padding: 9px 16px; |
| font-size: 14px; |
| cursor: pointer; |
| transition: opacity .15s; |
| align-self: flex-end; |
| } |
| #sendBtn:disabled { opacity: .4; cursor: not-allowed; } |
| |
| |
| #debugPane { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| |
| #debugHeader { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 12px 16px; |
| border-bottom: 1px solid var(--border); |
| background: var(--surface); |
| flex-shrink: 0; |
| } |
| #debugHeader h2 { font-size: 13px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; } |
| #turnBadge { |
| font-size: 11px; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 4px; |
| padding: 2px 8px; |
| color: var(--muted); |
| } |
| |
| #debugScroll { |
| flex: 1; |
| overflow-y: auto; |
| padding: 14px; |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| } |
| |
| |
| .empty-state { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| height: 100%; |
| color: var(--muted); |
| font-size: 13px; |
| gap: 8px; |
| text-align: center; |
| } |
| .empty-state svg { opacity: .3; } |
| |
| |
| .round-block { |
| border: 1px solid var(--border); |
| border-radius: 10px; |
| overflow: hidden; |
| background: var(--round-bg); |
| } |
| .round-header { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 8px 14px; |
| background: var(--surface2); |
| border-bottom: 1px solid var(--border); |
| font-size: 12px; |
| font-weight: 600; |
| color: var(--muted); |
| text-transform: uppercase; |
| letter-spacing: .4px; |
| } |
| .round-pill { |
| background: var(--accent); |
| color: #fff; |
| border-radius: 20px; |
| padding: 1px 8px; |
| font-size: 11px; |
| font-weight: 700; |
| } |
| |
| |
| .think-block { |
| padding: 10px 14px; |
| font-size: 13px; |
| color: var(--muted); |
| font-style: italic; |
| border-bottom: 1px solid var(--border); |
| } |
| |
| |
| .tool-call-card { |
| margin: 10px 12px 0; |
| border-radius: 8px; |
| overflow: hidden; |
| border: 1px solid #1e3a5f; |
| } |
| .tool-call-header { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 8px 12px; |
| background: var(--tool-bg); |
| cursor: pointer; |
| user-select: none; |
| } |
| .tool-name { |
| font-size: 13px; |
| font-weight: 600; |
| color: var(--accent2); |
| font-family: 'Fira Code', monospace; |
| } |
| .tool-arrow { font-size: 11px; color: var(--muted); margin-left: auto; transition: transform .2s; } |
| .tool-arrow.open { transform: rotate(90deg); } |
| |
| .tool-args { |
| padding: 10px 12px; |
| background: #091826; |
| border-top: 1px solid #1e3a5f; |
| } |
| .tool-args pre { |
| font-family: 'Fira Code', 'Courier New', monospace; |
| font-size: 12px; |
| color: #93c5fd; |
| white-space: pre-wrap; |
| word-break: break-all; |
| } |
| |
| |
| .tool-result-card { |
| margin: 6px 12px 12px; |
| border-radius: 8px; |
| overflow: hidden; |
| border: 1px solid #1a3d2b; |
| } |
| .tool-result-header { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 7px 12px; |
| background: var(--result-bg); |
| cursor: pointer; |
| user-select: none; |
| } |
| .result-label { font-size: 12px; font-weight: 600; color: var(--accent2); opacity: .7; } |
| .result-badge-ok { |
| font-size: 10px; background: #1a4d38; color: var(--accent2); |
| border-radius: 4px; padding: 1px 6px; |
| } |
| .result-badge-err { |
| font-size: 10px; background: #4d1a1a; color: var(--error); |
| border-radius: 4px; padding: 1px 6px; |
| } |
| .result-badge-wait { |
| font-size: 10px; background: #4d3a1a; color: var(--warn); |
| border-radius: 4px; padding: 1px 6px; |
| } |
| |
| .tool-result-body { |
| padding: 10px 12px; |
| background: #060f09; |
| border-top: 1px solid #1a3d2b; |
| } |
| .tool-result-body pre { |
| font-family: 'Fira Code', 'Courier New', monospace; |
| font-size: 12px; |
| color: #86efac; |
| white-space: pre-wrap; |
| word-break: break-all; |
| max-height: 260px; |
| overflow-y: auto; |
| } |
| |
| |
| .status-banner { |
| margin: 0 0 4px; |
| padding: 6px 14px; |
| border-radius: 6px; |
| font-size: 12px; |
| font-weight: 600; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| } |
| .status-answered { background: #1a3d28; color: var(--accent2); } |
| .status-waiting { background: #3d2e0a; color: var(--warn); } |
| .status-error { background: #3d0a0a; color: var(--error); } |
| .status-max { background: #2a2a3d; color: var(--muted); } |
| |
| |
| ::-webkit-scrollbar { width: 5px; height: 5px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } |
| </style> |
| </head> |
| <body> |
|
|
| <header> |
| <h1>🔬 Research Agent UI</h1> |
| <span id="modelLabel">openrouter · gpt-4o-mini</span> |
| <button id="resetBtn">↺ Reset</button> |
| </header> |
|
|
| <main> |
| |
| <div id="chatPane"> |
| <div id="messages"> |
| <div class="msg system-note">Agent sẵn sàng. Hãy thử hỏi gì đó!</div> |
| </div> |
| <div id="inputRow"> |
| <textarea id="userInput" rows="1" placeholder="Nhập câu hỏi… (Enter để gửi)"></textarea> |
| <button id="sendBtn">Gửi</button> |
| </div> |
| </div> |
|
|
| |
| <div id="debugPane"> |
| <div id="debugHeader"> |
| <h2>Tool Execution Trace</h2> |
| <span id="turnBadge">–</span> |
| </div> |
| <div id="debugScroll"> |
| <div class="empty-state"> |
| <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/> |
| </svg> |
| <span>Gửi tin nhắn để xem<br>tool calls xuất hiện tại đây</span> |
| </div> |
| </div> |
| </div> |
| </main> |
|
|
| <script> |
| // ── Backend URL ─────────────────────────────────────────────────────────────── |
| // When hosting this file on HF Spaces (or any static host), set BACKEND_URL to |
| // your ngrok / tunnel URL (e.g. "https://abc123.ngrok-free.app"). |
| // Leave as empty string "" to use the same origin (local dev). |
| const BACKEND_URL = "https://simon-detector-easy-architectural.trycloudflare.com; |
| |
| const messagesEl = document.getElementById('messages'); |
| const userInput = document.getElementById('userInput'); |
| const sendBtn = document.getElementById('sendBtn'); |
| const debugScroll = document.getElementById('debugScroll'); |
| const turnBadge = document.getElementById('turnBadge'); |
| let turnCount = 0; |
| |
| // ── helpers ────────────────────────────────────────────────────────────────── |
| function scrollBottom(el) { el.scrollTop = el.scrollHeight; } |
| |
| function addMessage(text, role) { |
| const div = document.createElement('div'); |
| div.className = `msg ${role}`; |
| div.textContent = text; |
| messagesEl.appendChild(div); |
| scrollBottom(messagesEl); |
| return div; |
| } |
| |
| function showTyping() { |
| const d = document.createElement('div'); |
| d.className = 'typing'; d.id = 'typingDot'; |
| d.innerHTML = '<span></span><span></span><span></span>'; |
| messagesEl.appendChild(d); |
| scrollBottom(messagesEl); |
| } |
| function hideTyping() { |
| document.getElementById('typingDot')?.remove(); |
| } |
| |
| function prettyJson(obj) { |
| return JSON.stringify(obj, null, 2); |
| } |
| |
| function toggleBody(header, bodyId, arrowEl) { |
| const body = document.getElementById(bodyId); |
| if (!body) return; |
| const open = body.style.display !== 'none'; |
| body.style.display = open ? 'none' : 'block'; |
| if (arrowEl) arrowEl.classList.toggle('open', !open); |
| } |
| |
| // ── render debug trace ──────────────────────────────────────────────────────── |
| function renderDebug(data) { |
| debugScroll.innerHTML = ''; |
| turnCount++; |
| turnBadge.textContent = `Turn ${turnCount}`; |
| |
| // status banner |
| const banner = document.createElement('div'); |
| const sMap = { |
| answered: ['status-answered', '✅ answered'], |
| waiting_for_user: ['status-waiting', '⏳ waiting for user'], |
| max_tool_rounds: ['status-max', '⚠️ max tool rounds reached'], |
| error: ['status-error', '❌ error'], |
| }; |
| const [cls, label] = sMap[data.status] || ['status-error', data.status]; |
| banner.className = `status-banner ${cls}`; |
| banner.textContent = label; |
| debugScroll.appendChild(banner); |
| |
| // rounds |
| if (!data.rounds || data.rounds.length === 0) { |
| const note = document.createElement('div'); |
| note.className = 'empty-state'; |
| note.style.height = 'auto'; |
| note.style.paddingTop = '24px'; |
| note.textContent = 'No tool calls — agent answered directly.'; |
| debugScroll.appendChild(note); |
| return; |
| } |
| |
| data.rounds.forEach((round, ri) => { |
| const block = document.createElement('div'); |
| block.className = 'round-block'; |
| |
| const rHeader = document.createElement('div'); |
| rHeader.className = 'round-header'; |
| rHeader.innerHTML = `<span class="round-pill">Round ${round.round}</span> |
| <span>${round.tool_calls?.length || 0} tool call(s)</span>`; |
| block.appendChild(rHeader); |
| |
| // assistant thinking text |
| if (round.assistant_text) { |
| const think = document.createElement('div'); |
| think.className = 'think-block'; |
| think.textContent = `💭 ${round.assistant_text}`; |
| block.appendChild(think); |
| } |
| |
| // tool calls + results |
| const calls = round.tool_calls || []; |
| const results = round.tool_results || []; |
| |
| calls.forEach((call, ci) => { |
| const callId = `call-args-${ri}-${ci}`; |
| const resId = `call-res-${ri}-${ci}`; |
| const result = results[ci] || null; |
| |
| // call card |
| const callCard = document.createElement('div'); |
| callCard.className = 'tool-call-card'; |
| |
| const callHeader = document.createElement('div'); |
| callHeader.className = 'tool-call-header'; |
| const arrow = document.createElement('span'); |
| arrow.className = 'tool-arrow open'; |
| arrow.textContent = '▶'; |
| callHeader.innerHTML = ` |
| <span style="font-size:14px">🔧</span> |
| <span class="tool-name">${call.name}()</span>`; |
| callHeader.appendChild(arrow); |
| callHeader.addEventListener('click', () => toggleBody(callHeader, callId, arrow)); |
| callCard.appendChild(callHeader); |
| |
| const callArgs = document.createElement('div'); |
| callArgs.className = 'tool-args'; callArgs.id = callId; |
| callArgs.innerHTML = `<pre>${prettyJson(call.args)}</pre>`; |
| callCard.appendChild(callArgs); |
| block.appendChild(callCard); |
| |
| // result card |
| if (result) { |
| const resCard = document.createElement('div'); |
| resCard.className = 'tool-result-card'; |
| |
| const resHeader = document.createElement('div'); |
| resHeader.className = 'tool-result-header'; |
| const resArrow = document.createElement('span'); |
| resArrow.className = 'tool-arrow open'; resArrow.textContent = '▶'; |
| |
| let badge = ''; |
| const r = result.result || {}; |
| if (r.error) badge = `<span class="result-badge-err">error: ${r.error}</span>`; |
| else if (r.awaiting_user) badge = `<span class="result-badge-wait">awaiting user</span>`; |
| else badge = `<span class="result-badge-ok">ok</span>`; |
| |
| resHeader.innerHTML = `<span class="result-label">↳ result</span>${badge}`; |
| resHeader.appendChild(resArrow); |
| resHeader.addEventListener('click', () => toggleBody(resHeader, resId, resArrow)); |
| resCard.appendChild(resHeader); |
| |
| const resBody = document.createElement('div'); |
| resBody.className = 'tool-result-body'; resBody.id = resId; |
| resBody.innerHTML = `<pre>${prettyJson(result.result)}</pre>`; |
| resCard.appendChild(resBody); |
| block.appendChild(resCard); |
| } |
| }); |
| |
| debugScroll.appendChild(block); |
| }); |
| |
| scrollBottom(debugScroll); |
| } |
| |
| // ── send ────────────────────────────────────────────────────────────────────── |
| async function sendMessage() { |
| const text = userInput.value.trim(); |
| if (!text) return; |
| |
| userInput.value = ''; |
| userInput.style.height = 'auto'; |
| sendBtn.disabled = true; |
| |
| addMessage(text, 'user'); |
| showTyping(); |
| |
| try { |
| const res = await fetch(`${BACKEND_URL}/chat`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ message: text }), |
| }); |
| const data = await res.json(); |
| hideTyping(); |
| |
| if (data.error) { |
| addMessage(`❌ ${data.error}`, 'msg system-note'); |
| } else if (data.status === 'waiting_for_user') { |
| addMessage(data.assistant_text, 'waiting'); |
| } else { |
| addMessage(data.assistant_text, 'assistant'); |
| } |
| |
| renderDebug(data); |
| } catch (e) { |
| hideTyping(); |
| addMessage(`❌ Network error: ${e.message}`, 'system-note'); |
| } |
| |
| sendBtn.disabled = false; |
| userInput.focus(); |
| } |
| |
| // ── reset ───────────────────────────────────────────────────────────────────── |
| document.getElementById('resetBtn').addEventListener('click', async () => { |
| await fetch(`${BACKEND_URL}/reset`, { method: 'POST' }); |
| messagesEl.innerHTML = '<div class="msg system-note">Conversation reset.</div>'; |
| debugScroll.innerHTML = `<div class="empty-state"> |
| <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> |
| <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/> |
| </svg> |
| <span>Gửi tin nhắn để xem<br>tool calls xuất hiện tại đây</span> |
| </div>`; |
| turnBadge.textContent = '–'; |
| turnCount = 0; |
| }); |
| |
| // ── input auto-resize + Enter ────────────────────────────────────────────────── |
| userInput.addEventListener('input', () => { |
| userInput.style.height = 'auto'; |
| userInput.style.height = Math.min(userInput.scrollHeight, 120) + 'px'; |
| }); |
| userInput.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } |
| }); |
| sendBtn.addEventListener('click', sendMessage); |
| </script> |
| </body> |
| </html> |
|
|