152 lines
4.7 KiB
JavaScript
152 lines
4.7 KiB
JavaScript
/**
|
|
* 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, '"')
|
|
}
|