Image-Text-to-Text
MLX
English
apple-silicon
Mixture of Experts
mixture-of-experts
vision-language
gemma
falcon-perception
inference
Instructions to use waltgrace/mlx-expert-sniper with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use waltgrace/mlx-expert-sniper with MLX:
# Make sure mlx-vlm is installed # pip install --upgrade mlx-vlm from mlx_vlm import load, generate from mlx_vlm.prompt_utils import apply_chat_template from mlx_vlm.utils import load_config # Load the model model, processor = load("waltgrace/mlx-expert-sniper") config = load_config("waltgrace/mlx-expert-sniper") # Prepare input image = ["http://images.cocodataset.org/val2017/000000039769.jpg"] prompt = "Describe this image." # Apply chat template formatted_prompt = apply_chat_template( processor, config, prompt, num_images=1 ) # Generate output output = generate(model, processor, formatted_prompt, image) print(output) - Notebooks
- Google Colab
- Kaggle
- Local Apps
- LM Studio
| <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 */ | |
| 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 indicator */ | |
| .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; } | |
| } | |
| /* Chat area */ | |
| 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 cards */ | |
| .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 indicator */ | |
| .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 indicator */ | |
| .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; } | |
| } | |
| /* Input bar */ | |
| 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 */ | |
| .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 state */ | |
| .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; | |
| } | |
| /* Scrollbar */ | |
| ::-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 = []; // [{role, text, image?, tools?}] | |
| 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'; | |
| }); | |
| // Drag-and-drop | |
| 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(); | |
| // Show user message in chat | |
| appendMessage('user', `🎯 Find: "${query}"`, imageDataUrl); | |
| // Append a "grounding" placeholder | |
| 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(); | |
| // Render the annotated image + count summary + mask metadata | |
| 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} | |
| `; | |
| // Wire up the download button | |
| 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`); | |
| }); | |
| // Save to conversation log | |
| 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; | |
| // Clear UI | |
| messagesEl.innerHTML = ` | |
| <div class="empty"> | |
| <h2>Context cleared</h2> | |
| <p>Start a new conversation.</p> | |
| </div> | |
| `; | |
| }); | |
| // Save conversation as JSON | |
| 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); | |
| }); | |
| // Helper: download an image from a data 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(); | |
| // Track in conversation log | |
| 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(); | |
| // Capture the image data URL to show it in the message bubble | |
| const imageDataUrl = attachedImage ? previewImg.src : null; | |
| const imageToSend = attachedImage; | |
| // Clear the preview now (so it doesn't stick around for the next message) | |
| 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) { | |
| // Vision mode — multipart/form-data | |
| 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') { | |
| // Update the thinking indicator | |
| 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') { | |
| // Vision mode streams tokens directly (no tool calls) | |
| 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') { | |
| // Replace thinking indicator with final answer | |
| 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); | |
| // Save to conversation log | |
| 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> | |