waltgrace's picture
initial release: deploy code + split scripts
0e41b61 verified
<!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 */
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">&lt;shell&gt;</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">&lt;python&gt;</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">&lt;ls&gt;</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">&lt;read&gt;</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">&lt;${escapeHtml(event.tool)}&gt;</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>