Agent Charts
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
Reference in New Issue
Block a user