/* presa-mano GUI */ const $ = (id) => document.getElementById(id); // ---------- tab switching ---------- document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); $('tab-' + btn.dataset.tab).classList.add('active'); }); }); function setStatus(msg, cls = '') { const strip = $('statusStrip'); strip.textContent = msg || ''; strip.style.color = cls === 'err' ? 'var(--danger)' : cls === 'ok' ? 'var(--ok)' : 'var(--dim)'; } // ---------- button flash + toast feedback ---------- // Used by every command-sending handler so the user sees an immediate, // unambiguous success/failure signal (beyond the log panels that require // scrolling). Call flashBtn(btn, ok) to flash the button, and toast(title, // body, ok) to show a corner notification. function flashBtn(btn, ok) { if (!btn || !btn.classList) return; btn.classList.remove('flash-ok', 'flash-err'); // restart animation void btn.offsetWidth; btn.classList.add(ok ? 'flash-ok' : 'flash-err'); setTimeout(() => btn.classList.remove(ok ? 'flash-ok' : 'flash-err'), ok ? 900 : 1300); } function toast(title, body, ok = true, ttlMs = 3500) { const host = $('toastHost'); if (!host) return; const el = document.createElement('div'); el.className = 'toast ' + (ok ? 'ok' : 'err'); const t = document.createElement('div'); t.className = 't-title'; t.textContent = (ok ? '✓ ' : '✗ ') + title; el.appendChild(t); if (body) { const b = document.createElement('div'); b.className = 't-body'; b.textContent = typeof body === 'string' ? body : JSON.stringify(body); el.appendChild(b); } host.appendChild(el); setTimeout(() => { el.classList.add('fade'); }, ttlMs - 400); setTimeout(() => { el.remove(); }, ttlMs); } // Short synthetic beep via Web Audio — no external asset. Used to audibly flag // new anomalies that flip the system from OK -> WARN/ERR. Gated by the // `diagBeep` checkbox so users can silence it. function beep(freq = 880, ms = 140, gain = 0.18) { if (!window.AudioContext && !window.webkitAudioContext) return; try { const ctx = beep._ctx || (beep._ctx = new (window.AudioContext || window.webkitAudioContext)()); const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = freq; g.gain.value = gain; osc.connect(g); g.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + ms / 1000); } catch {} } function beepWarn() { beep(880, 130); } function beepErr() { beep(440, 150); setTimeout(() => beep(330, 180), 170); } // Summarise a successful response in one short line — prefer "dry-run" hint // or response 'ok', else truncated JSON. function summarizeOk(resp) { if (!resp || typeof resp !== 'object') return ''; if (resp.dry_run) return 'dry-run (nessun comando reale inviato)'; if (resp.ok === true && Object.keys(resp).length <= 3) return 'ok'; const short = JSON.stringify(resp); return short.length > 140 ? short.slice(0, 140) + '…' : short; } function fmtTimings(t) { if (!t || typeof t !== 'object') return ''; // Put 'total' last; others in insertion order. const keys = Object.keys(t).filter(k => k !== 'total'); const parts = keys.map(k => `${k}=${Number(t[k]).toFixed(1)}ms`); if (t.total != null) parts.push(`TOTAL=${Number(t.total).toFixed(1)}ms`); return parts.join(' '); } async function api(method, path, body = null) { const opts = { method, headers: {} }; if (body !== null) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const r = await fetch(path, opts); const text = await r.text(); let data; try { data = JSON.parse(text); } catch { data = text; } if (!r.ok) { const detail = (data && data.detail) || text || r.statusText; throw new Error(`${r.status} ${detail}`); } return data; } // ==================== TROVA UTENSILE ==================== const graspCanvas = $('graspCanvas'); const graspCtx = graspCanvas.getContext('2d'); let displayedGrasp = null; let graspRgbInFlight = false; // (rimossi cropCanvas/scatter — ora c'è findResultCanvas) let lastDetections = []; // all detections, sorted by confidence desc let lastSelectedClass = null; // class name whose point cloud is displayed let knownClasses = []; // names discovered from /api/perception/classes // Stable per-class color palette (Okabe-Ito, colour-blind safe). const CLASS_PALETTE = ['#4da3ff', '#f39c12', '#2ecc71', '#e74c3c', '#9b59b6', '#1abc9c', '#e67e22', '#fd79a8']; function colorForClass(name) { if (!name) return '#888'; let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0; return CLASS_PALETTE[Math.abs(h) % CLASS_PALETTE.length]; } function drawGraspFrame() { if (displayedGrasp) { if (graspCanvas.width !== displayedGrasp.naturalWidth) { graspCanvas.width = displayedGrasp.naturalWidth; graspCanvas.height = displayedGrasp.naturalHeight; } graspCtx.drawImage(displayedGrasp, 0, 0); } lastDetections.forEach(d => { const b = d.bbox; const color = colorForClass(d.class); const isSel = lastSelectedClass && d.class === lastSelectedClass; graspCtx.strokeStyle = color; graspCtx.lineWidth = isSel ? 4 : 2; graspCtx.setLineDash(isSel ? [] : [6, 4]); graspCtx.strokeRect(b.x1, b.y1, b.x2 - b.x1, b.y2 - b.y1); graspCtx.setLineDash([]); const label = `${d.class || '?'} ${(d.confidence*100).toFixed(0)}%${isSel ? ' ★' : ''}`; graspCtx.font = 'bold 13px sans-serif'; const tw = graspCtx.measureText(label).width + 8; const ty = b.y1 > 18 ? b.y1 - 4 : b.y1 + 16; graspCtx.fillStyle = color; graspCtx.fillRect(b.x1, ty - 14, tw, 18); graspCtx.fillStyle = '#0b0f14'; graspCtx.fillText(label, b.x1 + 4, ty); }); } function refreshGraspRgb() { if (graspRgbInFlight) return; graspRgbInFlight = true; const img = new Image(); img.onload = () => { displayedGrasp = img; graspRgbInFlight = false; drawGraspFrame(); }; img.onerror = () => { graspRgbInFlight = false; }; img.src = `/api/calibration/rgb.jpg?t=${Date.now()}`; } setInterval(() => { if ($('tab-grasp').classList.contains('active')) refreshGraspRgb(); }, 150); // click-to-measure on RGB graspCanvas.addEventListener('click', async (ev) => { const rect = graspCanvas.getBoundingClientRect(); const u = Math.round((ev.clientX - rect.left) * (graspCanvas.width / rect.width)); const v = Math.round((ev.clientY - rect.top) * (graspCanvas.height / rect.height)); $('measureInfo').textContent = `measuring (u=${u}, v=${v})…`; try { const r = await api('GET', `/api/perception/measure?u=${u}&v=${v}&radius=6`); const cam = r.camera || {}; let line = `(u=${u}, v=${v}) cam: `; const xyz = (o) => o && (o.x !== undefined ? `x=${(+o.x).toFixed(3)} y=${(+o.y).toFixed(3)} z=${(+o.z).toFixed(3)}` : JSON.stringify(o)); line += xyz(cam.point || cam.xyz || cam); const d = cam.distance_m ?? cam.distance ?? null; if (d != null) line += ` d=${(+d*100).toFixed(1)} cm`; if (r.base) line += ` base: ${xyz(r.base)}`; const t = fmtTimings(r.timings_ms); if (t) line += ` [${t}]`; $('measureInfo').textContent = line; // marker graspCtx.fillStyle = '#e74c3c'; graspCtx.beginPath(); graspCtx.arc(u, v, 5, 0, 2*Math.PI); graspCtx.fill(); } catch (e) { $('measureInfo').textContent = `measure error: ${e.message}`; } }); function pickedClass() { const v = $('graspObjectPick').value; return v ? v : null; } // Apply a /scan response to local state + canvases. function applyScanResponse(r) { lastDetections = Array.isArray(r.detections) ? r.detections : []; const sel = r.selected || null; lastSelectedClass = sel ? sel.class : null; refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); drawGraspFrame(); } $('detectBtn').addEventListener('click', async () => { try { setStatus('YOLO + pointcloud…'); const body = { conf: +$('graspConf').value || null, class_name: pickedClass(), include_points: true, }; const r = await api('POST', '/api/perception/scan', body); applyScanResponse(r); $('graspOut').textContent = JSON.stringify({ detections: r.detections, selected: r.selected, n_points: r.n_points, frame: r.points_base ? 'base' : 'camera', timings_ms: r.timings_ms, }, null, 2); const nDet = r.detections ? r.detections.length : 0; setStatus(`${nDet} detection · ${r.n_points || 0} punti · ${fmtTimings(r.timings_ms)}`, 'ok'); } catch (e) { setStatus(e.message, 'err'); } }); // --- continuous detection loop --- const STATS_WINDOW = 5; const MISS_LIMIT = 5; // consecutive frames without detection before clearing the view let loopTimer = null; let loopInFlight = false; let recentCentroids = []; // [[x,y,z], ...] in base frame if calibrated, else camera let missStreak = 0; function clearDetectionState(reason) { lastDetections = []; lastSelectedClass = null; recentCentroids = []; drawGraspFrame(); // redraws RGB without bbox overlay updateStats(); // resets table to dashes refreshObjectPickOptions([]); renderGraspLegend([]); $('graspOut').textContent = reason || '(nessuna detection)'; } function centroidOf(pts) { if (!pts || !pts.length) return null; let sx = 0, sy = 0, sz = 0; for (const p of pts) { sx += p[0]; sy += p[1]; sz += p[2]; } return [sx / pts.length, sy / pts.length, sz / pts.length]; } function updateStats() { const rows = $('statsTable').querySelectorAll('tbody tr'); const empty = ['—','—','—','—','—']; $('statsWindow').textContent = STATS_WINDOW; if (recentCentroids.length === 0) { rows.forEach(r => { r.children[1].textContent='—'; r.children[2].textContent='—'; r.children[3].textContent='—'; r.children[4].textContent='—'; r.children[5].textContent='—'; }); return; } const n = recentCentroids.length; for (let axis = 0; axis < 3; axis++) { const vals = recentCentroids.map(c => c[axis]); const mean = vals.reduce((a,b) => a+b, 0) / n; const vmin = Math.min(...vals), vmax = Math.max(...vals); const variance = n > 1 ? vals.reduce((s,v) => s + (v-mean)*(v-mean), 0) / (n - 1) : 0; const std = Math.sqrt(variance); const r = rows[axis]; r.children[1].textContent = mean.toFixed(4); r.children[2].textContent = (std * 1000).toFixed(2); r.children[3].textContent = ((vmax - vmin) * 1000).toFixed(2); r.children[4].textContent = vmin.toFixed(4); r.children[5].textContent = vmax.toFixed(4); } } async function loopTick() { if (loopInFlight) return; loopInFlight = true; const t0 = performance.now(); try { const body = { conf: +$('graspConf').value || null, class_name: pickedClass(), include_points: true, }; const r = await api('POST', '/api/perception/scan', body); const pts = r.points_base || r.points_camera || []; const frame = r.points_base ? 'base' : 'camera'; lastDetections = Array.isArray(r.detections) ? r.detections : []; lastSelectedClass = r.selected ? r.selected.class : null; refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); drawGraspFrame(); const c = centroidOf(pts); if (c) { recentCentroids.push(c); if (recentCentroids.length > STATS_WINDOW) recentCentroids.shift(); updateStats(); } const nDet = lastDetections.length; if (nDet === 0) throw new Error('404 no object detected'); missStreak = 0; const dt = performance.now() - t0; const selMsg = r.selected ? `sel=${lastSelectedClass}` : (pickedClass() ? `sel=${pickedClass()} NON rilevata` : 'sel=—'); $('statsMeta').textContent = `loop ON · ${nDet} detection · ${selMsg} · frame=${frame} · n=${r.n_points} · iter ${dt.toFixed(0)}ms · ${fmtTimings(r.timings_ms)}`; $('statsMeta').style.color = r.selected ? 'var(--ok)' : 'var(--warn)'; } catch (e) { const msg = e.message || ''; // Classify: // - true miss (404/422): object not detected → increments miss streak // - transient upstream (500/502/503 from YOLO/Nuwa): skip silently, no streak bump // - anything else: surface as real error const isMiss = /^404\b|no object|^422\b|too few/i.test(msg); const isTransient = /^(500|502|503)\b|bad gateway|service unavailable|temporarily/i.test(msg); if (isMiss) { missStreak++; if (missStreak >= MISS_LIMIT) { clearDetectionState(`nessun oggetto per ${MISS_LIMIT} frame consecutivi`); $('statsMeta').textContent = `loop ON · nessun oggetto per ${MISS_LIMIT} frame → dati azzerati`; $('statsMeta').style.color = 'var(--warn)'; } else { $('statsMeta').textContent = `loop ON · miss ${missStreak}/${MISS_LIMIT}`; $('statsMeta').style.color = 'var(--warn)'; } } else if (isTransient) { $('statsMeta').textContent = `loop ON · upstream busy (skip): ${msg}`; $('statsMeta').style.color = 'var(--warn)'; } else { $('statsMeta').textContent = `loop error: ${msg}`; $('statsMeta').style.color = 'var(--danger)'; } } finally { loopInFlight = false; } } function startLoop() { if (loopTimer) return; const rate = Math.max(100, +$('loopRateMs').value || 500); missStreak = 0; recentCentroids = []; updateStats(); loopTick(); loopTimer = setInterval(loopTick, rate); const b = $('loopBtn'); b.textContent = '■ Stop loop'; b.classList.add('running'); $('statsMeta').textContent = 'loop ON'; $('statsMeta').style.color = 'var(--ok)'; } function stopLoop() { if (loopTimer) { clearInterval(loopTimer); loopTimer = null; } const b = $('loopBtn'); b.textContent = '▶ Loop riconoscimento'; b.classList.remove('running'); $('statsMeta').textContent = 'loop inattivo'; $('statsMeta').style.color = 'var(--dim)'; } $('loopBtn').addEventListener('click', () => (loopTimer ? stopLoop() : startLoop())); // If user leaves the tab, pause to avoid background load document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => { if (btn.dataset.tab !== 'grasp' && loopTimer) stopLoop(); })); // --- object pick select + legend + class discovery --- // Build per-class summary: best (highest-confidence) detection and instance count. function bestPerClass(dets) { const byClass = new Map(); const counts = new Map(); for (const d of dets) { if (!d.class) continue; counts.set(d.class, (counts.get(d.class) || 0) + 1); const cur = byClass.get(d.class); if (!cur || d.confidence > cur.confidence) byClass.set(d.class, d); } return { byClass, counts }; } function refreshObjectPickOptions(dets) { const sel = $('graspObjectPick'); const prev = sel.value; // known classes (from /classes, if any) keep a stable entry order; // detected-only classes are added after. const known = new Set(knownClasses); const ordered = []; knownClasses.forEach(n => ordered.push(n)); dets.forEach(d => { if (d.class && !known.has(d.class)) { ordered.push(d.class); known.add(d.class); } }); const { byClass, counts } = bestPerClass(dets); sel.innerHTML = ''; const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = '(top overall)'; sel.appendChild(opt0); for (const name of ordered) { const d = byClass.get(name); const n = counts.get(name) || 0; const o = document.createElement('option'); o.value = name; const suffix = n > 1 ? ` ×${n}` : ''; o.textContent = d ? `${name} · ${(d.confidence*100).toFixed(0)}%${suffix}` : `${name} · —`; if (!d) o.style.color = '#888'; sel.appendChild(o); } // Preserve the previous selection if still valid, else fall back to '(top)'. const stillAvailable = prev === '' || ordered.includes(prev); sel.value = stillAvailable ? prev : ''; } function renderGraspLegend(dets) { const host = $('graspLegend'); if (!host) return; const known = new Set(knownClasses); const ordered = []; knownClasses.forEach(n => ordered.push(n)); dets.forEach(d => { if (d.class && !known.has(d.class)) { ordered.push(d.class); known.add(d.class); } }); const { byClass, counts } = bestPerClass(dets); host.innerHTML = ''; if (ordered.length === 0) { host.textContent = '(nessuna classe)'; return; } for (const name of ordered) { const d = byClass.get(name); const n = counts.get(name) || 0; const item = document.createElement('span'); item.className = 'item' + (name === lastSelectedClass ? ' selected' : '') + (!d ? ' missing' : ''); const sw = document.createElement('span'); sw.className = 'sw'; sw.style.background = colorForClass(name); item.appendChild(sw); const suffix = n > 1 ? ` ×${n}` : ''; item.appendChild(document.createTextNode( d ? `${name} ${(d.confidence*100).toFixed(0)}%${suffix}` : `${name} (non rilevata)` )); host.appendChild(item); } } async function loadKnownClasses() { try { const r = await api('GET', '/api/perception/classes'); knownClasses = (r.classes || []).map(c => c.name).filter(Boolean); } catch (_e) { knownClasses = []; } refreshObjectPickOptions(lastDetections); renderGraspLegend(lastDetections); } loadKnownClasses(); // Changing the pick while the loop is idle → fire a one-off scan so the cloud // updates immediately. While looping, the next tick picks it up automatically. $('graspObjectPick').addEventListener('change', () => { renderGraspLegend(lastDetections); drawGraspFrame(); if (!loopTimer) $('detectBtn').click(); }); // ==================== FIT MODELLO ==================== const findCanvas = $('findResultCanvas'); const findCtx = findCanvas.getContext('2d'); let findResult = null; let findRgbImg = null; async function loadRecipes() { const sel = $('findModelPick'); try { const r = await api('GET', '/api/models/recipes'); sel.innerHTML = ''; (r.recipes || []).forEach(rec => { const opt = document.createElement('option'); opt.value = rec.id; opt.textContent = `${rec.name} (${rec.symmetry_type})`; sel.appendChild(opt); }); sel.disabled = (r.recipes || []).length === 0; $('findBtn').disabled = !sel.value; } catch (e) { sel.innerHTML = ``; } } $('findModelPick').addEventListener('change', () => { $('findBtn').disabled = !$('findModelPick').value; }); $('findRecipesRefreshBtn').addEventListener('click', loadRecipes); $('findBtn').addEventListener('click', async () => { const recipeId = $('findModelPick').value; if (!recipeId) return; $('findBtn').disabled = true; $('findStatus').textContent = 'Cerco posa…'; $('findStatus').className = 'status-line'; try { const body = { recipe_id: recipeId, conf: +$('graspConf').value || null, class_name: pickedClass(), }; const r = await api('POST', '/api/grasp/find_model', body); findResult = r; findRgbImg = new Image(); findRgbImg.onload = drawFindResult; findRgbImg.src = 'data:image/jpeg;base64,' + r.rgb_jpeg_b64; const fit = r.fit_ok ? `OK · fitness=${(r.fitness ?? 0).toFixed(2)} · rmse=${(r.inlier_rmse_mm ?? 0).toFixed(2)}mm` : `Fit fallito: ${r.fit_reason}`; $('findStatus').textContent = `${fit} · ${fmtTimings(r.timings_ms)}`; $('findStatus').className = 'status-line ' + (r.fit_ok ? 'ok' : 'warn'); $('findDetails').textContent = JSON.stringify({ detection: r.detection, recipe: r.recipe, n_accepted: r.n_accepted, n_excluded: r.n_excluded, fitness: r.fitness, rmse_mm: r.inlier_rmse_mm, timings: r.timings_ms, }, null, 2); } catch (e) { $('findStatus').textContent = `errore: ${e.message}`; $('findStatus').className = 'status-line err'; } finally { $('findBtn').disabled = !$('findModelPick').value; } }); ['findShowModel', 'findShowAccepted', 'findShowExcluded', 'findShowSam21'].forEach(id => $(id).addEventListener('change', drawFindResult)); function drawFindResult() { if (!findResult || !findRgbImg || !findRgbImg.complete) return; const [W, H] = findResult.rgb_size; if (findCanvas.width !== W) { findCanvas.width = W; findCanvas.height = H; } findCtx.drawImage(findRgbImg, 0, 0); const dot = (pts, color, r) => { findCtx.fillStyle = color; for (const [u, v] of pts) findCtx.fillRect(u - r, v - r, r*2+1, r*2+1); }; // Draw order: SAM21 cluster (background) -> excluded -> accepted -> model // (foreground), so the Open3DModel STL projection wins over everything. if ($('findShowSam21').checked) dot(findResult.sam21_points_px || [], '#ffd83d', 1); if ($('findShowExcluded').checked) dot(findResult.excluded_points_px, '#e74c3c', 1); if ($('findShowAccepted').checked) dot(findResult.accepted_points_px, '#3ecf8e', 1); if ($('findShowModel').checked) dot(findResult.model_points_px, '#4da3ff', 1); } function loadRecipesIfNeeded() { if ($('tab-grasp').classList.contains('active') && !$('findModelPick').dataset.loaded) { loadRecipes(); $('findModelPick').dataset.loaded = '1'; } } document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => setTimeout(loadRecipesIfNeeded, 50))); loadRecipesIfNeeded(); // ==================== EE ==================== async function refreshWrist() { try { const r = await api('GET', '/api/wrist'); $('wristCurrent').textContent = JSON.stringify(r, null, 2); } catch (e) { $('wristCurrent').textContent = e.message; } } $('refreshWristBtn').addEventListener('click', refreshWrist); $('absSendBtn').addEventListener('click', async () => { const body = { side: $('eeSide').value, position: [+$('absPx').value, +$('absPy').value, +$('absPz').value], quaternion: [+$('absQx').value, +$('absQy').value, +$('absQz').value, +$('absQw').value], wait: true, timeout_s: 5.0, precise: false, dry_run: $('eeDryRun').checked, }; try { $('eeLog').textContent = JSON.stringify(await api('POST', '/api/wrist/absolute', body), null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); $('relSendBtn').addEventListener('click', async () => { const body = { side: $('eeSide').value, delta_position: [+$('relDx').value, +$('relDy').value, +$('relDz').value], wait: true, timeout_s: 5.0, dry_run: $('eeDryRun').checked, }; try { $('eeLog').textContent = JSON.stringify(await api('POST', '/api/wrist/relative', body), null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); const handBtn = (id, action) => $(id).addEventListener('click', async () => { try { const r = await api('POST', '/api/wrist/hand', { action, dry_run: $('eeDryRun').checked }); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); handBtn('handOpenBtn', 'RIGHT_OPEN'); handBtn('handCloseBtn', 'RIGHT_CLOSE'); handBtn('handOpenLBtn', 'LEFT_OPEN'); handBtn('handCloseLBtn', 'LEFT_CLOSE'); $('estopBtn').addEventListener('click', async () => { if (!confirm('EMERGENCY STOP: confermi?')) return; try { const r = await api('POST', `/api/wrist/emergency_stop?dry_run=${$('eeDryRun').checked}`); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); $('unlockBtn').addEventListener('click', async () => { try { const r = await api('POST', `/api/wrist/unlock?dry_run=${$('eeDryRun').checked}`); $('eeLog').textContent = JSON.stringify(r, null, 2); } catch (e) { $('eeLog').textContent = e.message; } }); // ==================== VISTA 3D ==================== // Minimal canvas-2D 3D renderer: orthographic camera with yaw/pitch around a // target point, painter's-algorithm depth sorting, mouse drag/zoom/pan. const view3dCanvas = $('view3dCanvas'); const view3dCtx = view3dCanvas.getContext('2d'); const view3d = { // camera yaw: -0.6, // around base Z pitch: 0.5, // 0 = horizontal, π/2 = top-down scale: 350, // pixels per meter target: [0.30, 0.0, 0.20], // world point at canvas center // scene data wrist: null, // { left:{position,quaternion}, right:{...} } object: null, // { points:[[x,y,z],...], centroid:[x,y,z], n_points } plan: null, // { grasp:{position,quaternion_xyzw}, pre_grasp:{position} } calibrated: true, // false → object data unavailable lastErr: null, }; // ---- math helpers ---- function quatToR(q) { const x=q[0], y=q[1], z=q[2], w=q[3]; const n = x*x + y*y + z*z + w*w; if (n === 0) return [[1,0,0],[0,1,0],[0,0,1]]; const s = 2/n; return [ [1 - s*(y*y + z*z), s*(x*y - z*w), s*(x*z + y*w)], [s*(x*y + z*w), 1 - s*(x*x + z*z), s*(y*z - x*w)], [s*(x*z - y*w), s*(y*z + x*w), 1 - s*(x*x + y*y)], ]; } // Project world point → {sx, sy, depth}. depth = camera-axis distance for sorting. function project(p) { const dx = p[0] - view3d.target[0]; const dy = p[1] - view3d.target[1]; const dz = p[2] - view3d.target[2]; const cy = Math.cos(view3d.yaw), sy = Math.sin(view3d.yaw); // Yaw around Z const x1 = cy*dx + sy*dy; const y1 = -sy*dx + cy*dy; const z1 = dz; // Pitch around X (camera looks roughly toward +Y after yaw, tilting down by pitch) const cp = Math.cos(view3d.pitch), sp = Math.sin(view3d.pitch); const y2 = cp*y1 + sp*z1; const z2 = -sp*y1 + cp*z1; // Orthographic: screen X = +x1; screen Y = -z2 (so +Z up on screen) const sx = view3dCanvas.width / 2 + view3d.scale * x1; const sxy = view3dCanvas.height / 2 - view3d.scale * z2; // depth: y2 increases away from viewer → larger y2 = farther return { sx, sy: sxy, depth: y2 }; } function drawSegment(items, p1, p2, color, width = 2) { const a = project(p1), b = project(p2); items.push({ depth: (a.depth + b.depth) / 2, draw: () => { view3dCtx.strokeStyle = color; view3dCtx.lineWidth = width; view3dCtx.beginPath(); view3dCtx.moveTo(a.sx, a.sy); view3dCtx.lineTo(b.sx, b.sy); view3dCtx.stroke(); }}); } function drawDot(items, p, color, radius = 3) { const a = project(p); items.push({ depth: a.depth, draw: () => { view3dCtx.fillStyle = color; view3dCtx.beginPath(); view3dCtx.arc(a.sx, a.sy, radius, 0, 2 * Math.PI); view3dCtx.fill(); }}); } function drawLabel(items, p, text, color, dx = 8, dy = -8) { const a = project(p); items.push({ depth: a.depth - 1e-6, draw: () => { view3dCtx.fillStyle = color; view3dCtx.font = 'bold 11px ui-monospace, monospace'; view3dCtx.fillText(text, a.sx + dx, a.sy + dy); }}); } function drawTriad(items, position, R, len = 0.06) { // Columns of R = axis directions in base frame. const axes = [ { col: 0, color: '#e74c3c' }, // X { col: 1, color: '#3ecf8e' }, // Y { col: 2, color: '#4da3ff' }, // Z ]; for (const a of axes) { const tip = [ position[0] + R[0][a.col] * len, position[1] + R[1][a.col] * len, position[2] + R[2][a.col] * len, ]; drawSegment(items, position, tip, a.color, 2); } } function drawGroundGrid(items) { if (!$('view3dShowGrid').checked) return; const half = 0.6, step = 0.05; const minor = '#1a1f27', major = '#2a2f37'; for (let x = -half; x <= half + 1e-6; x += step) { const isMajor = Math.abs(x % 0.10) < 1e-6; drawSegment(items, [x, -half, 0], [x, half, 0], isMajor ? major : minor, 1); } for (let y = -half; y <= half + 1e-6; y += step) { const isMajor = Math.abs(y % 0.10) < 1e-6; drawSegment(items, [-half, y, 0], [half, y, 0], isMajor ? major : minor, 1); } } function drawBaseAxes(items) { drawTriad(items, [0,0,0], [[1,0,0],[0,1,0],[0,0,1]], 0.12); drawLabel(items, [0.12, 0, 0], 'X', '#e74c3c'); drawLabel(items, [0, 0.12, 0], 'Y', '#3ecf8e'); drawLabel(items, [0, 0, 0.12], 'Z', '#4da3ff'); drawLabel(items, [0,0,0], 'BASE', '#9aa0a6', 8, 14); } function drawWrist(items, side, pose, color) { if (!pose || !pose.position || !pose.quaternion) return; const p = pose.position; const R = quatToR(pose.quaternion); drawTriad(items, p, R, 0.08); drawDot(items, p, color, 5); drawLabel(items, p, `polso ${side}`, color, 10, -6); } function drawObject(items) { if (!view3d.object) return; if ($('view3dShowCloud').checked && view3d.object.points) { // Cap render to avoid huge per-frame work. const max = 1500; const pts = view3d.object.points; const stride = Math.max(1, Math.ceil(pts.length / max)); for (let i = 0; i < pts.length; i += stride) { drawDot(items, pts[i], '#aaaaaa', 1.4); } } if (view3d.object.centroid) { drawDot(items, view3d.object.centroid, '#ffd54f', 6); drawLabel(items, view3d.object.centroid, 'oggetto', '#ffd54f', 10, -8); } } function drawPlan(items) { if (!view3d.plan) return; const g = view3d.plan.grasp, pg = view3d.plan.pre_grasp; if (!g || !g.position) return; const R = quatToR(g.quaternion_xyzw); // grasp pose triad + marker drawTriad(items, g.position, R, 0.07); drawDot(items, g.position, '#ff5e7e', 6); drawLabel(items, g.position, 'grasp', '#ff5e7e', 10, -8); // pre-grasp marker + approach line if (pg && pg.position) { drawDot(items, pg.position, '#ffffff', 4); drawLabel(items, pg.position, 'pre-grasp', '#ffffff', 10, -8); drawSegment(items, pg.position, g.position, '#ffffff', 1.5); } } function renderView3d() { const ctx = view3dCtx; ctx.fillStyle = '#0d1014'; ctx.fillRect(0, 0, view3dCanvas.width, view3dCanvas.height); const items = []; drawGroundGrid(items); drawBaseAxes(items); if (view3d.wrist) { drawWrist(items, 'right', view3d.wrist.right, '#f0ad4e'); drawWrist(items, 'left', view3d.wrist.left, '#9b59b6'); } drawObject(items); drawPlan(items); // Painter's algorithm: far first. items.sort((a, b) => b.depth - a.depth); items.forEach(it => it.draw()); // HUD ctx.fillStyle = '#9aa0a6'; ctx.font = '11px ui-monospace, monospace'; ctx.fillText(`yaw=${(view3d.yaw*180/Math.PI).toFixed(0)}° pitch=${(view3d.pitch*180/Math.PI).toFixed(0)}° scale=${view3d.scale.toFixed(0)} px/m`, 8, 14); ctx.fillText(`target=[${view3d.target.map(v => v.toFixed(2)).join(', ')}]`, 8, 28); if (!view3d.calibrated) { ctx.fillStyle = '#f0ad4e'; ctx.fillText('⚠ calibrazione mancante: punti oggetto non disponibili in frame base', 8, view3dCanvas.height - 8); } if (view3d.lastErr) { ctx.fillStyle = '#e74c3c'; ctx.fillText(view3d.lastErr, 8, view3dCanvas.height - 22); } } // ---- mouse interaction ---- let drag3d = null; view3dCanvas.addEventListener('mousedown', (ev) => { drag3d = { x: ev.clientX, y: ev.clientY, pan: ev.shiftKey }; }); window.addEventListener('mouseup', () => { drag3d = null; }); window.addEventListener('mousemove', (ev) => { if (!drag3d) return; const dx = ev.clientX - drag3d.x; const dy = ev.clientY - drag3d.y; drag3d.x = ev.clientX; drag3d.y = ev.clientY; if (drag3d.pan) { // Pan in screen plane → world XY (approx, ignoring pitch foreshortening on Z) const cy = Math.cos(view3d.yaw), sy = Math.sin(view3d.yaw); const wx = cy*dx - sy*(-dy) * Math.cos(view3d.pitch); const wy = sy*dx + cy*(-dy) * Math.cos(view3d.pitch); view3d.target[0] -= wx / view3d.scale; view3d.target[1] -= wy / view3d.scale; // also let vertical drag move target Z when looking near horizontal view3d.target[2] += (-dy) * Math.sin(view3d.pitch) / view3d.scale; } else { view3d.yaw += dx * 0.01; view3d.pitch += dy * 0.01; view3d.pitch = Math.max(-Math.PI/2 + 0.05, Math.min(Math.PI/2 - 0.05, view3d.pitch)); } renderView3d(); }); view3dCanvas.addEventListener('wheel', (ev) => { ev.preventDefault(); const f = Math.exp(-ev.deltaY * 0.001); view3d.scale = Math.max(50, Math.min(3000, view3d.scale * f)); renderView3d(); }, { passive: false }); view3dCanvas.addEventListener('dblclick', () => resetView3d()); function resetView3d() { view3d.yaw = -0.6; view3d.pitch = 0.5; view3d.scale = 350; view3d.target = [0.30, 0.0, 0.20]; renderView3d(); } $('view3dResetBtn').addEventListener('click', resetView3d); function autoFitView3d() { if (!$('view3dAutoFit').checked) return; const pts = []; if (view3d.wrist) { if (view3d.wrist.right && view3d.wrist.right.position) pts.push(view3d.wrist.right.position); if (view3d.wrist.left && view3d.wrist.left.position) pts.push(view3d.wrist.left.position); } if (view3d.object && view3d.object.centroid) pts.push(view3d.object.centroid); if (view3d.plan && view3d.plan.grasp && view3d.plan.grasp.position) pts.push(view3d.plan.grasp.position); if (pts.length === 0) return; const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity]; for (const p of pts) for (let i = 0; i < 3; i++) { if (p[i] < min[i]) min[i] = p[i]; if (p[i] > max[i]) max[i] = p[i]; } view3d.target = [(min[0]+max[0])/2, (min[1]+max[1])/2, (min[2]+max[2])/2]; const span = Math.max(0.15, max[0]-min[0], max[1]-min[1], max[2]-min[2]); // fit ~70% of canvas width view3d.scale = Math.min(2000, (view3dCanvas.width * 0.7) / (span * 2.5)); } // ---- data fetching ---- async function refreshView3d() { view3d.lastErr = null; const out = { wrist: null, object: null, plan: null, calibrated: true }; const summary = []; // Wrist (always; doesn't need calibration — already in base frame). try { const w = await api('GET', '/api/wrist'); // Server returns {left_wrist, right_wrist}; tolerate {left,right} too. const data = (w && w.data) || w || {}; out.wrist = { left: data.left_wrist || data.left || null, right: data.right_wrist || data.right || null, }; summary.push(`wrist: ${out.wrist.right ? 'R✓' : 'R—'} ${out.wrist.left ? 'L✓' : 'L—'}`); } catch (e) { summary.push(`wrist: err (${e.message})`); } // Object points + centroid in base frame. try { const body = { conf: +$('view3dConf').value || null, class_name: $('view3dClass').value || null, max_points: 3000, }; const r = await api('POST', '/api/perception/object_points', body); const pts = r.points_base || null; if (!pts) { out.calibrated = false; summary.push('oggetto: serve calibrazione'); } else { let cx=0, cy=0, cz=0; for (const p of pts) { cx+=p[0]; cy+=p[1]; cz+=p[2]; } const n = Math.max(1, pts.length); out.object = { points: pts, centroid: [cx/n, cy/n, cz/n], n_points: pts.length, detection: r.detection, }; summary.push(`oggetto: ${pts.length} pt`); } } catch (e) { if (/^412\b/.test(e.message)) out.calibrated = false; summary.push(`oggetto: ${e.message}`); } // Grasp plan (optional). if ($('view3dShowPlan').checked && out.calibrated && out.object) { try { const body = { conf: +$('view3dConf').value || null, class_name: $('view3dClass').value || null, }; const r = await api('POST', '/api/grasp/plan', body); out.plan = r.plan || null; if (out.plan && out.plan.grasp) { const p = out.plan.grasp.position; summary.push(`grasp: [${p.map(v => v.toFixed(2)).join(', ')}]`); } } catch (e) { summary.push(`plan: ${e.message}`); } } Object.assign(view3d, out); autoFitView3d(); renderView3d(); $('view3dStatusLine').textContent = summary.join(' · '); $('view3dStatusLine').style.color = view3d.lastErr ? 'var(--danger)' : 'var(--dim)'; $('view3dOut').textContent = JSON.stringify({ wrist: out.wrist, object: out.object && { n_points: out.object.n_points, centroid: out.object.centroid, detection: out.object.detection, }, plan: out.plan, }, null, 2); } $('view3dRefreshBtn').addEventListener('click', async () => { setStatus('Aggiorno scena 3D…'); try { await refreshView3d(); setStatus('Scena 3D aggiornata', 'ok'); } catch (e) { setStatus(e.message, 'err'); } }); // loop let view3dTimer = null; let view3dInFlight = false; async function view3dTick() { if (view3dInFlight) return; view3dInFlight = true; try { await refreshView3d(); } catch {} finally { view3dInFlight = false; } } function startView3dLoop() { if (view3dTimer) return; const rate = Math.max(200, +$('view3dRateMs').value || 800); view3dTick(); view3dTimer = setInterval(view3dTick, rate); const b = $('view3dLoopBtn'); b.textContent = '■ Stop loop'; b.classList.add('running'); } function stopView3dLoop() { if (view3dTimer) { clearInterval(view3dTimer); view3dTimer = null; } const b = $('view3dLoopBtn'); b.textContent = '▶ Loop'; b.classList.remove('running'); } $('view3dLoopBtn').addEventListener('click', () => view3dTimer ? stopView3dLoop() : startView3dLoop()); // Pause loop when leaving the tab (mirrors the grasp-tab behavior). document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => { if (btn.dataset.tab !== 'view3d' && view3dTimer) stopView3dLoop(); if (btn.dataset.tab === 'view3d') renderView3d(); })); // Initial empty render so the canvas isn't blank when first shown. renderView3d(); // ==================== HOME ==================== // Shows reachability + latency for every upstream microservice and lets the // user switch the active robot (G1 ↔ H1). The orchestrator runs the actual // switch via PUT /api/home/robot, which deep-merges into config.yaml. let homePollTimer = null; let homePollInFlight = false; function fmtLatency(ms) { if (ms == null) return '—'; if (ms < 1) return '<1 ms'; return `${Math.round(ms)} ms`; } function renderHomeStatus(data) { const tbody = document.querySelector('#homeDeviceTable tbody'); if (!tbody) return; tbody.innerHTML = ''; if (!data || !Array.isArray(data.devices) || data.devices.length === 0) { tbody.innerHTML = 'nessun servizio configurato'; return; } for (const d of data.devices) { const tr = document.createElement('tr'); const status = d.ok ? 'ok' : 'err'; const icon = d.ok ? '✓' : '✗'; const detail = d.ok ? (d.status_code != null ? `HTTP ${d.status_code}` : 'reachable') : (d.error || (d.status_code != null ? `HTTP ${d.status_code}` : 'unreachable')); tr.innerHTML = ` ${d.label}${d.active ? ' attivo' : ''} ${d.url} ${icon} ${d.ok ? 'online' : 'offline'} ${fmtLatency(d.latency_ms)} ${detail} `; tbody.appendChild(tr); } const pill = $('activeRobotPill'); if (pill) pill.textContent = `robot: ${(data.active_robot || '—').toUpperCase()}`; } async function homePollOnce() { if (homePollInFlight) return; homePollInFlight = true; try { const r = await api('GET', '/api/home/status'); renderHomeStatus(r); } catch (e) { const tbody = document.querySelector('#homeDeviceTable tbody'); if (tbody) tbody.innerHTML = `errore: ${e.message}`; } finally { homePollInFlight = false; } } function startHomePoll() { stopHomePoll(); if (!$('homeAutoPoll').checked) return; const rate = Math.max(500, +$('homePollMs').value || 2000); homePollOnce(); homePollTimer = setInterval(homePollOnce, rate); } function stopHomePoll() { if (homePollTimer) { clearInterval(homePollTimer); homePollTimer = null; } } $('homeAutoPoll').addEventListener('change', startHomePoll); $('homePollMs').addEventListener('change', startHomePoll); $('homeRefreshBtn').addEventListener('click', homePollOnce); $('homeApplyRobotBtn').addEventListener('click', async (ev) => { const btn = ev.currentTarget; const target = $('homeRobotSelect').value; try { const r = await api('PUT', '/api/home/robot', { active: target }); $('homeLog').textContent = `robot attivo: ${r.active}`; $('homeLog').classList.remove('err'); flashBtn(btn, true); toast('Robot attivo', `${r.active.toUpperCase()}`, true); homePollOnce(); } catch (e) { $('homeLog').textContent = e.message; $('homeLog').classList.add('err'); flashBtn(btn, false); toast('Errore cambio robot', e.message, false); } }); // Sync the dropdown with whatever the backend currently reports as active. async function refreshHomeRobotSelector() { try { const r = await api('GET', '/api/home/robot'); if (r && r.active) $('homeRobotSelect').value = r.active; const pill = $('activeRobotPill'); if (pill) pill.textContent = `robot: ${(r.active || '—').toUpperCase()}`; } catch {} } refreshHomeRobotSelector(); startHomePoll(); // ---------- init ---------- (async () => { try { const cfg = await api('GET', '/api/config'); if ($('eeSide')) $('eeSide').value = cfg.robot.wrist_side; } catch {} refreshWrist(); })(); // ==================== ASSISTENTE AI ==================== // Curated tool-use chat: l'utente parla in linguaggio naturale, il backend // passa la richiesta a OpenRouter con i tool definiti in agent/tools.py. // La cronologia è tenuta lato browser e ribaltata indietro a ogni POST. const agentState = { history: [], // OpenAI-format messages (escluso system, ricostruito server-side) visualMessages: [], // {role, content, traceIdx?} per il render busy: false, health: null, }; function agentEscape(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>'); } function renderAgentChat() { const host = $('agentChat'); host.innerHTML = ''; if (agentState.visualMessages.length === 0) { const sys = document.createElement('div'); sys.className = 'agent-msg system'; sys.textContent = 'Conversazione vuota. Scrivi una richiesta sotto.'; host.appendChild(sys); return; } for (const m of agentState.visualMessages) { const div = document.createElement('div'); div.className = `agent-msg ${m.role}`; div.textContent = m.content || ''; if (m.tools && m.tools.length) { const meta = document.createElement('div'); meta.className = 'meta'; for (const t of m.tools) { const pill = document.createElement('span'); pill.className = 'agent-tool-pill' + (t.ok ? '' : ' err'); pill.textContent = `${t.name} · ${t.latency_ms}ms`; pill.title = JSON.stringify(t.args || {}, null, 2); meta.appendChild(pill); } div.appendChild(meta); } host.appendChild(div); } host.scrollTop = host.scrollHeight; } async function refreshAgentHealth() { try { const h = await api('GET', '/api/agent/health'); agentState.health = h; const el = $('agentHealth'); if (h.configured) { el.textContent = `pronto · modello ${h.model}`; el.style.color = 'var(--ok)'; $('agentSendBtn').disabled = false; } else { el.textContent = `API key mancante (env ${h.api_key_env}): chat disabilitata`; el.style.color = 'var(--danger)'; $('agentSendBtn').disabled = true; } } catch (e) { $('agentHealth').textContent = `errore: ${e.message}`; $('agentHealth').style.color = 'var(--danger)'; } // mirror del robot attivo nella pill della tab try { const r = await api('GET', '/api/home/robot'); const pill = $('agentActiveRobotPill'); if (pill) pill.textContent = `robot: ${(r.active || '—').toUpperCase()}`; } catch {} } async function sendAgentMessage() { if (agentState.busy) return; const input = $('agentInput'); const message = input.value.trim(); if (!message) return; agentState.busy = true; $('agentSendBtn').disabled = true; $('agentStatus').textContent = 'invio…'; $('agentStatus').style.color = 'var(--dim)'; agentState.visualMessages.push({ role: 'user', content: message }); renderAgentChat(); input.value = ''; try { const r = await api('POST', '/api/agent/chat', { message, history: agentState.history, }); agentState.history = r.history || []; const tools = (r.tool_calls || []).map(tc => ({ name: tc.name, ok: tc.result && tc.result.ok !== false, latency_ms: tc.latency_ms, args: tc.arguments, })); agentState.visualMessages.push({ role: 'assistant', content: r.reply || '(nessuna risposta)', tools, }); renderAgentChat(); // Tool trace dettagliata nel pannello di destra. if (r.tool_calls && r.tool_calls.length) { $('agentTrace').textContent = JSON.stringify(r.tool_calls, null, 2); } else { $('agentTrace').textContent = '(nessuna chiamata)'; } const usage = r.usage || {}; const u = usage.total_tokens != null ? `· ${usage.total_tokens} tokens` : ''; $('agentStatus').textContent = `ok · ${r.iterations} iterazioni · ${tools.length} tool ${u}`; $('agentStatus').style.color = 'var(--ok)'; // se la risposta ha cambiato robot attivo, aggiorna pill refreshAgentHealth().catch(() => {}); } catch (e) { agentState.visualMessages.push({ role: 'error', content: e.message, }); renderAgentChat(); $('agentStatus').textContent = `errore: ${e.message}`; $('agentStatus').style.color = 'var(--danger)'; } finally { agentState.busy = false; $('agentSendBtn').disabled = !(agentState.health && agentState.health.configured); } } $('agentSendBtn').addEventListener('click', sendAgentMessage); $('agentInput').addEventListener('keydown', (e) => { if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); sendAgentMessage(); } }); $('agentClearBtn').addEventListener('click', () => { if (agentState.visualMessages.length === 0 && agentState.history.length === 0) return; if (!confirm('Resetta la conversazione corrente?')) return; agentState.history = []; agentState.visualMessages = []; renderAgentChat(); $('agentTrace').textContent = '(nessuna chiamata)'; $('agentStatus').textContent = 'conversazione svuotata'; }); // ----- Task salvati ----- async function loadAgentTasks() { const ul = $('agentTaskList'); try { const r = await api('GET', '/api/agent/tasks'); const items = r.tasks || []; ul.innerHTML = ''; if (items.length === 0) { ul.innerHTML = '
  • (nessun task salvato)
  • '; return; } for (const it of items) { const li = document.createElement('li'); const name = document.createElement('span'); name.className = 'task-name'; name.textContent = it.name; const desc = document.createElement('span'); desc.className = 'task-desc'; desc.textContent = it.description || it.prompt; desc.title = it.prompt; const useBtn = document.createElement('button'); useBtn.className = 'link-btn'; useBtn.textContent = '↥ usa'; useBtn.title = 'Carica questo prompt nella casella di input'; useBtn.addEventListener('click', () => { $('agentInput').value = it.prompt; $('agentInput').focus(); }); const runBtn = document.createElement('button'); runBtn.className = 'link-btn'; runBtn.textContent = '▶ esegui'; runBtn.addEventListener('click', () => { $('agentInput').value = it.prompt; sendAgentMessage(); }); const delBtn = document.createElement('button'); delBtn.className = 'link-btn'; delBtn.textContent = '✕'; delBtn.title = 'Elimina'; delBtn.addEventListener('click', async () => { if (!confirm(`Eliminare task "${it.name}"?`)) return; try { await api('DELETE', `/api/agent/tasks/${encodeURIComponent(it.name)}`); loadAgentTasks(); } catch (e) { toast('Eliminazione fallita', e.message, false); } }); li.appendChild(name); li.appendChild(desc); li.appendChild(useBtn); li.appendChild(runBtn); li.appendChild(delBtn); ul.appendChild(li); } } catch (e) { ul.innerHTML = `
  • errore: ${agentEscape(e.message)}
  • `; } } $('agentTaskSaveBtn').addEventListener('click', async (ev) => { const name = $('agentTaskName').value.trim(); const prompt = $('agentInput').value.trim(); const description = $('agentTaskDesc').value.trim() || null; if (!name) { toast('Manca il nome', 'Indica un nome per il task', false); return; } if (!prompt) { toast('Prompt vuoto', 'Scrivi prima il prompt nella casella', false); return; } try { await api('POST', '/api/agent/tasks', { name, prompt, description }); flashBtn(ev.currentTarget, true); toast('Task salvato', name, true); $('agentTaskName').value = ''; $('agentTaskDesc').value = ''; loadAgentTasks(); } catch (e) { flashBtn(ev.currentTarget, false); toast('Salvataggio fallito', e.message, false); } }); $('agentTaskReloadBtn').addEventListener('click', loadAgentTasks); // Carica health + task quando si apre la tab Assistente AI. document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => { if (btn.dataset.tab === 'agent') { refreshAgentHealth(); loadAgentTasks(); } })); // Bootstrap iniziale (anche se la tab non è attiva, così la pill si popola). refreshAgentHealth(); loadAgentTasks(); // ==================== IMPOSTAZIONI AI ==================== async function loadAiSettings() { const select = $('aiModelSelect'); // Lista modelli (statica con override custom): si carica una sola volta. if (select && select.options.length <= 1) { try { const r = await api('GET', '/api/agent/known_models'); select.innerHTML = ''; for (const m of r.models) { const opt = document.createElement('option'); opt.value = m.id; opt.textContent = `${m.id} — ${m.label}`; select.appendChild(opt); } } catch (e) { select.innerHTML = ``; } } // Carica config corrente try { const cfg = await api('GET', '/api/config'); const o = (cfg.chat && cfg.chat.openrouter) || {}; const model = o.model || 'anthropic/claude-sonnet-4.5'; if ([...$('aiModelSelect').options].some(opt => opt.value === model)) { $('aiModelSelect').value = model; $('aiModelCustom').value = ''; } else { $('aiModelCustom').value = model; } $('aiTemperature').value = (o.temperature != null) ? o.temperature : 0.1; $('aiMaxIterations').value = o.max_tool_iterations || 8; $('aiTimeout').value = o.timeout_s || 60; $('aiPromptAddendum').value = o.user_prompt_addendum || ''; $('aiSettingsStatus').textContent = 'caricato da config'; $('aiSettingsStatus').style.color = 'var(--dim)'; } catch (e) { $('aiSettingsStatus').textContent = `errore: ${e.message}`; $('aiSettingsStatus').style.color = 'var(--danger)'; } } async function saveAiSettings(btn) { const customModel = $('aiModelCustom').value.trim(); const model = customModel || $('aiModelSelect').value; if (!model) { toast('Modello mancante', 'Seleziona o scrivi un modello', false); return; } const temperature = parseFloat($('aiTemperature').value); const max_tool_iterations = parseInt($('aiMaxIterations').value, 10); const timeout_s = parseFloat($('aiTimeout').value); if (Number.isNaN(temperature) || temperature < 0 || temperature > 2) { toast('Temperature non valido', '0..2', false); return; } if (Number.isNaN(max_tool_iterations) || max_tool_iterations < 1) { toast('Max iterations non valido', '>=1', false); return; } const patch = { chat: { openrouter: { model, temperature, max_tool_iterations, timeout_s, user_prompt_addendum: $('aiPromptAddendum').value, }}, }; try { await api('PUT', '/api/config', patch); flashBtn(btn, true); toast('Salvato', `model=${model}, T=${temperature}`, true); $('aiSettingsStatus').textContent = `salvato · model=${model} · T=${temperature}`; $('aiSettingsStatus').style.color = 'var(--ok)'; refreshAgentHealth().catch(() => {}); } catch (e) { flashBtn(btn, false); toast('Errore salvataggio', e.message, false); $('aiSettingsStatus').textContent = e.message; $('aiSettingsStatus').style.color = 'var(--danger)'; } } async function previewSystemPrompt() { const pre = $('aiPromptPreview'); pre.textContent = 'caricamento…'; try { const r = await api('GET', '/api/agent/system_prompt_preview'); pre.textContent = r.system_prompt || '(vuoto)'; } catch (e) { pre.textContent = `errore: ${e.message}`; } } $('aiSaveBtn').addEventListener('click', (ev) => saveAiSettings(ev.currentTarget)); $('aiReloadBtn').addEventListener('click', loadAiSettings); $('aiResetTempBtn').addEventListener('click', () => { $('aiTemperature').value = 0.1; }); $('aiPromptPreviewBtn').addEventListener('click', previewSystemPrompt); $('aiPromptCopyBtn').addEventListener('click', async () => { try { await navigator.clipboard.writeText($('aiPromptPreview').textContent); toast('Copiato', 'Prompt nel clipboard', true); } catch (e) { toast('Copia fallita', e.message, false); } }); // Quando l'utente seleziona un modello dalla combo, svuota il custom (priorità chiara). $('aiModelSelect').addEventListener('change', () => { $('aiModelCustom').value = ''; }); // Carica i valori quando si apre la tab. document.querySelectorAll('.tab').forEach(btn => btn.addEventListener('click', () => { if (btn.dataset.tab === 'ai-settings') { loadAiSettings(); previewSystemPrompt(); } }));