CxWebApp/static/js/panes/tools.js
CxAI Agent 75153b7fe9
Some checks are pending
build-and-push / image (push) Waiting to run
feat(cxwebapp): comprehensive pane enhancements
- All 14 panes rewritten with rich controls: KPIs, sparklines, meter
  gauges, presets, history sidebars, search/filter, bulk actions,
  schema-driven forms, copy buttons, auto-refresh.
- Backend /api/system enriched with cpu_count, rss_bytes, mem_total,
  mem_used_pct, loadavg[], user/system CPU times, max_rss_kb.
- CSS additions: .meter/.gauge, .seg, .chip(s), .spark, .slider, .dl,
  .link-card, details.card-d plus utility classes.
- ui.js helpers: copyToClipboard, withCopy/bindCopyButtons, sparkline,
  meterRow, fmtBytes/fmtNum, historyStore, inputFromSchema,
  collectSchemaForm.
- Files pane added; iCloud duplicate '*2' files removed.
2026-05-17 09:36:19 -05:00

210 lines
8.1 KiB
JavaScript

// panes/tools.js — MCP tool browser & invoker with schema-driven forms.
import { registerPane } from '../app.js';
import { mcp, escapeHtml } from '../lib/api.js';
import { fmtJSON, ok, err, copyToClipboard, inputFromSchema, collectSchemaForm, historyStore } from '../lib/ui.js';
const callHist = historyStore('cx_tool_calls', 30);
const FAV_KEY = 'cx_tool_favs';
const favs = new Set(JSON.parse(localStorage.getItem(FAV_KEY) || '[]'));
function persistFavs() { localStorage.setItem(FAV_KEY, JSON.stringify([...favs])); }
const TPL = `
<div class="pane-head">
<div><div class="title">Tools</div><div class="sub">Discover and invoke MCP tools.</div></div>
<div class="grow"></div>
<button class="btn btn-secondary" id="tl-refresh">Reload</button>
</div>
<div class="grid" style="grid-template-columns: 320px 1fr; gap: 16px;">
<div class="card no-pad scroll" style="max-height:72vh">
<div class="p-2 flex gap-2">
<input class="input" id="tl-search" placeholder="filter tools…"/>
</div>
<div class="p-2">
<div class="seg" id="tl-seg">
<button class="active" data-g="all">All</button>
<button data-g="fav">Favorites</button>
<button data-g="recent">Recent</button>
</div>
</div>
<div id="tl-list">loading…</div>
</div>
<div class="card">
<div class="card-title">
<h2 id="tl-name">Select a tool</h2>
<button class="btn btn-ghost" id="tl-fav" hidden>★</button>
</div>
<p class="muted small" id="tl-desc">Choose a tool on the left.</p>
<div id="tl-body" hidden>
<div class="seg mb-2" id="tl-mode">
<button class="active" data-m="form">Form</button>
<button data-m="json">JSON</button>
</div>
<form id="tl-form" class="grid gap-3"></form>
<textarea id="tl-json" class="input" rows="8" placeholder='{"key":"value"}' hidden></textarea>
<div class="btn-row mt-3">
<button class="btn btn-primary" id="tl-call">Call</button>
<button class="btn btn-secondary" id="tl-copy-args">Copy args</button>
<button class="btn btn-ghost" id="tl-reset">Reset</button>
</div>
<div class="divider"></div>
<div class="card-title"><h2 style="font-size:14px">Result</h2><span id="tl-meta" class="muted xsmall"></span></div>
<pre id="tl-result" class="code" style="max-height:32vh">—</pre>
<details class="card-d mt-3"><summary>Recent calls</summary>
<div id="tl-hist" class="body"></div>
</details>
</div>
</div>
</div>
`;
let allTools = [];
let current = null;
let mode = 'form';
let group = 'all';
function passes(t, q) {
const blob = (t.name + ' ' + (t.description || '')).toLowerCase();
if (q && !blob.includes(q)) return false;
if (group === 'fav' && !favs.has(t.name)) return false;
if (group === 'recent') {
const recents = new Set(callHist.list().map(r => r.tool));
if (!recents.has(t.name)) return false;
}
return true;
}
function renderList(host) {
const q = host.querySelector('#tl-search').value.trim().toLowerCase();
const items = allTools.filter(t => passes(t, q));
host.querySelector('#tl-list').innerHTML = items.length
? items.map(t => `<button class="nav-item" data-tool="${escapeHtml(t.name)}">
<span class="grow truncate"><span class="mono">${escapeHtml(t.name)}</span></span>
${favs.has(t.name) ? '<span class="muted">★</span>' : ''}
</button>`).join('')
: '<div class="muted xsmall p-2">no tools match</div>';
host.querySelectorAll('[data-tool]').forEach(b => b.addEventListener('click', () => select(host, b.dataset.tool)));
}
function renderForm(host) {
if (!current) return;
const f = host.querySelector('#tl-form');
const schema = current.inputSchema || current.input_schema || {};
const props = schema.properties || {};
const names = Object.keys(props);
if (!names.length) {
f.innerHTML = '<div class="muted xsmall">no input schema — call with empty args or use JSON</div>';
return;
}
f.innerHTML = names.map(n => inputFromSchema(n, props[n])).join('');
}
function renderHist(host) {
const list = callHist.list().filter(r => r.tool === current?.name);
host.querySelector('#tl-hist').innerHTML = list.length
? list.map((r, i) => `<button class="nav-item" data-hi="${i}">
<span class="muted xsmall">${new Date(r.t).toLocaleTimeString()}</span>
<span class="grow truncate mono xsmall">${escapeHtml(JSON.stringify(r.args))}</span>
</button>`).join('')
: '<div class="muted xsmall">no recent calls</div>';
host.querySelectorAll('#tl-hist [data-hi]').forEach(b => b.addEventListener('click', () => {
const r = list[+b.dataset.hi];
if (r) { host.querySelector('#tl-json').value = fmtJSON(r.args); switchMode(host, 'json'); }
}));
}
function select(host, name) {
current = allTools.find(t => t.name === name);
if (!current) return;
host.querySelector('#tl-name').textContent = current.name;
host.querySelector('#tl-desc').textContent = current.description || '—';
host.querySelector('#tl-body').hidden = false;
const favBtn = host.querySelector('#tl-fav');
favBtn.hidden = false;
favBtn.textContent = favs.has(name) ? '★ unfavorite' : '☆ favorite';
host.querySelector('#tl-json').value = '{}';
renderForm(host); renderHist(host);
}
function switchMode(host, m) {
mode = m;
host.querySelectorAll('#tl-mode button').forEach(b => b.classList.toggle('active', b.dataset.m === m));
host.querySelector('#tl-form').hidden = m !== 'form';
host.querySelector('#tl-json').hidden = m !== 'json';
}
function gatherArgs(host) {
if (mode === 'form') return collectSchemaForm(host.querySelector('#tl-form'));
const t = host.querySelector('#tl-json').value.trim();
if (!t) return {};
return JSON.parse(t);
}
async function load(host) {
try {
const j = await mcp.tools();
allTools = (j.tools || []).slice().sort((a, b) => a.name.localeCompare(b.name));
renderList(host);
} catch (e) {
host.querySelector('#tl-list').innerHTML = `<div class="muted xsmall p-2">${escapeHtml(e.message)}</div>`;
}
}
registerPane('tools', {
label: 'Tools',
init(host) {
host.innerHTML = TPL;
host.querySelector('#tl-refresh').addEventListener('click', () => load(host));
host.querySelector('#tl-search').addEventListener('input', () => renderList(host));
host.querySelectorAll('#tl-seg button').forEach(b => b.addEventListener('click', () => {
host.querySelectorAll('#tl-seg button').forEach(x => x.classList.toggle('active', x === b));
group = b.dataset.g; renderList(host);
}));
host.querySelectorAll('#tl-mode button').forEach(b => b.addEventListener('click', () => switchMode(host, b.dataset.m)));
host.querySelector('#tl-fav').addEventListener('click', () => {
if (!current) return;
if (favs.has(current.name)) favs.delete(current.name); else favs.add(current.name);
persistFavs();
host.querySelector('#tl-fav').textContent = favs.has(current.name) ? '★ unfavorite' : '☆ favorite';
renderList(host);
});
host.querySelector('#tl-copy-args').addEventListener('click', () => {
try { copyToClipboard(fmtJSON(gatherArgs(host)), 'args copied'); } catch (e) { err(e.message); }
});
host.querySelector('#tl-reset').addEventListener('click', () => {
if (current) select(host, current.name);
});
host.querySelector('#tl-call').addEventListener('click', async () => {
if (!current) return;
let args; try { args = gatherArgs(host); } catch (e) { return err('args: ' + e.message); }
const t0 = performance.now();
const pre = host.querySelector('#tl-result');
pre.textContent = 'calling…';
try {
const j = await mcp.call(current.name, args);
const dt = (performance.now() - t0).toFixed(0);
host.querySelector('#tl-meta').textContent = `${dt} ms`;
pre.textContent = fmtJSON(j);
callHist.push({ t: Date.now(), tool: current.name, args });
renderHist(host);
ok('called ' + current.name);
} catch (e) {
pre.textContent = 'error: ' + e.message;
err(e.message);
}
});
load(host);
},
});