| (() => { |
| const socket = io(); |
|
|
| const logEl = document.getElementById('log'); |
| const thinkingEl = document.getElementById('thinking'); |
| const thinkingLabel = document.getElementById('thinkingLabel'); |
| const statusDot = document.getElementById('statusDot'); |
| const statusLabel = document.getElementById('statusLabel'); |
| const agentNameEl = document.getElementById('agentName'); |
| const composer = document.getElementById('composer'); |
| const input = document.getElementById('composerInput'); |
| const toolListEl = document.getElementById('toolList'); |
| const benchToggle = document.getElementById('benchToggle'); |
| const toolbench = document.getElementById('toolbench'); |
| const modelSelect = document.getElementById('modelSelect'); |
|
|
| let agentName = 'Forge'; |
| let thinkingTimer = null; |
| const ticketNodes = new Map(); |
| const toolsById = new Map(); |
|
|
| const icon = { |
| check: '<svg viewBox="0 0 24 24" fill="none"><path d="M4 12.5 L10 18 L20 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>', |
| x: '<svg viewBox="0 0 24 24" fill="none"><path d="M6 6 L18 18 M18 6 L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>', |
| }; |
|
|
| function scrollToBottom() { |
| logEl.scrollTop = logEl.scrollHeight; |
| } |
|
|
| function el(tag, className, text) { |
| const node = document.createElement(tag); |
| if (className) node.className = className; |
| if (text !== undefined) node.textContent = text; |
| return node; |
| } |
|
|
| function renderUser(entry) { |
| logEl.appendChild(el('div', 'msg msg--user', entry.content)); |
| } |
|
|
| function renderAssistant(entry) { |
| const node = el('div', 'msg msg--assistant', entry.content); |
| node.dataset.name = agentName; |
| logEl.appendChild(node); |
| } |
|
|
| function renderSystem(entry, isError) { |
| logEl.appendChild(el('div', 'msg msg--system' + (isError ? ' is-error' : ''), entry.content)); |
| } |
|
|
| function stampLabel(tool) { |
| if (tool.status === 'approved') return 'approved'; |
| if (tool.status === 'rejected') return 'declined'; |
| return tool.kind === 'fix' ? 'fix pending' : 'pending approval'; |
| } |
|
|
| function stampClass(tool) { |
| if (tool.status === 'approved') return 'stamp approved'; |
| if (tool.status === 'rejected') return 'stamp rejected'; |
| return 'stamp'; |
| } |
|
|
| function renderTicket(tool) { |
| const card = el('div', 'ticket'); |
|
|
| const top = el('div', 'ticket__top'); |
| const nameWrap = document.createElement('div'); |
| const nameEl = el('span', 'ticket__name', tool.name); |
| nameWrap.appendChild(nameEl); |
| if (tool.kind === 'fix') nameWrap.appendChild(el('span', 'ticket__kind', 'fix')); |
| const stamp = el('span', stampClass(tool), stampLabel(tool)); |
| top.appendChild(nameWrap); |
| top.appendChild(stamp); |
| card.appendChild(top); |
|
|
| card.appendChild(el('p', 'ticket__desc', tool.description)); |
| if (tool.reason) card.appendChild(el('p', 'ticket__reason', `“${tool.reason}”`)); |
|
|
| const details = document.createElement('details'); |
| const summary = el('summary', null, 'view code'); |
| const pre = document.createElement('pre'); |
| pre.textContent = tool.code; |
| details.appendChild(summary); |
| details.appendChild(pre); |
| card.appendChild(details); |
|
|
| const actions = el('div', 'ticket__actions'); |
| const resolved = el('div', 'ticket__resolved'); |
| resolved.hidden = true; |
|
|
| if (tool.status === 'pending') { |
| const approveBtn = el('button', 'ticket__btn ticket__btn--approve'); |
| approveBtn.innerHTML = icon.check + '<span>Approve</span>'; |
| approveBtn.addEventListener('click', () => { |
| approveBtn.disabled = true; |
| socket.emit('tool:approve', tool.id); |
| }); |
|
|
| const rejectBtn = el('button', 'ticket__btn ticket__btn--reject'); |
| rejectBtn.innerHTML = icon.x + '<span>Decline</span>'; |
| rejectBtn.addEventListener('click', () => { |
| rejectBtn.disabled = true; |
| socket.emit('tool:reject', { toolId: tool.id }); |
| }); |
|
|
| actions.appendChild(approveBtn); |
| actions.appendChild(rejectBtn); |
| } else { |
| resolved.hidden = false; |
| resolved.textContent = tool.status === 'approved' ? 'Ready to use.' : 'Not built.'; |
| } |
|
|
| card.appendChild(actions); |
| card.appendChild(resolved); |
| logEl.appendChild(card); |
|
|
| ticketNodes.set(tool.id, { card, stamp, actions, resolved }); |
| } |
|
|
| function updateTicket(matchId, tool) { |
| const node = ticketNodes.get(matchId); |
| if (!node) return; |
| node.stamp.className = stampClass(tool); |
| node.stamp.textContent = stampLabel(tool); |
| node.actions.innerHTML = ''; |
| node.resolved.hidden = false; |
| node.resolved.textContent = tool.status === 'approved' ? 'Ready to use.' : 'Not built.'; |
| } |
|
|
| function renderEntry(entry) { |
| switch (entry.type) { |
| case 'user': |
| renderUser(entry); |
| break; |
| case 'assistant': |
| renderAssistant(entry); |
| break; |
| case 'tool_proposed': |
| toolsById.set(entry.tool.id, entry.tool); |
| renderTicket(entry.tool); |
| break; |
| case 'tool_update': { |
| toolsById.set(entry.tool.id, entry.tool); |
| const matchId = entry.matchId || entry.tool.id; |
| updateTicket(matchId, entry.tool); |
| renderSystem({ |
| content: `${entry.tool.name} ${entry.action === 'approved' ? 'approved — Forge can use it now.' : 'declined.'}`, |
| }); |
| break; |
| } |
| case 'tool_error': |
| renderSystem({ content: `${entry.toolName} hit an error: ${entry.message}` }, true); |
| break; |
| default: |
| break; |
| } |
| } |
|
|
| function renderToolbench() { |
| const tools = Array.from(toolsById.values()); |
| toolListEl.innerHTML = ''; |
| if (!tools.length) { |
| toolListEl.appendChild(el('p', 'toolbench__empty', 'No tools yet — ask Forge to build something.')); |
| return; |
| } |
| tools |
| .slice() |
| .sort((a, b) => (a.status === 'pending' ? -1 : 1) - (b.status === 'pending' ? -1 : 1)) |
| .forEach((tool) => { |
| const row = el('div', 'tool-row'); |
| row.appendChild(el('span', `tool-row__dot ${tool.status}`)); |
| row.appendChild(el('span', 'tool-row__name', tool.name)); |
| row.appendChild(el('span', 'tool-row__status', tool.status)); |
| toolListEl.appendChild(row); |
| }); |
| } |
|
|
| function populateModels(models, current) { |
| modelSelect.innerHTML = ''; |
| (models || []).forEach((m) => { |
| const opt = document.createElement('option'); |
| opt.value = m; |
| opt.textContent = m; |
| if (m === current) opt.selected = true; |
| modelSelect.appendChild(opt); |
| }); |
| } |
|
|
| modelSelect.addEventListener('change', () => { |
| socket.emit('model:change', modelSelect.value); |
| }); |
|
|
| socket.on('init', (data) => { |
| agentName = data.agentName || 'Forge'; |
| agentNameEl.textContent = agentName; |
| populateModels(data.availableModels, data.model); |
| (data.tools || []).forEach((t) => toolsById.set(t.id, t)); |
| (data.log || []).forEach(renderEntry); |
| renderToolbench(); |
| scrollToBottom(); |
| }); |
|
|
| socket.on('log:entry', (entry) => { |
| renderEntry(entry); |
| renderToolbench(); |
| scrollToBottom(); |
| }); |
|
|
| socket.on('model:changed', ({ model }) => { |
| const opt = modelSelect.querySelector(`option[value="${model}"]`); |
| if (opt) opt.selected = true; |
| }); |
|
|
| socket.on('agent:status', ({ state, detail }) => { |
| if (thinkingTimer) { clearTimeout(thinkingTimer); thinkingTimer = null; } |
| if (state === 'idle') { |
| thinkingEl.hidden = true; |
| statusDot.classList.remove('busy'); |
| statusLabel.textContent = 'online'; |
| } else if (state === 'thinking' || state === 'running_tool') { |
| thinkingEl.hidden = false; |
| thinkingLabel.textContent = state === 'thinking' ? 'thinking' : `running ${detail || 'a tool'}`; |
| statusDot.classList.add('busy'); |
| statusLabel.textContent = state === 'thinking' ? 'thinking' : 'building'; |
| thinkingTimer = setTimeout(() => { |
| thinkingEl.hidden = true; |
| statusDot.classList.remove('busy'); |
| statusLabel.textContent = 'online'; |
| thinkingTimer = null; |
| }, 90000); |
| } |
| scrollToBottom(); |
| }); |
|
|
| socket.on('connect_error', () => { |
| statusLabel.textContent = 'offline'; |
| statusDot.classList.remove('busy'); |
| }); |
|
|
| composer.addEventListener('submit', (e) => { |
| e.preventDefault(); |
| const text = input.value.trim(); |
| if (!text) return; |
| socket.emit('chat:send', text); |
| input.value = ''; |
| input.style.height = 'auto'; |
| }); |
|
|
| input.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| composer.requestSubmit(); |
| } |
| }); |
|
|
| input.addEventListener('input', () => { |
| input.style.height = 'auto'; |
| input.style.height = Math.min(input.scrollHeight, 160) + 'px'; |
| }); |
|
|
| benchToggle.addEventListener('click', () => { |
| toolbench.classList.toggle('open'); |
| }); |
| })(); |
|
|