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
+29 -1
View File
@@ -327,8 +327,21 @@ const qvacView = createQvacView({
return bm?.alias || `${String(active.publicKeyHex).slice(0, 12)}` return bm?.alias || `${String(active.publicKeyHex).slice(0, 12)}`
}, },
getCatalog: () => chartCatalog, getCatalog: () => chartCatalog,
onOpenChart: (chartId, ts) => { onOpenChart: (chartId, ts, chartOpts = {}) => {
showView('charts') showView('charts')
if (metricsDashboard.showCharts) {
metricsDashboard.showCharts({
charts: [chartId],
pin: chartOpts.pin !== false,
boardOnly: chartOpts.pin !== false,
focus: chartId,
ts,
mode: chartOpts.mode,
preset: chartOpts.preset,
openFocus: true,
})
return
}
if (ts) metricsDashboard.focusChartAt(chartId, ts) if (ts) metricsDashboard.focusChartAt(chartId, ts)
else { else {
metricsDashboard.scrollToChart?.(chartId) metricsDashboard.scrollToChart?.(chartId)
@@ -336,6 +349,21 @@ const qvacView = createQvacView({
} }
}, },
onOpenView: (view) => showView(view), onOpenView: (view) => showView(view),
charts: {
showCharts: (o) => {
showView('charts')
return metricsDashboard.showCharts?.(o) || { ok: false, error: 'unavailable' }
},
setPinned: (ids, o) => metricsDashboard.setPinned?.(ids, o),
setChartMode: (id, mode) => metricsDashboard.setChartMode?.(id, mode),
setTimeWindow: (o) => metricsDashboard.setTimeWindow?.(o),
setFilter: (q) => metricsDashboard.setFilter?.(q),
showRelated: (id) => metricsDashboard.showRelated?.(id),
openFocus: (id) => metricsDashboard.openFocus?.(id),
scrollToChart: (id) => metricsDashboard.scrollToChart?.(id),
correlateAround: (ts, o) => metricsDashboard.correlateAround?.(ts, o),
runMetricCorrelations: (o) => metricsDashboard.runMetricCorrelations?.(o),
},
log: (msg) => log(msg), log: (msg) => log(msg),
}) })
+19 -2
View File
@@ -200,10 +200,27 @@ Tools are **tiered** so small models stay within context:
| Tier | When | Tools | | Tier | When | Tools |
|------|------|-------| |------|------|-------|
| **core** | All tool-enabled profiles | `investigate_host`, `host_snapshot`, `search_charts`, `summarize_chart`, `list_anomalies`, `list_alerts`, `list_processes`, `local_knowledge`, `open_chart`, `open_view` | | **core** | All tool-enabled profiles | Investigation + **live charts**: `show_charts`, `open_chart`, `pin_charts`, `set_chart_type`, `set_time_window`, `filter_charts`, `show_related_ui`, plus `investigate_host`, snapshots, search/summarize, processes, `local_knowledge`, `open_view` |
| **deep** | Recommended / Strong | `hot_metrics`, `related_charts`, `compare_chart_windows`, `summarize_charts`, `query_metric`, `get_weights`, `query_logs`, `fleet_health`, `list_child_peers`, `storage_info`, `agent_health`, `node_info`, `db_info`, `list_contexts`, `get_chart`, `list_jobs`, `get_alert` | | **deep** | Recommended / Strong | `run_correlations`, `hot_metrics`, `related_charts`, `compare_chart_windows`, `summarize_charts`, `query_metric`, `get_weights`, logs, fleet, storage, agent meta, `get_alert` |
| **write** | Operator role + confirm | `silence_alert`, `ack_alert`, `run_job` | | **write** | Operator role + confirm | `silence_alert`, `ack_alert`, `run_job` |
### Live charts (desktop)
The model can drive the Charts wall in real time:
| Tool | Effect |
|------|--------|
| `show_charts` | Open Charts, pin board, set window/type, focus |
| `open_chart` | Focus one chart (optional pin / mode / preset / event `ts`) |
| `pin_charts` | Pin or unpin board cards |
| `set_chart_type` | `line` · `area` · `stacked` · `bar` · `multibar` · `pie` |
| `set_time_window` | Preset or seconds + play/pause |
| `filter_charts` | Wall search box |
| `show_related_ui` | Related panel for a seed chart |
| `run_correlations` | Metric Correlations around a time |
Chat also embeds **sparklines** for charts the tools touched (click opens the full wall).
On context overflow the engine first drops to **core** tools, then drops tool schemas entirely and retries. On context overflow the engine first drops to **core** tools, then drops tool schemas entirely and retries.
## Settings ## Settings
+152
View File
@@ -1053,6 +1053,150 @@ export function createMetricsDashboard(opts) {
if (wall) wall.scrollTop = top if (wall) wall.scrollTop = top
} }
/**
* Pin or unpin one or more charts (agent / automation API).
* @param {string|string[]} chartIds
* @param {{ pin?: boolean }} [optsIn] pin=true force pin, false force unpin, omit=toggle each
*/
function setPinned(chartIds, optsIn = {}) {
const ids = (Array.isArray(chartIds) ? chartIds : [chartIds])
.map(String)
.filter(Boolean)
const force = optsIn.pin
for (const id of ids) {
if (force === true) state.pinned.add(id)
else if (force === false) state.pinned.delete(id)
else if (state.pinned.has(id)) state.pinned.delete(id)
else state.pinned.add(id)
}
persistPrefs({ pinned: [...state.pinned] })
const wall = opts.els.wall
const top = wall?.scrollTop || 0
render()
if (wall) wall.scrollTop = top
return { ok: true, pinned: [...state.pinned], touched: ids }
}
/**
* Set chart visualization mode: line|area|bar|pie|stack.
* @param {string} id
* @param {string} mode
*/
function setChartMode(id, mode) {
const card = state.cards.get(id) || ensureCard(id, opts.getCatalog()?.[id] || {})
if (!card) return { ok: false, error: 'unknown chart' }
const next = normalizeChartMode(mode)
card.mode = next
state.chartTypes.set(id, next)
persistPrefs({ chartTypes: Object.fromEntries(state.chartTypes) })
const typeBtn = state.cardEls.get(id)?.querySelector('.metric-type-btn')
if (typeBtn) {
typeBtn.textContent = CHART_MODE_LABEL[next] || next
typeBtn.title = chartModeHint(next)
}
paintCard(id)
return { ok: true, chart: id, mode: next }
}
/**
* Filter the charts wall by free text (same as search box).
* @param {string} q
*/
function setFilter(q) {
state.filter = String(q || '')
if (opts.els.search) opts.els.search.value = state.filter
render()
return { ok: true, filter: state.filter }
}
/**
* Time window control for the wall.
* @param {{
* preset?: string,
* seconds?: number,
* endOffset?: number,
* playing?: boolean,
* }} optsIn
*/
function setTimeWindow(optsIn = {}) {
if (optsIn.preset) setPreset(String(optsIn.preset))
else if (optsIn.seconds != null) {
setWindow(Number(optsIn.seconds), optsIn.endOffset != null ? Number(optsIn.endOffset) : state.endOffset)
}
if (optsIn.playing != null) setPlaying(Boolean(optsIn.playing))
return {
ok: true,
afterSeconds: state.afterSeconds,
endOffset: state.endOffset,
playing: state.playing,
preset: state.preset,
}
}
/**
* High-level “show these charts now” for QVAC / automation.
* Opens board, pins charts, applies window/type, optional focus.
* @param {{
* charts?: string[],
* pin?: boolean,
* boardOnly?: boolean,
* focus?: string,
* ts?: number,
* preset?: string,
* seconds?: number,
* mode?: string,
* filter?: string,
* related?: boolean,
* playing?: boolean,
* }} optsIn
*/
function showCharts(optsIn = {}) {
const charts = (Array.isArray(optsIn.charts) ? optsIn.charts : [])
.map(String)
.filter(Boolean)
.slice(0, 24)
if (optsIn.filter != null) setFilter(optsIn.filter)
if (optsIn.preset || optsIn.seconds != null) {
setTimeWindow({
preset: optsIn.preset,
seconds: optsIn.seconds,
playing: optsIn.playing,
})
} else if (optsIn.playing != null) {
setPlaying(Boolean(optsIn.playing))
}
if (optsIn.pin !== false && charts.length) {
setPinned(charts, { pin: true })
}
if (optsIn.boardOnly != null) setBoardOnly(Boolean(optsIn.boardOnly))
else if (charts.length >= 1 && optsIn.pin !== false) setBoardOnly(true)
if (optsIn.mode && charts.length) {
for (const id of charts) setChartMode(id, optsIn.mode)
}
const focusId = optsIn.focus || charts[0]
if (focusId) {
if (optsIn.ts) focusChartAt(focusId, optsIn.ts)
else {
scrollToChart(focusId)
if (optsIn.openFocus !== false && charts.length === 1) openFocus(focusId)
}
}
if (optsIn.related && focusId) {
showRelated(focusId).catch(() => {})
}
return {
ok: true,
charts,
pinned: [...state.pinned],
boardOnly: state.boardOnly,
focus: focusId || null,
afterSeconds: state.afterSeconds,
playing: state.playing,
}
}
async function showRelated(seedId) { async function showRelated(seedId) {
state.relatedSeed = seedId state.relatedSeed = seedId
/** @type {Array<{ id: string, weight: number }>|undefined} */ /** @type {Array<{ id: string, weight: number }>|undefined} */
@@ -2258,6 +2402,14 @@ export function createMetricsDashboard(opts) {
correlateAround, correlateAround,
refreshRetention, refreshRetention,
getState: () => state, getState: () => state,
// Agent / automation surface
setPinned,
setChartMode,
setFilter,
setTimeWindow,
showCharts,
togglePin,
cycleChartType,
} }
} }
+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;')
}
+26 -1
View File
@@ -11,6 +11,7 @@ import {
runSubAgents, runSubAgents,
synthesizeFromAgents, synthesizeFromAgents,
} from './agents.js' } from './agents.js'
import { chartIdsFromToolLog, mountChartEmbeds } from './chart-embed.js'
/** /**
* @param {{ * @param {{
@@ -39,8 +40,9 @@ import {
* saveSettings: (patch: object) => void, * saveSettings: (patch: object) => void,
* getPeerLabel: () => string, * getPeerLabel: () => string,
* getCatalog?: () => Record<string, object>, * getCatalog?: () => Record<string, object>,
* onOpenChart?: (id: string, ts?: number) => void, * onOpenChart?: (id: string, ts?: number, opts?: object) => void,
* onOpenView?: (view: string) => void, * onOpenView?: (view: string) => void,
* charts?: object,
* log?: (msg: string) => void, * log?: (msg: string) => void,
* }} opts * }} opts
*/ */
@@ -60,6 +62,7 @@ export function createQvacView(opts) {
getCatalog: () => opts.getCatalog?.() || {}, getCatalog: () => opts.getCatalog?.() || {},
onOpenChart: opts.onOpenChart, onOpenChart: opts.onOpenChart,
onOpenView: opts.onOpenView, onOpenView: opts.onOpenView,
charts: opts.charts,
confirmAction: (msg) => { confirmAction: (msg) => {
try { try {
return typeof confirm === 'function' ? confirm(msg) : true return typeof confirm === 'function' ? confirm(msg) : true
@@ -892,6 +895,28 @@ export function createQvacView(opts) {
if (chips) chips.remove() if (chips) chips.remove()
assistantUi.div.appendChild(renderToolChips(toolLog)) assistantUi.div.appendChild(renderToolChips(toolLog))
} }
// Live sparklines under the answer for chart-related tools
const embedIds = chartIdsFromToolLog(toolLog)
if (embedIds.length && assistantUi?.div && opts.manager?.request) {
mountChartEmbeds(assistantUi.div, embedIds, {
request: (m, a) => opts.manager.request(m, a || {}),
catalog: opts.getCatalog?.() || {},
onOpen: (id) => {
opts.onOpenView?.('charts')
if (opts.charts?.showCharts) {
opts.charts.showCharts({
charts: [id],
pin: true,
boardOnly: true,
focus: id,
openFocus: true,
})
} else {
opts.onOpenChart?.(id)
}
},
}).catch(() => {})
}
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok') setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
} catch (err) { } catch (err) {
const msg = err?.message || String(err) const msg = err?.message || String(err)
+16 -9
View File
@@ -28,7 +28,7 @@ export function buildSystemPrompt(ctx = {}) {
'- Cite chart ids (e.g. system.cpu) when discussing metrics.', '- Cite chart ids (e.g. system.cpu) when discussing metrics.',
'- You are read-only unless the user explicitly asks for an operator action and their role allows it.', '- You are read-only unless the user explicitly asks for an operator action and their role allows it.',
'- Do not claim cloud access; all inference is local via QVAC.', '- Do not claim cloud access; all inference is local via QVAC.',
'- Prefer open_chart / open_view when the user asks to see something in the UI.', '- Prefer show_charts / open_chart / open_view when the user asks to see graphs or the UI.',
multi multi
? '- Multi-agent mode: specialist investigators may already have collected a briefing. Trust those facts; only call extra tools for gaps.' ? '- Multi-agent mode: specialist investigators may already have collected a briefing. Trust those facts; only call extra tools for gaps.'
: '', : '',
@@ -36,12 +36,18 @@ export function buildSystemPrompt(ctx = {}) {
'Tool playbook (use these exact tool names and chart ids):', 'Tool playbook (use these exact tool names and chart ids):',
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).', '- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).',
' Lighter alternative: host_snapshot. Do NOT use md.health for general health.', ' Lighter alternative: host_snapshot. Do NOT use md.health for general health.',
'- Spikes / unusual activity → hot_metrics, then summarize_chart or compare_chart_windows on top hits.', '- "Show me a graph/chart/plot" → search_charts if needed, then show_charts with concrete chart ids.',
'- CPU high / load → investigate_host or host_snapshot + summarize_chart chart=system.cpu + list_processes.', ' show_charts pins to the board, sets window (preset=5m|1h|…), optional mode=line|area|bar|pie, focus.',
'- Memory → summarize_chart chart=system.ram (and search_charts q=mem if needed).', ' Example: show_charts charts=system.cpu,system.ram preset=15m mode=area boardOnly=true.',
'- Disk / IO / docker / redis / nginx / postgres → search_charts, then summarize_chart on a concrete id.', '- Single chart focus → open_chart chart=system.cpu (optional pin, mode, preset, ts).',
'- Pin/unpin board → pin_charts; change type → set_chart_type; window → set_time_window; search wall → filter_charts.',
'- Related wall panel → show_related_ui; Metric Correlations UI → run_correlations.',
'- Spikes / unusual activity → hot_metrics, then show_charts on top hits + summarize_chart.',
'- CPU high / load → investigate_host + show_charts charts=system.cpu,system.load + list_processes.',
'- Memory → show_charts charts=system.ram + summarize_chart chart=system.ram.',
'- Disk / IO / docker / redis / nginx / postgres → search_charts, then show_charts on concrete ids.',
'- Follow-up on one chart → related_charts, get_weights, compare_chart_windows, summarize_charts (batch).', '- Follow-up on one chart → related_charts, get_weights, compare_chart_windows, summarize_charts (batch).',
'- Alerts / anomalies → list_anomalies and/or list_alerts; get_alert for one id.', '- Alerts / anomalies → list_anomalies and/or list_alerts; get_alert for one id; open_chart with ts on the chart.',
'- Fleet → fleet_health + list_child_peers.', '- Fleet → fleet_health + list_child_peers.',
'- Storage / retention / prune questions → storage_info (+ local_knowledge for product how-to).', '- Storage / retention / prune questions → storage_info (+ local_knowledge for product how-to).',
'- Logs → query_logs source=anomaly (or journal/audit when role allows).', '- Logs → query_logs source=anomaly (or journal/audit when role allows).',
@@ -51,6 +57,7 @@ export function buildSystemPrompt(ctx = {}) {
'- NEVER use chart id "md.health" for general host health — that is MD RAID only and is often empty.', '- NEVER use chart id "md.health" for general host health — that is MD RAID only and is often empty.',
'- Prefer chart ids that appear in tool results. If summarize_chart returns points=0, try investigate_host or another chart.', '- Prefer chart ids that appear in tool results. If summarize_chart returns points=0, try investigate_host or another chart.',
'- After tools return, give a clear human summary with numbers; do not only restate empty charts.', '- After tools return, give a clear human summary with numbers; do not only restate empty charts.',
'- When you show charts, name the chart ids you opened so the operator can find them.',
'', '',
`Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}${multi ? ', multi-agent=on' : ''}.`, `Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}${multi ? ', multi-agent=on' : ''}.`,
] ]
@@ -61,12 +68,12 @@ export function buildSystemPrompt(ctx = {}) {
export const SAMPLE_PROMPTS = [ export const SAMPLE_PROMPTS = [
'Summarize host health right now', 'Summarize host health right now',
"What's wrong — investigate this host", "What's wrong — investigate this host",
'Show me live graphs for CPU and RAM (15m)',
'Plot disk / io charts as an area board',
'Why might CPU be high?', 'Why might CPU be high?',
'Show hot / spiking metrics', 'Show hot / spiking metrics and open them',
'List charts related to disk or io',
'Any open alerts or anomalies?', 'Any open alerts or anomalies?',
'Top processes by CPU if available', 'Top processes by CPU if available',
'Run a multi-agent investigation of this host', 'Run a multi-agent investigation of this host',
'How do Charts time presets work?', 'How do Charts time presets work?',
'What is retention / warm history?',
] ]
+290 -7
View File
@@ -129,15 +129,163 @@ export const TOOL_DEFS = [
type: 'function', type: 'function',
name: 'open_chart', name: 'open_chart',
tier: 'core', tier: 'core',
description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).', description:
'Open one chart on the Charts wall (scroll + focus). Prefer show_charts when pinning multiple or changing window/type.',
parameters: params( parameters: params(
{ {
chart: { type: 'string', description: 'Chart id' }, chart: { type: 'string', description: 'Chart id' },
ts: { type: 'number', description: 'Event time ms' }, ts: { type: 'number', description: 'Event time ms (pauses near event)' },
pin: { type: 'boolean', description: 'Pin chart to board (default true)' },
mode: {
type: 'string',
description: 'Chart type: line|area|stacked|bar|multibar|pie',
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
},
preset: {
type: 'string',
description: 'Time preset e.g. 5m|15m|1h|6h|24h',
},
}, },
['chart'] ['chart']
), ),
}, },
{
type: 'function',
name: 'show_charts',
tier: 'core',
description:
'PRIMARY live chart tool. Opens Charts view, pins charts to the board, sets time window/type, optional focus and related panel. Use for "show me graphs", "plot CPU", multi-chart boards.',
parameters: params(
{
charts: {
type: 'string',
description: 'Comma/space separated chart ids (max 24), e.g. system.cpu,system.ram',
},
pin: {
type: 'boolean',
description: 'Pin charts to board (default true)',
},
boardOnly: {
type: 'boolean',
description: 'Show only pinned board (default true when pinning)',
},
focus: { type: 'string', description: 'Chart id to focus/expand' },
ts: { type: 'number', description: 'Event time ms for focus' },
preset: {
type: 'string',
description: 'Time preset: 5m|15m|30m|1h|3h|6h|12h|24h',
},
seconds: {
type: 'number',
description: 'Custom lookback window seconds (alternative to preset)',
},
mode: {
type: 'string',
description: 'Apply chart type to all listed charts',
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
},
filter: {
type: 'string',
description: 'Charts wall search filter text',
},
related: {
type: 'boolean',
description: 'Open related-charts panel for focus chart',
},
playing: {
type: 'boolean',
description: 'Live play (true) or pause (false)',
},
},
['charts']
),
},
{
type: 'function',
name: 'pin_charts',
tier: 'core',
description: 'Pin or unpin charts on the Charts board without changing focus.',
parameters: params(
{
charts: {
type: 'string',
description: 'Comma/space separated chart ids',
},
pin: {
type: 'boolean',
description: 'true=pin (default), false=unpin',
},
},
['charts']
),
},
{
type: 'function',
name: 'set_chart_type',
tier: 'core',
description: 'Change visualization for a chart: line, area, stacked, bar, multibar, pie.',
parameters: params(
{
chart: { type: 'string', description: 'Chart id' },
mode: {
type: 'string',
description: 'line|area|stacked|bar|multibar|pie',
enum: ['line', 'area', 'stacked', 'bar', 'multibar', 'pie'],
},
},
['chart', 'mode']
),
},
{
type: 'function',
name: 'set_time_window',
tier: 'core',
description: 'Set Charts wall time window (preset or seconds) and play/pause live.',
parameters: params({
preset: {
type: 'string',
description: '5m|15m|30m|1h|3h|6h|12h|24h',
},
seconds: { type: 'number', description: 'Lookback seconds' },
playing: { type: 'boolean', description: 'Live updates on/off' },
}),
},
{
type: 'function',
name: 'filter_charts',
tier: 'core',
description: 'Filter the Charts wall by free text (same as the search box).',
parameters: params(
{
q: { type: 'string', description: 'Filter query (empty clears)' },
},
['q']
),
},
{
type: 'function',
name: 'show_related_ui',
tier: 'core',
description: 'Open the Related charts panel for a seed chart on the Charts wall.',
parameters: params(
{
chart: { type: 'string', description: 'Seed chart id' },
},
['chart']
),
},
{
type: 'function',
name: 'run_correlations',
tier: 'deep',
description:
'Run Metric Correlations around a chart or timestamp and show results on the Charts wall.',
parameters: params({
chart: { type: 'string', description: 'Optional seed chart id' },
ts: { type: 'number', description: 'Center timestamp ms (default now)' },
window: { type: 'number', description: 'Highlight window seconds' },
}),
},
{ {
type: 'function', type: 'function',
name: 'open_view', name: 'open_view',
@@ -423,8 +571,19 @@ export const TOOL_DEFS = [
}, },
] ]
/** Tools that work without an agent connection. */ /** Tools that work without an agent connection (desktop UI / local RAG). */
const LOCAL_TOOLS = new Set(['open_view', 'open_chart', 'local_knowledge']) const LOCAL_TOOLS = new Set([
'open_view',
'open_chart',
'show_charts',
'pin_charts',
'set_chart_type',
'set_time_window',
'filter_charts',
'show_related_ui',
'run_correlations',
'local_knowledge',
])
/** Operator write tools. */ /** Operator write tools. */
const WRITE_TOOLS = new Set( const WRITE_TOOLS = new Set(
@@ -473,12 +632,32 @@ function wireDefs(defs) {
* getRole: () => string, * getRole: () => string,
* isConnected: () => boolean, * isConnected: () => boolean,
* getCatalog?: () => Record<string, object>, * getCatalog?: () => Record<string, object>,
* onOpenChart?: (chartId: string, ts?: number) => void, * onOpenChart?: (chartId: string, ts?: number, opts?: object) => void,
* onOpenView?: (view: string) => void, * onOpenView?: (view: string) => void,
* charts?: {
* showCharts?: (opts: object) => object,
* setPinned?: (ids: string|string[], opts?: object) => object,
* setChartMode?: (id: string, mode: string) => object,
* setTimeWindow?: (opts: object) => object,
* setFilter?: (q: string) => object,
* showRelated?: (id: string) => Promise<void>|void,
* openFocus?: (id: string) => void,
* correlateAround?: (ts: number, opts?: object) => void,
* runMetricCorrelations?: (opts?: object) => Promise<any>,
* scrollToChart?: (id: string) => void,
* },
* confirmAction?: (message: string) => boolean|Promise<boolean>, * confirmAction?: (message: string) => boolean|Promise<boolean>,
* }} deps * }} deps
*/ */
export function createToolRunner(deps) { export function createToolRunner(deps) {
function parseChartList(raw) {
if (Array.isArray(raw)) return raw.map(String).filter(Boolean)
return String(raw || '')
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
}
/** /**
* @param {string} name * @param {string} name
* @param {object} args * @param {object} args
@@ -494,6 +673,7 @@ export function createToolRunner(deps) {
} }
} }
const req = (m, a) => deps.manager.request(m, a || {}) const req = (m, a) => deps.manager.request(m, a || {})
const chartsApi = deps.charts || {}
try { try {
switch (name) { switch (name) {
@@ -616,8 +796,111 @@ export function createToolRunner(deps) {
return { context: ctx || 'No matching local knowledge.', q: args.q } return { context: ctx || 'No matching local knowledge.', q: args.q }
} }
case 'open_chart': { case 'open_chart': {
deps.onOpenChart?.(String(args.chart), args.ts) const chart = String(args.chart || args.id || '')
return { ok: true, opened: args.chart } if (!chart) return { error: 'chart required' }
deps.onOpenView?.('charts')
if (chartsApi.showCharts) {
return chartsApi.showCharts({
charts: [chart],
pin: args.pin !== false,
boardOnly: args.pin !== false,
focus: chart,
ts: args.ts,
mode: args.mode,
preset: args.preset,
openFocus: true,
})
}
deps.onOpenChart?.(chart, args.ts, {
pin: args.pin !== false,
mode: args.mode,
preset: args.preset,
})
return { ok: true, opened: chart }
}
case 'show_charts': {
const list = parseChartList(args.charts || args.chart || args.ids)
if (!list.length) return { error: 'charts required' }
deps.onOpenView?.('charts')
if (!chartsApi.showCharts) {
// Fallback: open first chart only
deps.onOpenChart?.(list[0], args.ts)
return { ok: true, charts: list, partial: true }
}
return chartsApi.showCharts({
charts: list,
pin: args.pin !== false,
boardOnly: args.boardOnly != null ? Boolean(args.boardOnly) : args.pin !== false,
focus: args.focus || list[0],
ts: args.ts,
preset: args.preset,
seconds: args.seconds,
mode: args.mode,
filter: args.filter,
related: Boolean(args.related),
playing: args.playing,
openFocus: list.length === 1,
})
}
case 'pin_charts': {
const list = parseChartList(args.charts || args.chart)
if (!list.length) return { error: 'charts required' }
deps.onOpenView?.('charts')
if (!chartsApi.setPinned) return { error: 'charts API unavailable' }
return chartsApi.setPinned(list, {
pin: args.pin !== false && args.pin !== 'false',
})
}
case 'set_chart_type': {
const chart = String(args.chart || args.id || '')
const mode = String(args.mode || args.type || 'line')
if (!chart) return { error: 'chart required' }
deps.onOpenView?.('charts')
if (!chartsApi.setChartMode) return { error: 'charts API unavailable' }
return chartsApi.setChartMode(chart, mode)
}
case 'set_time_window': {
deps.onOpenView?.('charts')
if (!chartsApi.setTimeWindow) return { error: 'charts API unavailable' }
return chartsApi.setTimeWindow({
preset: args.preset,
seconds: args.seconds ?? args.after,
playing: args.playing,
})
}
case 'filter_charts': {
deps.onOpenView?.('charts')
if (!chartsApi.setFilter) return { error: 'charts API unavailable' }
return chartsApi.setFilter(args.q != null ? args.q : args.filter || '')
}
case 'show_related_ui': {
const chart = String(args.chart || args.id || '')
if (!chart) return { error: 'chart required' }
deps.onOpenView?.('charts')
chartsApi.scrollToChart?.(chart)
await chartsApi.showRelated?.(chart)
return { ok: true, chart, related: true }
}
case 'run_correlations': {
deps.onOpenView?.('charts')
const ts =
args.ts != null ? Number(args.ts) : Date.now()
if (chartsApi.correlateAround) {
chartsApi.correlateAround(ts, {
chart: args.chart,
window: args.window,
})
return { ok: true, ts, chart: args.chart || null }
}
if (chartsApi.runMetricCorrelations) {
const res = await chartsApi.runMetricCorrelations({
chart: args.chart,
ts,
window: args.window,
})
return { ok: true, ...(res || {}) }
}
return { error: 'correlations API unavailable' }
} }
case 'open_view': { case 'open_view': {
deps.onOpenView?.(String(args.view || 'overview')) deps.onOpenView?.(String(args.view || 'overview'))
+51
View File
@@ -3757,6 +3757,57 @@ html[data-theme='light'] .proc-detail-cmd {
border-color: color-mix(in srgb, #f87171 55%, var(--border-color)); border-color: color-mix(in srgb, #f87171 55%, var(--border-color));
} }
.qvac-chart-embeds {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
margin-top: 10px;
width: 100%;
}
.qvac-chart-embed {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.qvac-chart-embed:hover {
border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-color));
}
.qvac-chart-embed header {
display: flex;
flex-direction: column;
gap: 1px;
font-size: 0.75rem;
}
.qvac-chart-embed-id {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--accent-primary);
}
.qvac-chart-embed canvas {
width: 100%;
height: 72px;
display: block;
border-radius: 6px;
background: color-mix(in srgb, var(--bg-primary, #0b1020) 80%, transparent);
}
.qvac-chart-embed footer {
font-size: 0.7rem;
}
.qvac-model-chip { .qvac-model-chip {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;