| <!doctype html>
|
| <html>
|
| <head>
|
| <meta charset="utf-8" />
|
| <meta name="viewport" content="width=device-width, initial-scale=1" />
|
| <title>Chat UI</title>
|
| <style>
|
| :root { --bg:#f6f7f9; --card:#ffffff; --border:#e5e7eb; --text:#111827; --muted:#6b7280; --user:#2563eb; --assistant:#10b981; }
|
| body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 0; background: var(--bg); color: var(--text); }
|
| .container { max-width: 900px; margin: 0 auto; padding: 24px; }
|
| h1 { margin: 0 0 16px 0; font-size: 22px; }
|
| .settings { background: var(--card); border: 1px solid var(--border); padding: 12px; border-radius: 10px; margin-bottom: 16px; }
|
| .row { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
|
| label { font-weight: 600; font-size: 14px; }
|
| input[type=text] { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; background: #fff; }
|
| .chat { background: var(--card); border: 1px solid var(--border); border-radius: 10px; height: 60vh; display: flex; flex-direction: column; }
|
| .messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
|
| .bubble { max-width: 75%; padding: 10px 12px; border-radius: 12px; white-space: pre-wrap; line-height: 1.35; }
|
| .user { align-self: flex-end; background: rgba(37,99,235,0.12); border: 1px solid rgba(37,99,235,0.35); }
|
| .assistant { align-self: flex-start; background: rgba(16,185,129,0.12); border: 1px solid rgba(16,185,129,0.35); }
|
| .composer { border-top: 1px solid var(--border); padding: 10px; display: flex; gap: 8px; }
|
| textarea { flex: 1; resize: none; height: 60px; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 14px; }
|
| button { padding: 8px 14px; border-radius: 8px; background: var(--user); color: #fff; border: none; cursor: pointer; font-weight: 600; }
|
| button:disabled { opacity: .6; cursor: not-allowed; }
|
| .muted { color: var(--muted); font-size: 12px; }
|
| </style>
|
| </head>
|
| <body>
|
| <div class="container">
|
| <h1>Chat</h1>
|
| <div class="settings">
|
| <div class="row">
|
| <label>Thread ID</label>
|
| <input id="thread" type="text" value="abc123" />
|
| </div>
|
| <div class="muted">Change Base URL if your server runs elsewhere. Thread ID keeps conversation memory.</div>
|
| </div>
|
|
|
| <div class="chat">
|
| <div id="messages" class="messages"></div>
|
| <div class="composer">
|
| <textarea id="input" placeholder="Type your message... (Ctrl/Cmd+Enter to send)"></textarea>
|
| <button id="send">Send</button>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <script>
|
| const messagesEl = document.getElementById('messages');
|
| const inputEl = document.getElementById('input');
|
| const sendBtn = document.getElementById('send');
|
|
|
| function appendBubble(text, role) {
|
| const div = document.createElement('div');
|
| div.className = 'bubble ' + (role === 'user' ? 'user' : 'assistant');
|
| div.textContent = text;
|
| messagesEl.appendChild(div);
|
| messagesEl.scrollTop = messagesEl.scrollHeight;
|
| }
|
|
|
| async function send() {
|
| const baseUrl = window.location.origin.replace(/\/$/, '');
|
| const query = inputEl.value.trim();
|
| const threadId = document.getElementById('thread').value.trim() || 'default';
|
| if (!query) return;
|
|
|
| appendBubble(query, 'user');
|
| inputEl.value = '';
|
| sendBtn.disabled = true;
|
|
|
| try {
|
| const meta = await collectClientMeta();
|
| const resp = await fetch(baseUrl + '/chat', {
|
| method: 'POST',
|
| headers: { 'Content-Type': 'application/json' },
|
| body: JSON.stringify({ query, config: { configurable: { thread_id: threadId } }, meta })
|
| });
|
| if (!resp.ok) {
|
| const text = await resp.text();
|
| appendBubble(`Error ${resp.status}: ${text}`, 'assistant');
|
| } else {
|
| const data = await resp.json();
|
| appendBubble(String(data.response ?? ''), 'assistant');
|
| }
|
| } catch (e) {
|
| appendBubble('Request failed: ' + e, 'assistant');
|
| } finally {
|
| sendBtn.disabled = false;
|
| inputEl.focus();
|
| }
|
| }
|
|
|
| sendBtn.addEventListener('click', send);
|
| inputEl.addEventListener('keydown', (e) => {
|
| if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
| e.preventDefault();
|
| send();
|
| }
|
| });
|
|
|
| async function collectClientMeta() {
|
| const nav = navigator || {};
|
| const screenObj = window.screen || {};
|
| const meta = {
|
| userAgent: nav.userAgent || '',
|
| platform: nav.platform || '',
|
| language: nav.language || '',
|
| timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
|
| screen: `${screenObj.width || ''}x${screenObj.height || ''}`
|
| };
|
|
|
| try {
|
| const ctrl = new AbortController();
|
| setTimeout(() => ctrl.abort(), 2500);
|
| const r = await fetch('https://api.ipify.org?format=json', { signal: ctrl.signal });
|
| const j = await r.json();
|
| if (j && j.ip) meta.publicIp = j.ip;
|
| } catch { }
|
| try {
|
| if (navigator.geolocation) {
|
| const pos = await new Promise((resolve, reject) =>
|
| navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 3000 })
|
| );
|
| meta.lat = pos.coords.latitude;
|
| meta.lon = pos.coords.longitude;
|
| }
|
| } catch { }
|
| return meta;
|
| }
|
| </script>
|
| </body>
|
| </html>
|
|
|
|
|
|
|