Agent Charts
CI / test (push) Successful in 2m37s
Release rolling / release (push) Successful in 11m32s

This commit is contained in:
Raven Scott
2026-07-30 16:28:06 -04:00
parent bcfec67368
commit 6119ba6e46
8 changed files with 734 additions and 20 deletions
+151
View File
@@ -0,0 +1,151 @@
/**
* Inline sparkline embeds for QVAC chat from tool results / agent chart actions.
*/
import { drawChart, seriesColor } from '../charts.js'
/**
* Extract chart ids from tool log for embedding.
* @param {Array<{ name: string, args?: object, result?: any }>} toolLog
* @returns {string[]}
*/
export function chartIdsFromToolLog(toolLog) {
/** @type {string[]} */
const ids = []
const push = (id) => {
const s = String(id || '').trim()
if (s && !ids.includes(s)) ids.push(s)
}
for (const t of toolLog || []) {
const n = t.name || ''
const a = t.args || {}
const r = t.result || {}
if (
n === 'show_charts' ||
n === 'open_chart' ||
n === 'pin_charts' ||
n === 'set_chart_type' ||
n === 'summarize_chart' ||
n === 'query_metric' ||
n === 'compare_chart_windows'
) {
if (a.chart) push(a.chart)
if (a.id) push(a.id)
if (a.charts) {
String(a.charts)
.split(/[\s,]+/)
.filter(Boolean)
.forEach(push)
}
if (Array.isArray(r.charts)) r.charts.forEach(push)
if (r.chart) push(r.chart)
if (r.focus) push(r.focus)
}
if (n === 'hot_metrics' && Array.isArray(r.results)) {
for (const row of r.results.slice(0, 4)) push(row.chart)
}
if (n === 'search_charts' && Array.isArray(r.results)) {
for (const row of r.results.slice(0, 3)) push(row.id)
}
}
return ids.slice(0, 6)
}
/**
* Mount sparklines under a message element.
* @param {HTMLElement} msgEl
* @param {string[]} chartIds
* @param {{
* request: (method: string, args?: object) => Promise<any>,
* catalog?: Record<string, object>,
* onOpen?: (id: string) => void,
* }} deps
*/
export async function mountChartEmbeds(msgEl, chartIds, deps) {
if (!msgEl || !chartIds?.length || !deps?.request) return
let host = msgEl.querySelector('.qvac-chart-embeds')
if (host) host.remove()
host = document.createElement('div')
host.className = 'qvac-chart-embeds'
msgEl.appendChild(host)
for (const id of chartIds) {
const card = document.createElement('button')
card.type = 'button'
card.className = 'qvac-chart-embed'
card.title = `Open ${id} on Charts wall`
const title = deps.catalog?.[id]?.title || id
card.innerHTML = `<header><span class="qvac-chart-embed-id">${escapeHtml(id)}</span><span class="muted">${escapeHtml(title)}</span></header><canvas width="320" height="72"></canvas><footer class="muted">Loading…</footer>`
card.addEventListener('click', () => deps.onOpen?.(id))
host.appendChild(card)
const canvas = card.querySelector('canvas')
const footer = card.querySelector('footer')
try {
const q = await deps.request('queryData', {
chart: id,
after: -180,
points: 90,
group: 'average',
})
const labels = (q.labels || []).filter((l) => l && l !== 'time')
const data = Array.isArray(q.data) ? q.data : []
if (!labels.length || !data.length) {
if (footer) footer.textContent = 'No points'
continue
}
// Prefer up to 3 interesting dims
/** @type {Array<{ values: number[], color: string, label: string }>} */
const lines = []
for (let di = 0; di < Math.min(3, labels.length); di++) {
const vals = data
.map((row) => Number(row[di + 1]))
.filter((v) => Number.isFinite(v))
if (vals.length < 2) continue
// Pad to full length with nulls as 0 for sparkline simplicity
const full = data.map((row) => {
const v = Number(row[di + 1])
return Number.isFinite(v) ? v : 0
})
lines.push({
values: full,
color: seriesColor(di),
label: labels[di],
})
}
if (!lines.length) {
if (footer) footer.textContent = 'No numeric dims'
continue
}
if (canvas) {
drawChart(canvas, lines, {
maxPoints: 90,
showYAxis: false,
padLeft: 4,
mode: 'area',
emptyMessage: '',
})
}
const last = lines[0].values[lines[0].values.length - 1]
if (footer) {
footer.textContent = `${lines.map((l) => l.label).join(', ')} · last ${formatNum(last)}`
}
} catch (err) {
if (footer) footer.textContent = err?.message || 'query failed'
}
}
}
function formatNum(v) {
const n = Number(v)
if (!Number.isFinite(n)) return '—'
if (Math.abs(n) >= 100) return n.toFixed(0)
if (Math.abs(n) >= 10) return n.toFixed(1)
return n.toFixed(2)
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}