| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>mac-tensor agent</title> |
| <style> |
| :root { |
| --bg: #0a0a0f; |
| --bg-2: #111128; |
| --primary: #6366f1; |
| --secondary: #22d3ee; |
| --accent: #f472b6; |
| --green: #34d399; |
| --orange: #fb923c; |
| --red: #ef4444; |
| --text: #f1f5f9; |
| --text-muted: #94a3b8; |
| --card: rgba(30, 30, 60, 0.7); |
| --border: rgba(99, 102, 241, 0.3); |
| } |
| * { box-sizing: border-box; } |
| html, body { |
| margin: 0; |
| padding: 0; |
| height: 100%; |
| background: var(--bg); |
| color: var(--text); |
| font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif; |
| } |
| body { |
| background: linear-gradient(135deg, #0a0a0f 0%, #111128 50%, #0a0a0f 100%); |
| background-attachment: fixed; |
| display: flex; |
| flex-direction: column; |
| height: 100vh; |
| } |
| |
| |
| header { |
| padding: 16px 24px; |
| border-bottom: 1px solid var(--border); |
| background: rgba(10, 10, 15, 0.85); |
| backdrop-filter: blur(20px); |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| flex-shrink: 0; |
| } |
| .brand { |
| display: flex; |
| align-items: center; |
| gap: 12px; |
| } |
| .brand-logo { |
| font-size: 28px; |
| font-weight: 800; |
| background: linear-gradient(135deg, var(--primary), var(--secondary), var(--accent)); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| letter-spacing: -1px; |
| } |
| .brand-info { |
| font-size: 13px; |
| color: var(--text-muted); |
| } |
| .brand-info b { color: var(--secondary); font-weight: 600; } |
| .actions { |
| display: flex; |
| gap: 8px; |
| } |
| .btn { |
| background: var(--card); |
| border: 1px solid var(--border); |
| color: var(--text); |
| padding: 8px 16px; |
| border-radius: 8px; |
| cursor: pointer; |
| font-size: 13px; |
| transition: all 0.15s; |
| } |
| .btn:hover { |
| background: rgba(99, 102, 241, 0.15); |
| border-color: var(--primary); |
| } |
| .btn.primary { |
| background: linear-gradient(135deg, var(--primary), var(--secondary)); |
| border: none; |
| color: #fff; |
| font-weight: 600; |
| } |
| .btn.primary:hover { opacity: 0.9; } |
| .btn:disabled { opacity: 0.4; cursor: not-allowed; } |
| |
| |
| .status { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| font-size: 12px; |
| color: var(--text-muted); |
| padding: 6px 12px; |
| background: rgba(52, 211, 153, 0.08); |
| border: 1px solid rgba(52, 211, 153, 0.3); |
| border-radius: 6px; |
| } |
| .status .dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: var(--green); |
| box-shadow: 0 0 8px var(--green); |
| animation: pulse 2s infinite; |
| } |
| @keyframes pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.5; } |
| } |
| |
| |
| main { |
| flex: 1; |
| overflow-y: auto; |
| padding: 24px; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| } |
| .messages { |
| width: 100%; |
| max-width: 900px; |
| display: flex; |
| flex-direction: column; |
| gap: 20px; |
| } |
| .message { |
| display: flex; |
| flex-direction: column; |
| gap: 8px; |
| animation: slideIn 0.3s ease-out; |
| } |
| @keyframes slideIn { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .message-role { |
| font-size: 12px; |
| font-weight: 600; |
| color: var(--text-muted); |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| } |
| .message-role.user { color: var(--secondary); } |
| .message-role.agent { color: var(--accent); } |
| .bubble { |
| padding: 16px 20px; |
| border-radius: 14px; |
| line-height: 1.55; |
| font-size: 15px; |
| white-space: pre-wrap; |
| word-wrap: break-word; |
| } |
| .bubble.user { |
| background: linear-gradient(135deg, rgba(34, 211, 238, 0.1), rgba(99, 102, 241, 0.1)); |
| border: 1px solid rgba(34, 211, 238, 0.3); |
| } |
| .bubble.agent { |
| background: var(--card); |
| border: 1px solid var(--border); |
| } |
| |
| |
| .tool-call { |
| margin-top: 8px; |
| border: 1px solid rgba(251, 146, 60, 0.3); |
| border-radius: 10px; |
| overflow: hidden; |
| background: rgba(251, 146, 60, 0.05); |
| } |
| .tool-header { |
| padding: 10px 14px; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| cursor: pointer; |
| user-select: none; |
| } |
| .tool-header:hover { |
| background: rgba(251, 146, 60, 0.08); |
| } |
| .tool-icon { |
| width: 28px; |
| height: 28px; |
| border-radius: 6px; |
| background: rgba(251, 146, 60, 0.2); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: var(--orange); |
| font-weight: 700; |
| font-size: 14px; |
| } |
| .tool-name { |
| font-family: 'SF Mono', Menlo, monospace; |
| font-size: 13px; |
| color: var(--orange); |
| font-weight: 600; |
| } |
| .tool-args { |
| flex: 1; |
| font-family: 'SF Mono', Menlo, monospace; |
| font-size: 12px; |
| color: var(--text-muted); |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| .tool-toggle { |
| color: var(--text-muted); |
| font-size: 12px; |
| transition: transform 0.2s; |
| } |
| .tool-call.expanded .tool-toggle { |
| transform: rotate(180deg); |
| } |
| .tool-result { |
| display: none; |
| padding: 12px 14px; |
| border-top: 1px solid rgba(251, 146, 60, 0.2); |
| font-family: 'SF Mono', Menlo, monospace; |
| font-size: 12px; |
| color: var(--text); |
| background: rgba(15, 15, 30, 0.6); |
| white-space: pre-wrap; |
| max-height: 300px; |
| overflow-y: auto; |
| line-height: 1.5; |
| } |
| .tool-call.expanded .tool-result { |
| display: block; |
| } |
| |
| |
| .step-pill { |
| display: inline-block; |
| padding: 4px 10px; |
| background: rgba(99, 102, 241, 0.15); |
| border: 1px solid rgba(99, 102, 241, 0.3); |
| border-radius: 6px; |
| font-size: 11px; |
| color: var(--primary); |
| font-family: 'SF Mono', monospace; |
| margin-bottom: 4px; |
| } |
| |
| |
| .thinking { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| padding: 12px 16px; |
| color: var(--text-muted); |
| font-size: 14px; |
| font-style: italic; |
| } |
| .thinking-dots { |
| display: inline-flex; |
| gap: 3px; |
| } |
| .thinking-dots span { |
| width: 6px; |
| height: 6px; |
| border-radius: 50%; |
| background: var(--secondary); |
| animation: bounce 1.4s infinite ease-in-out; |
| } |
| .thinking-dots span:nth-child(2) { animation-delay: 0.2s; } |
| .thinking-dots span:nth-child(3) { animation-delay: 0.4s; } |
| @keyframes bounce { |
| 0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; } |
| 40% { transform: scale(1); opacity: 1; } |
| } |
| |
| |
| footer { |
| padding: 16px 24px 24px; |
| background: rgba(10, 10, 15, 0.85); |
| backdrop-filter: blur(20px); |
| border-top: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| .input-row { |
| max-width: 900px; |
| margin: 0 auto; |
| display: flex; |
| gap: 12px; |
| align-items: flex-end; |
| } |
| .input-wrap { |
| flex: 1; |
| background: var(--card); |
| border: 1px solid var(--border); |
| border-radius: 12px; |
| padding: 12px 16px; |
| transition: border-color 0.15s; |
| } |
| .input-wrap:focus-within { |
| border-color: var(--primary); |
| box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); |
| } |
| textarea { |
| width: 100%; |
| background: transparent; |
| border: none; |
| color: var(--text); |
| font-family: inherit; |
| font-size: 15px; |
| line-height: 1.5; |
| resize: none; |
| outline: none; |
| min-height: 24px; |
| max-height: 200px; |
| } |
| textarea::placeholder { color: var(--text-muted); } |
| .send-btn { |
| padding: 12px 24px; |
| border-radius: 12px; |
| flex-shrink: 0; |
| height: 50px; |
| } |
| |
| |
| .examples { |
| width: 100%; |
| max-width: 900px; |
| margin-top: 40px; |
| } |
| .examples-title { |
| font-size: 13px; |
| color: var(--text-muted); |
| margin-bottom: 12px; |
| text-transform: uppercase; |
| letter-spacing: 0.5px; |
| } |
| .examples-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); |
| gap: 12px; |
| } |
| .example { |
| padding: 14px 16px; |
| background: var(--card); |
| border: 1px solid var(--border); |
| border-radius: 10px; |
| cursor: pointer; |
| transition: all 0.15s; |
| font-size: 13px; |
| color: var(--text); |
| } |
| .example:hover { |
| background: rgba(99, 102, 241, 0.1); |
| border-color: var(--primary); |
| } |
| .example-tool { |
| display: inline-block; |
| font-family: 'SF Mono', monospace; |
| font-size: 11px; |
| color: var(--orange); |
| margin-bottom: 4px; |
| } |
| |
| |
| .empty { |
| text-align: center; |
| color: var(--text-muted); |
| padding: 60px 20px 20px; |
| } |
| .empty h2 { |
| font-size: 28px; |
| font-weight: 700; |
| color: var(--text); |
| margin: 0 0 8px; |
| background: linear-gradient(135deg, var(--primary), var(--secondary)); |
| -webkit-background-clip: text; |
| -webkit-text-fill-color: transparent; |
| } |
| .empty p { |
| margin: 0; |
| font-size: 16px; |
| } |
| |
| |
| ::-webkit-scrollbar { |
| width: 10px; |
| height: 10px; |
| } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { |
| background: rgba(99, 102, 241, 0.2); |
| border-radius: 10px; |
| } |
| ::-webkit-scrollbar-thumb:hover { background: rgba(99, 102, 241, 0.4); } |
| </style> |
| </head> |
| <body> |
|
|
| <header> |
| <div class="brand"> |
| <div class="brand-logo">mac-tensor</div> |
| <div class="brand-info"> |
| <b>{{MODEL_NAME}}</b> · {{NODE_COUNT}} expert nodes |
| </div> |
| </div> |
| <div class="actions"> |
| <div class="status"><span class="dot"></span> Connected</div> |
| <button class="btn" id="save-btn" title="Download conversation as JSON">💾 Save</button> |
| <button class="btn" id="reset-btn">Reset</button> |
| </div> |
| </header> |
|
|
| <main> |
| <div class="messages" id="messages"> |
| <div class="empty"> |
| <h2>Talk to your distributed agent</h2> |
| <p>Backed by Apple Silicon expert nodes. Tools: read, ls, shell, search, python.</p> |
| </div> |
| <div class="examples"> |
| <div class="examples-title">Try one of these:</div> |
| <div class="examples-grid"> |
| <div class="example" onclick="setInput('How much disk space is free? Use shell.')"> |
| <div class="example-tool"><shell></div> |
| How much disk space is free? |
| </div> |
| <div class="example" onclick="setInput('What is 2 to the power of 32? Use python.')"> |
| <div class="example-tool"><python></div> |
| What is 2 to the power of 32? |
| </div> |
| <div class="example" onclick="setInput('List the files in the current directory using ls.')"> |
| <div class="example-tool"><ls></div> |
| List files in current directory |
| </div> |
| <div class="example" onclick="setInput('Read README.md and summarize what mac-tensor does.')"> |
| <div class="example-tool"><read></div> |
| Summarize the README |
| </div> |
| </div> |
| </div> |
| </div> |
| </main> |
|
|
| <footer> |
| <div class="input-row"> |
| <div class="input-wrap"> |
| <div id="image-preview" style="display:none; margin-bottom:8px;"> |
| <div style="position:relative; display:inline-block;"> |
| <img id="preview-img" style="max-height:80px; max-width:120px; border-radius:8px; border:1px solid var(--border);"> |
| <button id="remove-image" style="position:absolute; top:-6px; right:-6px; width:22px; height:22px; border-radius:50%; background:var(--red); color:white; border:none; cursor:pointer; font-size:14px; line-height:1;">×</button> |
| </div> |
| </div> |
| <textarea id="input" placeholder="Ask the agent anything..." rows="1"></textarea> |
| </div> |
| <input type="file" id="file-input" accept="image/*" style="display:none;"> |
| <button class="btn" id="upload-btn" title="Upload image" style="height:50px; width:50px; padding:0; font-size:20px;" data-vision="{{VISION_ENABLED}}">📷</button> |
| <button class="btn" id="ground-btn" title="Find objects (Falcon Perception)" style="height:50px; padding: 0 16px; display:none;" data-falcon="{{FALCON_ENABLED}}"> |
| <span style="font-size:16px;">🎯</span> Ground |
| </button> |
| <button class="btn primary send-btn" id="send-btn">Send</button> |
| </div> |
| </footer> |
|
|
| <script> |
| const messagesEl = document.getElementById('messages'); |
| const inputEl = document.getElementById('input'); |
| const sendBtn = document.getElementById('send-btn'); |
| const resetBtn = document.getElementById('reset-btn'); |
| const uploadBtn = document.getElementById('upload-btn'); |
| const groundBtn = document.getElementById('ground-btn'); |
| const fileInput = document.getElementById('file-input'); |
| const imagePreview = document.getElementById('image-preview'); |
| const previewImg = document.getElementById('preview-img'); |
| const removeImageBtn = document.getElementById('remove-image'); |
| |
| const VISION_ENABLED = uploadBtn.dataset.vision === 'true'; |
| const FALCON_ENABLED = groundBtn.dataset.falcon === 'true'; |
| if (!VISION_ENABLED) { |
| uploadBtn.style.display = 'none'; |
| } |
| if (FALCON_ENABLED) { |
| groundBtn.style.display = 'inline-flex'; |
| } |
| |
| let isGenerating = false; |
| let attachedImage = null; |
| const conversation = []; |
| |
| uploadBtn.addEventListener('click', () => fileInput.click()); |
| fileInput.addEventListener('change', (e) => { |
| const file = e.target.files[0]; |
| if (!file) return; |
| attachedImage = file; |
| const reader = new FileReader(); |
| reader.onload = (ev) => { |
| previewImg.src = ev.target.result; |
| imagePreview.style.display = 'block'; |
| }; |
| reader.readAsDataURL(file); |
| }); |
| removeImageBtn.addEventListener('click', () => { |
| attachedImage = null; |
| fileInput.value = ''; |
| imagePreview.style.display = 'none'; |
| }); |
| |
| |
| document.addEventListener('dragover', (e) => { |
| e.preventDefault(); |
| if (!VISION_ENABLED) return; |
| document.body.style.background = 'rgba(99, 102, 241, 0.05)'; |
| }); |
| document.addEventListener('dragleave', () => { |
| document.body.style.background = ''; |
| }); |
| document.addEventListener('drop', (e) => { |
| e.preventDefault(); |
| document.body.style.background = ''; |
| if (!VISION_ENABLED) return; |
| const file = e.dataTransfer.files[0]; |
| if (file && file.type.startsWith('image/')) { |
| attachedImage = file; |
| const reader = new FileReader(); |
| reader.onload = (ev) => { |
| previewImg.src = ev.target.result; |
| imagePreview.style.display = 'block'; |
| }; |
| reader.readAsDataURL(file); |
| } |
| }); |
| |
| function setInput(text) { |
| inputEl.value = text; |
| inputEl.focus(); |
| autoResize(); |
| } |
| |
| function autoResize() { |
| inputEl.style.height = 'auto'; |
| inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + 'px'; |
| } |
| |
| inputEl.addEventListener('input', autoResize); |
| inputEl.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| sendMessage(); |
| } |
| }); |
| |
| sendBtn.addEventListener('click', sendMessage); |
| |
| groundBtn.addEventListener('click', async () => { |
| if (isGenerating) return; |
| if (!attachedImage) { |
| alert('Upload an image first (📷 button or drag-and-drop)'); |
| return; |
| } |
| const query = inputEl.value.trim(); |
| if (!query) { |
| alert('Type what you want to find (e.g. "bird", "car", "person")'); |
| return; |
| } |
| |
| isGenerating = true; |
| groundBtn.disabled = true; |
| sendBtn.disabled = true; |
| |
| const imageDataUrl = previewImg.src; |
| const imageToSend = attachedImage; |
| attachedImage = null; |
| fileInput.value = ''; |
| imagePreview.style.display = 'none'; |
| inputEl.value = ''; |
| autoResize(); |
| |
| |
| appendMessage('user', `🎯 Find: "${query}"`, imageDataUrl); |
| |
| |
| const wrap = document.createElement('div'); |
| wrap.className = 'message'; |
| wrap.innerHTML = ` |
| <div class="message-role agent">Falcon Perception</div> |
| <div class="bubble agent"> |
| <div class="thinking"> |
| Running grounding model |
| <span class="thinking-dots"><span></span><span></span><span></span></span> |
| </div> |
| </div> |
| `; |
| messagesEl.appendChild(wrap); |
| scrollToBottom(); |
| const bubble = wrap.querySelector('.bubble'); |
| |
| try { |
| const fd = new FormData(); |
| fd.append('query', query); |
| fd.append('image', imageToSend); |
| |
| const response = await fetch('/api/falcon', { method: 'POST', body: fd }); |
| if (!response.ok) { |
| const err = await response.json().catch(() => ({error: 'unknown'})); |
| throw new Error(err.error || `HTTP ${response.status}`); |
| } |
| const data = await response.json(); |
| |
| |
| let metaHtml = ''; |
| if (data.masks && data.masks.length > 0) { |
| metaHtml = '<div style="margin-top:12px; font-size:13px; color:var(--text-muted);">'; |
| data.masks.forEach((m, i) => { |
| const cx = (m.centroid_norm.x * 100).toFixed(0); |
| const cy = (m.centroid_norm.y * 100).toFixed(0); |
| const area = (m.area_fraction * 100).toFixed(1); |
| metaHtml += `<div>#${m.id} — ${m.image_region}, center (${cx}%, ${cy}%), area ${area}%</div>`; |
| }); |
| metaHtml += '</div>'; |
| } |
| |
| bubble.innerHTML = ` |
| <div style="font-size:15px; margin-bottom:10px;"> |
| <b style="color:var(--orange);">Found ${data.count}</b> |
| ${data.count === 1 ? 'instance' : 'instances'} of |
| <i>"${escapeHtml(query)}"</i> |
| in ${data.elapsed_seconds}s |
| </div> |
| <div style="position:relative; display:inline-block;"> |
| <img src="${data.annotated_image}" style="max-width:100%; border-radius:10px; border:1px solid var(--border); display:block;"> |
| <button class="download-btn" style="position:absolute; top:8px; right:8px; padding:6px 12px; border-radius:6px; background:rgba(0,0,0,0.7); color:white; border:1px solid rgba(255,255,255,0.3); cursor:pointer; font-size:12px;">⬇ Download</button> |
| </div> |
| ${metaHtml} |
| `; |
| |
| const dlBtn = bubble.querySelector('.download-btn'); |
| dlBtn.addEventListener('click', () => { |
| const ts = new Date().toISOString().replace(/[:.]/g, '-'); |
| downloadDataUrl(data.annotated_image, `falcon-${query.replace(/\s+/g,'_')}-${ts}.png`); |
| }); |
| |
| conversation.push({ |
| role: 'falcon', |
| query, |
| count: data.count, |
| masks: data.masks, |
| annotated_image: data.annotated_image, |
| timestamp: new Date().toISOString(), |
| }); |
| } catch (e) { |
| bubble.innerHTML = `<span style="color: var(--red);">Error: ${escapeHtml(e.message)}</span>`; |
| } finally { |
| isGenerating = false; |
| groundBtn.disabled = false; |
| sendBtn.disabled = false; |
| scrollToBottom(); |
| } |
| }); |
| |
| resetBtn.addEventListener('click', async () => { |
| await fetch('/api/reset', { method: 'POST' }); |
| conversation.length = 0; |
| |
| messagesEl.innerHTML = ` |
| <div class="empty"> |
| <h2>Context cleared</h2> |
| <p>Start a new conversation.</p> |
| </div> |
| `; |
| }); |
| |
| |
| const saveBtn = document.getElementById('save-btn'); |
| saveBtn.addEventListener('click', () => { |
| if (conversation.length === 0) { |
| alert('No conversation to save yet — chat with the agent first!'); |
| return; |
| } |
| const exportData = { |
| timestamp: new Date().toISOString(), |
| model: document.querySelector('.brand-info b')?.textContent || 'mac-tensor', |
| messages: conversation, |
| }; |
| const blob = new Blob([JSON.stringify(exportData, null, 2)], {type: 'application/json'}); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `mac-tensor-${new Date().toISOString().replace(/[:.]/g, '-')}.json`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| }); |
| |
| |
| function downloadDataUrl(dataUrl, filename) { |
| const a = document.createElement('a'); |
| a.href = dataUrl; |
| a.download = filename; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| } |
| |
| function clearEmpty() { |
| const empty = messagesEl.querySelector('.empty'); |
| if (empty) empty.remove(); |
| const examples = messagesEl.querySelector('.examples'); |
| if (examples) examples.remove(); |
| } |
| |
| function appendMessage(role, content, imageDataUrl) { |
| clearEmpty(); |
| const wrap = document.createElement('div'); |
| wrap.className = 'message'; |
| let imageHtml = ''; |
| if (imageDataUrl) { |
| imageHtml = `<img src="${imageDataUrl}" style="max-width:300px; max-height:200px; border-radius:10px; border:1px solid var(--border); margin-bottom:8px; display:block;">`; |
| } |
| wrap.innerHTML = ` |
| <div class="message-role ${role}">${role === 'user' ? 'You' : 'Agent'}</div> |
| <div class="bubble ${role}">${imageHtml}${escapeHtml(content)}</div> |
| `; |
| messagesEl.appendChild(wrap); |
| scrollToBottom(); |
| |
| conversation.push({ |
| role, |
| text: content, |
| image: imageDataUrl || null, |
| timestamp: new Date().toISOString(), |
| }); |
| return wrap; |
| } |
| |
| function appendThinking() { |
| const wrap = document.createElement('div'); |
| wrap.className = 'message agent-progress'; |
| wrap.innerHTML = ` |
| <div class="message-role agent">Agent</div> |
| <div class="bubble agent"> |
| <div class="thinking"> |
| Thinking |
| <span class="thinking-dots"><span></span><span></span><span></span></span> |
| </div> |
| <div class="steps"></div> |
| </div> |
| `; |
| messagesEl.appendChild(wrap); |
| scrollToBottom(); |
| return wrap; |
| } |
| |
| function escapeHtml(text) { |
| const div = document.createElement('div'); |
| div.textContent = text; |
| return div.innerHTML; |
| } |
| |
| function scrollToBottom() { |
| document.querySelector('main').scrollTop = document.querySelector('main').scrollHeight; |
| } |
| |
| async function sendMessage() { |
| if (isGenerating) return; |
| const message = inputEl.value.trim(); |
| if (!message) return; |
| |
| isGenerating = true; |
| sendBtn.disabled = true; |
| inputEl.value = ''; |
| autoResize(); |
| |
| |
| const imageDataUrl = attachedImage ? previewImg.src : null; |
| const imageToSend = attachedImage; |
| |
| attachedImage = null; |
| fileInput.value = ''; |
| imagePreview.style.display = 'none'; |
| |
| appendMessage('user', message, imageDataUrl); |
| const progressWrap = appendThinking(); |
| const stepsContainer = progressWrap.querySelector('.steps'); |
| const thinkingEl = progressWrap.querySelector('.thinking'); |
| |
| let currentStep = null; |
| let finalText = ''; |
| |
| try { |
| let response; |
| if (imageToSend) { |
| |
| const fd = new FormData(); |
| fd.append('message', message); |
| fd.append('max_tokens', '200'); |
| fd.append('image', imageToSend); |
| response = await fetch('/api/chat_vision', { method: 'POST', body: fd }); |
| } else { |
| response = await fetch('/api/chat', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ |
| message, |
| max_iterations: 5, |
| max_tokens: 250, |
| }), |
| }); |
| } |
| |
| if (!response.ok) throw new Error(`HTTP ${response.status}`); |
| |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop() || ''; |
| |
| for (const line of lines) { |
| if (!line.startsWith('data: ')) continue; |
| try { |
| const event = JSON.parse(line.slice(6)); |
| handleEvent(event); |
| } catch (e) { |
| console.error('parse error', e); |
| } |
| } |
| } |
| |
| function handleEvent(event) { |
| if (event.type === 'step_start') { |
| |
| thinkingEl.innerHTML = ` |
| Step ${event.step}/${event.max} |
| <span class="thinking-dots"><span></span><span></span><span></span></span> |
| `; |
| } else if (event.type === 'tool_call') { |
| const toolDiv = document.createElement('div'); |
| toolDiv.className = 'tool-call'; |
| toolDiv.innerHTML = ` |
| <div class="tool-header" onclick="this.parentElement.classList.toggle('expanded')"> |
| <div class="tool-icon">⚙</div> |
| <div class="tool-name"><${escapeHtml(event.tool)}></div> |
| <div class="tool-args">${escapeHtml(event.args.slice(0, 80))}</div> |
| <div class="tool-toggle">▾</div> |
| </div> |
| <div class="tool-result">Running...</div> |
| `; |
| stepsContainer.appendChild(toolDiv); |
| scrollToBottom(); |
| currentStep = toolDiv; |
| } else if (event.type === 'tool_result') { |
| if (currentStep) { |
| currentStep.querySelector('.tool-result').textContent = event.result; |
| } |
| } else if (event.type === 'token') { |
| |
| finalText += event.text; |
| thinkingEl.innerHTML = `<div style="font-size:15px; line-height:1.55; white-space:pre-wrap;">${escapeHtml(finalText)}</div>`; |
| } else if (event.type === 'final') { |
| finalText = event.text; |
| } else if (event.type === 'error') { |
| thinkingEl.innerHTML = `<span style="color: var(--red);">Error: ${escapeHtml(event.message)}</span>`; |
| } else if (event.type === 'done') { |
| |
| thinkingEl.innerHTML = ''; |
| const finalDiv = document.createElement('div'); |
| finalDiv.style.fontSize = '15px'; |
| finalDiv.style.lineHeight = '1.55'; |
| finalDiv.style.whiteSpace = 'pre-wrap'; |
| finalDiv.textContent = finalText || '(no answer)'; |
| thinkingEl.appendChild(finalDiv); |
| |
| conversation.push({ |
| role: 'agent', |
| text: finalText || '(no answer)', |
| timestamp: new Date().toISOString(), |
| }); |
| } |
| } |
| } catch (e) { |
| thinkingEl.innerHTML = `<span style="color: var(--red);">Error: ${escapeHtml(e.message)}</span>`; |
| } finally { |
| isGenerating = false; |
| sendBtn.disabled = false; |
| inputEl.focus(); |
| scrollToBottom(); |
| } |
| } |
| |
| inputEl.focus(); |
| </script> |
| </body> |
| </html> |
|
|