diff --git a/app.js b/app.js index e86b38a..490be31 100644 --- a/app.js +++ b/app.js @@ -942,6 +942,12 @@ function animate() { } } + // Animate AutoLoRA panel — gentle hover float + if (autoLoRAPanelSprite) { + const ud = autoLoRAPanelSprite.userData; + autoLoRAPanelSprite.position.y = AUTOLORA_BASE_Y + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.18; + } + // Animate rune ring — orbit and vertical float for (const rune of runeSprites) { const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED; @@ -1929,3 +1935,240 @@ function showTimmySpeech(text) { timmySpeechSprite = sprite; timmySpeechState = { startTime: clock.getElapsedTime(), sprite }; } + +// === AUTOLORA DASHBOARD PANEL === +// Holographic 3D panel showing AutoLoRA training status, loss curve, and eval scores. +// Positioned to the right of the platform. Polls /api/autolora-status.json every 60 s. + +const AUTOLORA_REFRESH_MS = 60 * 1000; + +/** @type {{ status: string, run_id: string, epoch: number, total_epochs: number, loss_history: number[], eval: { accuracy: number, f1: number, bleu: number } }} */ +const AUTOLORA_STUB = { + status: 'training', + run_id: 'lora-run-042', + epoch: 6, + total_epochs: 12, + loss_history: [2.81, 2.34, 1.97, 1.62, 1.38, 1.19], + eval: { accuracy: 0.73, f1: 0.71, bleu: 0.48 }, +}; + +/** + * Builds a canvas texture for the AutoLoRA holo-panel. + * @param {typeof AUTOLORA_STUB} data + * @returns {THREE.CanvasTexture} + */ +function createAutoLoRATexture(data) { + const W = 480, H = 320; + const canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + const ctx = canvas.getContext('2d'); + + const STATUS_COLORS = { training: '#00ff88', idle: '#4488ff', done: '#ffcc00', error: '#ff4444' }; + const sc = STATUS_COLORS[data.status] || '#4488ff'; + + // Background + ctx.fillStyle = 'rgba(0, 6, 20, 0.92)'; + ctx.fillRect(0, 0, W, H); + + // Outer border + ctx.strokeStyle = sc; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, W - 2, H - 2); + + // Inner border (faint) + ctx.strokeStyle = sc; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.3; + ctx.strokeRect(4, 4, W - 8, H - 8); + ctx.globalAlpha = 1.0; + + // Title + ctx.font = 'bold 18px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + ctx.fillText('AUTOLORA', 14, 30); + + // Status dot + label + ctx.beginPath(); + ctx.arc(W - 26, 18, 8, 0, Math.PI * 2); + ctx.fillStyle = sc; + ctx.fill(); + ctx.font = '11px "Courier New", monospace'; + ctx.fillStyle = sc; + ctx.textAlign = 'right'; + ctx.fillText(data.status.toUpperCase(), W - 14, 36); + ctx.textAlign = 'left'; + + // Run ID + epoch progress + ctx.font = '11px "Courier New", monospace'; + ctx.fillStyle = '#556688'; + ctx.fillText('RUN', 14, 54); + ctx.font = '13px "Courier New", monospace'; + ctx.fillStyle = '#aabbcc'; + ctx.fillText(data.run_id, 14, 68); + + // Epoch bar + const progress = data.total_epochs > 0 ? data.epoch / data.total_epochs : 0; + ctx.font = '10px "Courier New", monospace'; + ctx.fillStyle = '#556688'; + ctx.fillText(`EPOCH ${data.epoch} / ${data.total_epochs}`, 240, 54); + const barX = 240, barY = 60, barW = 220, barH = 10; + ctx.fillStyle = '#0a1828'; + ctx.fillRect(barX, barY, barW, barH); + ctx.fillStyle = sc; + ctx.fillRect(barX, barY, Math.round(barW * progress), barH); + ctx.strokeStyle = '#1a3a6a'; + ctx.lineWidth = 1; + ctx.strokeRect(barX, barY, barW, barH); + + // Separator + ctx.strokeStyle = '#1a3a6a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(14, 80); + ctx.lineTo(W - 14, 80); + ctx.stroke(); + + // Loss curve mini-chart + ctx.font = '10px "Courier New", monospace'; + ctx.fillStyle = '#556688'; + ctx.fillText('LOSS CURVE', 14, 96); + + const chartX = 14, chartY = 102, chartW = 200, chartH = 100; + ctx.fillStyle = 'rgba(0, 16, 36, 0.6)'; + ctx.fillRect(chartX, chartY, chartW, chartH); + ctx.strokeStyle = '#1a3a6a'; + ctx.strokeRect(chartX, chartY, chartW, chartH); + + const hist = data.loss_history; + if (hist && hist.length > 1) { + const maxLoss = Math.max(...hist); + const minLoss = Math.min(...hist); + const range = maxLoss - minLoss || 1; + const pad = 6; + + ctx.beginPath(); + ctx.strokeStyle = '#ff8844'; + ctx.lineWidth = 2; + hist.forEach((v, idx) => { + const px = chartX + pad + (idx / (hist.length - 1)) * (chartW - pad * 2); + const py = chartY + pad + (1 - (v - minLoss) / range) * (chartH - pad * 2); + if (idx === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); + }); + ctx.stroke(); + + // Endpoint dot + const lastPx = chartX + pad + chartW - pad * 2; + const lastPy = chartY + pad + (1 - (hist[hist.length - 1] - minLoss) / range) * (chartH - pad * 2); + ctx.beginPath(); + ctx.arc(lastPx, lastPy, 3, 0, Math.PI * 2); + ctx.fillStyle = '#ff8844'; + ctx.fill(); + + // Latest loss value + ctx.font = '11px "Courier New", monospace'; + ctx.fillStyle = '#ff8844'; + ctx.fillText(hist[hist.length - 1].toFixed(3), chartX + 4, chartY + chartH - 4); + } + + // Separator vertical + ctx.strokeStyle = '#1a3a6a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(230, 85); + ctx.lineTo(230, 210); + ctx.stroke(); + + // Eval scores + ctx.font = '10px "Courier New", monospace'; + ctx.fillStyle = '#556688'; + ctx.fillText('EVAL SCORES', 244, 96); + + const evalX = 244; + const evalItems = [ + { label: 'ACCURACY', value: data.eval.accuracy, color: '#00ff88' }, + { label: 'F1', value: data.eval.f1, color: '#4488ff' }, + { label: 'BLEU', value: data.eval.bleu, color: '#ffcc00' }, + ]; + evalItems.forEach((item, i) => { + const ey = 112 + i * 36; + ctx.font = '10px "Courier New", monospace'; + ctx.fillStyle = '#556688'; + ctx.fillText(item.label, evalX, ey); + + const bx = evalX, by = ey + 4, bw = 220, bh = 14; + ctx.fillStyle = 'rgba(0, 16, 36, 0.6)'; + ctx.fillRect(bx, by, bw, bh); + ctx.fillStyle = item.color; + ctx.fillRect(bx, by, Math.round(bw * Math.min(1, Math.max(0, item.value))), bh); + ctx.strokeStyle = '#1a3a6a'; + ctx.lineWidth = 1; + ctx.strokeRect(bx, by, bw, bh); + + ctx.font = 'bold 12px "Courier New", monospace'; + ctx.fillStyle = item.color; + ctx.textAlign = 'right'; + ctx.fillText((item.value * 100).toFixed(1) + '%', evalX + bw, ey); + ctx.textAlign = 'left'; + }); + + // Bottom separator + ctx.strokeStyle = '#1a3a6a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(14, 215); + ctx.lineTo(W - 14, 215); + ctx.stroke(); + + // Footer: last updated hint + ctx.font = '10px "Courier New", monospace'; + ctx.fillStyle = '#334455'; + ctx.fillText('AUTO-LORA TRAINING MONITOR', 14, 232); + ctx.textAlign = 'right'; + ctx.fillStyle = '#4488ff'; + ctx.fillText('\u21ba 60s', W - 14, 232); + ctx.textAlign = 'left'; + + return new THREE.CanvasTexture(canvas); +} + +/** @type {THREE.Sprite|null} */ +let autoLoRAPanelSprite = null; + +const AUTOLORA_POS = new THREE.Vector3(11.5, 5.5, -4.5); +const AUTOLORA_BASE_Y = AUTOLORA_POS.y; + +function buildAutoLoRAPanel(data) { + if (autoLoRAPanelSprite) { + scene.remove(autoLoRAPanelSprite); + if (autoLoRAPanelSprite.material.map) autoLoRAPanelSprite.material.map.dispose(); + autoLoRAPanelSprite.material.dispose(); + autoLoRAPanelSprite = null; + } + + const texture = createAutoLoRATexture(data); + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0.93, + depthWrite: false, + }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(7.2, 4.8, 1); + sprite.position.copy(AUTOLORA_POS); + sprite.userData = { floatPhase: 1.3, floatSpeed: 0.14 }; + scene.add(sprite); + autoLoRAPanelSprite = sprite; +} + +async function refreshAutoLoRAPanel() { + let data = AUTOLORA_STUB; + try { + const res = await fetch('/api/autolora-status.json'); + if (res.ok) data = await res.json(); + } catch { /* use stub */ } + buildAutoLoRAPanel(data); +} + +refreshAutoLoRAPanel(); +setInterval(refreshAutoLoRAPanel, AUTOLORA_REFRESH_MS);