Files
Presa-Mano/web/app.js
T

1467 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 = '<option value="">— seleziona modello —</option>';
(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 = `<option value="">errore: ${e.message}</option>`;
}
}
$('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'].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);
};
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 = '<tr><td colspan="5" class="dim">nessun servizio configurato</td></tr>';
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 = `
<td>${d.label}${d.active ? ' <span class="badge ok">attivo</span>' : ''}</td>
<td><code>${d.url}</code></td>
<td><span class="device-pill ${status}">${icon} ${d.ok ? 'online' : 'offline'}</span></td>
<td>${fmtLatency(d.latency_ms)}</td>
<td>${detail}</td>
`;
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 = `<tr><td colspan="5" class="err">errore: ${e.message}</td></tr>`;
} 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 = '<li class="dim">(nessun task salvato)</li>';
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 = `<li class="err">errore: ${agentEscape(e.message)}</li>`;
}
}
$('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 = `<option value="">errore: ${e.message}</option>`;
}
}
// 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();
}
}));