/** * 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, * catalog?: Record, * 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 = `
${escapeHtml(id)}${escapeHtml(title)}
` 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, '"') }