refactor(pose_3d): rimuovi agent/chat, aggiungi cloud+diagnostics+camera polso
Rimosso il modulo agent (LLM chat) e la relativa config OpenRouter. La tab Pose 3D ora: espone i giunti reali via G1 /diagnostics per specchiare l'URDF senza IK; acquisisce point cloud Nuwa+D435i (/api/pose_3d/clouds); mostra il modello 3D della camera sul polso; degrada gracefully a 200 (wrist=null) quando il robot è offline. Il vecchio renderer canvas-2D (app.js view3d + diagnostic3d.js) è stato consolidato nel viewport Three.js di pose_3d.js. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9d30073d57
commit
6c2452fe20
20 files changed
+152757
-2714
No files matched your search
-732
@@ -642,392 +642,6 @@ $('unlockBtn').addEventListener('click', async () => {
|
||||
} 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
|
||||
@@ -1136,349 +750,3 @@ startHomePoll();
|
||||
} 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, '<').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 = '<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();
|
||||
}
|
||||
}));
|
||||
Reference in new issue
Block a user