First Try: QVAC (QuantumVerse Automatic Computer)
CI / test (push) Successful in 1m8s
Release rolling / release (push) Successful in 8m25s

This commit is contained in:
Raven Scott
2026-07-30 13:42:14 -04:00
parent f52168feb9
commit 32928f19bd
27 changed files with 2893 additions and 2 deletions
+73
View File
@@ -26,6 +26,7 @@ import { drawChart, CHART_PALETTE, pushRing } from './ui/charts.js'
import { defaultModeFromMeta } from './shared/chart-types.js' import { defaultModeFromMeta } from './shared/chart-types.js'
import { createMetricsDashboard } from './ui/dashboard.js' import { createMetricsDashboard } from './ui/dashboard.js'
import { getChartFocus } from './ui/chart-focus.js' import { getChartFocus } from './ui/chart-focus.js'
import { createQvacView } from './ui/qvac/index.js'
import { import {
buildFleetRoster, buildFleetRoster,
summarizeFleet, summarizeFleet,
@@ -97,6 +98,10 @@ const els = {
settingChartPoints: $('setting-chart-points'), settingChartPoints: $('setting-chart-points'),
settingDefaultExplore: $('setting-default-explore'), settingDefaultExplore: $('setting-default-explore'),
settingNotify: $('setting-notify'), settingNotify: $('setting-notify'),
settingQvacProfile: $('setting-qvac-profile'),
settingQvacRag: $('setting-qvac-rag'),
settingQvacIdle: $('setting-qvac-idle'),
btnQvacOpen: $('btn-qvac-open'),
settingAutoRestore: $('setting-auto-restore'), settingAutoRestore: $('setting-auto-restore'),
settingReconnectMax: $('setting-reconnect-max'), settingReconnectMax: $('setting-reconnect-max'),
btnResetPeers: $('btn-reset-peers'), btnResetPeers: $('btn-reset-peers'),
@@ -288,6 +293,49 @@ const dataManager = createDataManager({
log: (msg) => log(msg), log: (msg) => log(msg),
}) })
const qvacView = createQvacView({
els: {
root: $('qvac-view'),
setup: $('qvac-setup'),
chat: $('qvac-chat'),
messages: $('qvac-messages'),
input: /** @type {HTMLTextAreaElement|null} */ ($('qvac-input')),
sendBtn: $('qvac-send'),
status: $('qvac-status'),
modelChip: $('qvac-model-chip'),
samples: $('qvac-samples'),
setupSteps: $('qvac-setup-steps'),
resetBtn: $('qvac-reset'),
unloadBtn: $('qvac-unload'),
newChatBtn: $('qvac-new-chat'),
},
manager,
getRole: () => currentRole,
isConnected: () => Boolean(manager.active?.connected),
getSettings: () => settings,
saveSettings: (patch) => {
Object.assign(settings, patch)
persist(patch)
},
getPeerLabel: () => {
const active = manager.active
if (!active) return ''
const bm = loadBookmarks().find((b) => b.publicKeyHex === active.publicKeyHex)
return bm?.alias || `${String(active.publicKeyHex).slice(0, 12)}`
},
getCatalog: () => chartCatalog,
onOpenChart: (chartId, ts) => {
showView('charts')
if (ts) metricsDashboard.focusChartAt(chartId, ts)
else {
metricsDashboard.scrollToChart?.(chartId)
metricsDashboard.openFocus?.(chartId)
}
},
onOpenView: (view) => showView(view),
log: (msg) => log(msg),
})
function seriesMax() { function seriesMax() {
// Overview sparks follow Charts' active window when available (capped for density) // Overview sparks follow Charts' active window when available (capped for density)
try { try {
@@ -1340,6 +1388,8 @@ function showView(name) {
} }
if (name === 'logs') logsView.enter() if (name === 'logs') logsView.enter()
if (name === 'processes') processesView.enter() if (name === 'processes') processesView.enter()
if (name === 'qvac') qvacView.enter()
else qvacView.leave?.()
if (name === 'fleet') loadFleetView() if (name === 'fleet') loadFleetView()
if (name === 'settings') { if (name === 'settings') {
syncSettingsUi() syncSettingsUi()
@@ -1369,6 +1419,13 @@ function syncSettingsUi() {
if (els.settingReconnectMax) { if (els.settingReconnectMax) {
els.settingReconnectMax.value = String(settings.reconnectMaxAttempts ?? 20) els.settingReconnectMax.value = String(settings.reconnectMaxAttempts ?? 20)
} }
if (els.settingQvacProfile) {
els.settingQvacProfile.value = settings.qvacProfile || 'recommended'
}
if (els.settingQvacRag) els.settingQvacRag.checked = settings.qvacRag !== false
if (els.settingQvacIdle) {
els.settingQvacIdle.value = String(settings.qvacIdleUnloadMin ?? 30)
}
if (els.collapseSidebarBtn) { if (els.collapseSidebarBtn) {
els.collapseSidebarBtn.textContent = settings.sidebarCollapsed ? '' : '' els.collapseSidebarBtn.textContent = settings.sidebarCollapsed ? '' : ''
} }
@@ -1446,6 +1503,22 @@ els.settingReconnectMax?.addEventListener('change', () => {
els.settingReconnectMax.value = String(n) els.settingReconnectMax.value = String(n)
}) })
els.settingQvacProfile?.addEventListener('change', () => {
persist({ qvacProfile: els.settingQvacProfile.value || 'recommended' })
})
els.settingQvacRag?.addEventListener('change', () => {
persist({ qvacRag: els.settingQvacRag.checked })
})
els.settingQvacIdle?.addEventListener('change', () => {
const n = Math.max(0, Math.min(240, Number(els.settingQvacIdle.value) || 0))
persist({ qvacIdleUnloadMin: n })
els.settingQvacIdle.value = String(n)
})
els.btnQvacOpen?.addEventListener('click', () => showView('qvac'))
els.btnResetPeers?.addEventListener('click', async () => { els.btnResetPeers?.addEventListener('click', async () => {
if (!confirm('Forget all saved agents and disconnect?')) return if (!confirm('Forget all saved agents and disconnect?')) return
await manager.disconnectAll({ forget: true }) await manager.disconnectAll({ forget: true })
+12
View File
@@ -32,6 +32,12 @@ export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
* metricsFiltersOpen: boolean, * metricsFiltersOpen: boolean,
* reconnectMaxAttempts: number, * reconnectMaxAttempts: number,
* autoRestorePeers: boolean, * autoRestorePeers: boolean,
* qvacOnboarded?: boolean,
* qvacProfile?: string,
* qvacMode?: string,
* qvacCacheDir?: string,
* qvacRag?: boolean,
* qvacIdleUnloadMin?: number,
* }} UiSettings */ * }} UiSettings */
/** @returns {UiSettings} */ /** @returns {UiSettings} */
@@ -56,6 +62,12 @@ export function defaultSettings() {
metricsFiltersOpen: false, metricsFiltersOpen: false,
reconnectMaxAttempts: 20, reconnectMaxAttempts: 20,
autoRestorePeers: true, autoRestorePeers: true,
qvacOnboarded: false,
qvacProfile: 'recommended',
qvacMode: '',
qvacCacheDir: '',
qvacRag: true,
qvacIdleUnloadMin: 30,
} }
} }
+12
View File
@@ -117,6 +117,18 @@ Linux `/proc` only. Rates need two samples (~450ms cache). REST: `GET /api/v*/pr
| `ackAlert` | operator | Clear until next breach | | `ackAlert` | operator | Clear until next breach |
| `silenceAlert` | operator | Disable temporarily | | `silenceAlert` | operator | Disable temporarily |
### AI / QVAC tool composites
Lean read-only helpers for the desktop QVAC tab (and scripts). See [QVAC.md](./QVAC.md).
| Method | Role | Args | Notes |
|--------|------|------|-------|
| `getHostSnapshot` | viewer | — | Health, KPIs, recent anomalies/alerts, catalog + storage summary |
| `searchCharts` | viewer | `{ q?, limit? }` | Catalog free-text search (`limit` 1100) |
| `summarizeChart` | viewer | `{ chart, after?, points?, group? }` | min/avg/max/last per dim for a window |
REST mirrors: `GET /api/v3/ai/snapshot`, `/api/v3/ai/charts`, `/api/v3/ai/chart/:id/summary`.
### Jobs ### Jobs
| Method | Role | Known jobs | | Method | Role | Known jobs |
+115
View File
@@ -0,0 +1,115 @@
# QVAC integration (local AI)
PearDatas **QVAC** tab is a local-first SRE copilot. Inference runs on the **desktop** (Electron/Pear); the agent only serves metrics over existing P2P RPC / REST. No cloud LLM is required.
Upstream: [tetherto/qvac](https://github.com/tetherto/qvac) · [docs.qvac.tether.io](https://docs.qvac.tether.io)
## Architecture
```
Desktop QVAC tab
→ ui/qvac (engine + tools + onboarding)
→ @qvac/sdk (optional) OR tools-only fallback
→ manager.request(Methods.*)
→ PearMonitor agent
```
| Layer | Responsibility |
|-------|----------------|
| Desktop | Model download/load, chat UI, tool loop |
| Agent | Lean composite reads (`getHostSnapshot`, `searchCharts`, `summarizeChart`) + existing metrics RPCs |
| REST | `/api/v3/ai/*` mirrors for scripts |
Agent overhead is unchanged when the QVAC tab is unused (no model load on the agent).
## Model profiles
| Profile | Chat constant | Tools | Typical use |
|---------|---------------|-------|-------------|
| Lite | `QWEN3_600M_INST_Q4` | limited | ≤8 GB RAM |
| **Recommended** | `QWEN3_1_7B_INST_Q4` | yes | Default |
| Strong | `QWEN3_4B_INST_Q4_K_M` | yes | ≥16 GB RAM |
| Tool-tiny | `LLAMA_TOOL_CALLING_1B_INST_Q4_K` | yes | Fallback |
Embeddings (future RAG): `GTE_LARGE_FP16`.
## Install full local LLM
```bash
npm i @qvac/sdk
```
Without the SDK, the tab still works in **tools-only** mode: it calls agent RPCs and formats grounded answers (no generative model).
Onboarding lives in the QVAC tab (Setup). Preferences: `qvacOnboarded`, `qvacProfile`, `qvacMode` in desktop settings.
## Agent RPCs (viewer)
| Method | Purpose |
|--------|---------|
| `getHostSnapshot` | Health + KPIs + anomalies/alerts + catalog/storage summary |
| `searchCharts` | `{ q, limit }` catalog search |
| `summarizeChart` | `{ chart, after?, points? }` min/avg/max/last per dim |
### REST
- `GET /api/v3/ai/snapshot`
- `GET /api/v3/ai/charts?q=&limit=`
- `GET /api/v3/ai/chart/:id/summary?after=&points=`
## Tools the chat can call
| Tool | Purpose |
|------|---------|
| `host_snapshot` | Health + KPIs + anomalies |
| `search_charts` / `summarize_chart` / `query_metric` | Catalog + series |
| `list_anomalies` / `list_alerts` | Alerts surface |
| `list_processes` | Process table |
| `query_logs` | Journal / anomaly / audit |
| `fleet_health` | Parent fleet summary |
| `storage_info` | Retention + disk usage |
| `local_knowledge` | Keyword RAG over guide + catalog (desktop) |
| `open_chart` / `open_view` | UI navigation |
| `silence_alert` | Operator + confirm dialog |
## Local knowledge (RAG)
Without embedding models, PearData uses **keyword retrieval** over:
- Built-in user-guide snippets (`ui/qvac/rag.js`)
- Live chart catalog titles/contexts
Context is injected into the system prompt when **Settings → QVAC → Inject local knowledge** is on (default).
## Settings
| Pref | Default | Meaning |
|------|---------|---------|
| `qvacProfile` | recommended | Lite / Recommended / Strong / Tool-tiny |
| `qvacRag` | true | Inject local knowledge |
| `qvacIdleUnloadMin` | 30 | Unload model from RAM after idle minutes (0 = never) |
| `qvacOnboarded` | false | Setup wizard completed |
## Desktop settings
| Key | Default | Meaning |
|-----|---------|---------|
| `qvacOnboarded` | `false` | Setup wizard completed |
| `qvacProfile` | `recommended` | Model profile id |
| `qvacMode` | `''` | `qvac` \| `fallback` after last load |
| `qvacRag` | `true` | Inject guide + catalog snippets into prompts |
| `qvacIdleUnloadMin` | `30` | Unload LLM after idle minutes (`0` = never) |
UI: **Settings → QVAC** and the QVAC tab header (Unload / Setup / New chat).
## Pear / Bare notes
- Electron: `@qvac/sdk` spawns a Bare worker automatically.
- Pear: stub entry at `qvac/worker.pear.entry.mjs` (listed in `package.json``pear.stage.entrypoints`). Install `@qvac/bare-sdk` + `@qvac/llm-llamacpp` and uncomment plugin registration when enabling Pear-native inference.
## Related files
- `ui/qvac/*` — desktop UI + engine
- `server/services/ai-tools.js` — composite reads
- `shared/protocol.js` — method roles
- `user-guide/qvac.md` — operator walkthrough
+16
View File
@@ -49,6 +49,22 @@ curl -s http://127.0.0.1:18888/api/v3/health | jq
| GET | `/api/v3/config` | alias of settings | | GET | `/api/v3/config` | alias of settings |
| GET | `/health`, `/api/v1/health`, `/api/v3/health` | Aggregate health | | GET | `/health`, `/api/v1/health`, `/api/v3/health` | Aggregate health |
### AI / QVAC tool helpers
Compact reads for local copilots (same payloads as P2P `getHostSnapshot` / `searchCharts` / `summarizeChart`). See [QVAC.md](./QVAC.md).
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/v3/ai/snapshot` | Health, KPIs, anomalies/alerts, catalog/storage summary |
| GET | `/api/v3/ai/charts?q=&limit=` | Catalog search |
| GET | `/api/v3/ai/chart/:id/summary?after=&points=&group=` | Windowed min/avg/max/last per dimension |
```bash
curl -s http://127.0.0.1:18888/api/v3/ai/snapshot | jq
curl -s 'http://127.0.0.1:18888/api/v3/ai/charts?q=disk&limit=20' | jq
curl -s 'http://127.0.0.1:18888/api/v3/ai/chart/system.cpu/summary?after=-120&points=120' | jq
```
### Nodes ### Nodes
| Method | Path | | Method | Path |
+1
View File
@@ -73,6 +73,7 @@ Template rebrand, protocol, collector, memory store, anomalies, REST, desktop MV
| PearDock bridge | Done spike (`PEARDATA_PEARDOCK=1`, remap docker→peardock) | | PearDock bridge | Done spike (`PEARDATA_PEARDOCK=1`, remap docker→peardock) |
| PearVirt / BareOS adapters | Thin translators into shared contexts | | PearVirt / BareOS adapters | Thin translators into shared contexts |
| Holesail optional REST expose | Tunneled agent API | | Holesail optional REST expose | Tunneled agent API |
| **Local AI (QVAC)** | Phase 01: tab + onboarding + tool RPCs + REST `/api/v3/ai/*` + keyword RAG + settings (profile/RAG/idle unload) + expanded tools (fleet/logs/alerts/silence); optional `@qvac/sdk` — see [QVAC.md](./QVAC.md) |
**Phase 3 exit criteria** **Phase 3 exit criteria**
+65
View File
@@ -56,6 +56,9 @@
<button type="button" class="nav-link" data-view="logs"> <button type="button" class="nav-link" data-view="logs">
<span class="nav-ico"></span><span class="nav-label">Logs</span> <span class="nav-ico"></span><span class="nav-label">Logs</span>
</button> </button>
<button type="button" class="nav-link" data-view="qvac">
<span class="nav-ico"></span><span class="nav-label">QVAC</span>
</button>
<div class="nav-group-label">Fleet</div> <div class="nav-group-label">Fleet</div>
<button type="button" class="nav-link" data-view="fleet"> <button type="button" class="nav-link" data-view="fleet">
<span class="nav-ico"></span><span class="nav-label">Fleet</span> <span class="nav-ico"></span><span class="nav-label">Fleet</span>
@@ -339,6 +342,35 @@
</div> </div>
</section> </section>
<!-- QVAC local AI -->
<section id="qvac-view" class="view hidden">
<header class="page-header qvac-header">
<div>
<p class="dash-kicker">Local AI</p>
<h1 class="dash-title">QVAC</h1>
<p class="page-subtitle">On-device SRE copilot · tools → agent RPC · no cloud</p>
</div>
<div class="page-actions qvac-header-actions">
<span id="qvac-model-chip" class="qvac-model-chip" title="Model status">idle</span>
<span id="qvac-status" class="muted" data-kind=""></span>
<button type="button" id="qvac-new-chat" class="btn btn-ghost">New chat</button>
<button type="button" id="qvac-unload" class="btn btn-ghost" title="Unload model from memory">Unload</button>
<button type="button" id="qvac-reset" class="btn btn-ghost" title="Re-run onboarding">Setup</button>
</div>
</header>
<div id="qvac-setup" class="qvac-setup">
<div id="qvac-setup-steps" class="qvac-setup-steps"></div>
</div>
<div id="qvac-chat" class="qvac-chat hidden">
<div id="qvac-samples" class="qvac-samples" aria-label="Sample prompts"></div>
<div id="qvac-messages" class="qvac-messages" aria-live="polite"></div>
<form id="qvac-form" class="qvac-composer" onsubmit="return false">
<textarea id="qvac-input" rows="2" placeholder="Ask about host health, metrics, anomalies, processes…" autocomplete="off"></textarea>
<button type="button" id="qvac-send" class="btn btn-primary">Send</button>
</form>
</div>
</section>
<!-- Processes --> <!-- Processes -->
<section id="processes-view" class="view hidden"> <section id="processes-view" class="view hidden">
<header class="page-header proc-header"> <header class="page-header proc-header">
@@ -502,6 +534,7 @@
<button type="button" class="settings-tab" data-settings-tab="data">Data</button> <button type="button" class="settings-tab" data-settings-tab="data">Data</button>
<button type="button" class="settings-tab" data-settings-tab="connections">Connections</button> <button type="button" class="settings-tab" data-settings-tab="connections">Connections</button>
<button type="button" class="settings-tab" data-settings-tab="notifications">Notifications</button> <button type="button" class="settings-tab" data-settings-tab="notifications">Notifications</button>
<button type="button" class="settings-tab" data-settings-tab="qvac">QVAC</button>
<button type="button" class="settings-tab" data-settings-tab="about">About</button> <button type="button" class="settings-tab" data-settings-tab="about">About</button>
</div> </div>
@@ -680,6 +713,38 @@
</div> </div>
</div> </div>
<div class="settings-panel" data-settings-panel="qvac">
<div class="dash-card settings-section">
<h3>Local AI (QVAC)</h3>
<p class="hint">
Models run on this desktop. The agent only answers metrics tools.
Open the <strong>QVAC</strong> tab for chat and first-time setup.
</p>
<label>
Default model profile
<select id="setting-qvac-profile">
<option value="lite">Lite (Qwen3 0.6B)</option>
<option value="recommended" selected>Recommended (Qwen3 1.7B + tools)</option>
<option value="strong">Strong (Qwen3 4B)</option>
<option value="tool-tiny">Tool-tiny (Llama 1B tools)</option>
</select>
</label>
<label class="check-row">
<input type="checkbox" id="setting-qvac-rag" checked />
Inject local knowledge (guide + catalog) into prompts
</label>
<label>
Idle unload (minutes, 0 = never)
<input id="setting-qvac-idle" type="number" min="0" max="240" step="5" value="30" />
</label>
<p class="muted settings-hint">
Install <code>@qvac/sdk</code> for full local LLM. Without it, QVAC stays in tools-only mode.
See <code>docs/QVAC.md</code>.
</p>
<button type="button" id="btn-qvac-open" class="btn btn-ghost">Open QVAC tab</button>
</div>
</div>
<div class="settings-panel" data-settings-panel="about"> <div class="settings-panel" data-settings-panel="about">
<div class="dash-card settings-section"> <div class="dash-card settings-section">
<h3>PearData</h3> <h3>PearData</h3>
+6 -1
View File
@@ -31,7 +31,12 @@
"https://*", "https://*",
"ws://*", "ws://*",
"wss://*" "wss://*"
] ],
"stage": {
"entrypoints": [
"/qvac/worker.pear.entry.mjs"
]
}
}, },
"scripts": { "scripts": {
"dev": "pear run -d .", "dev": "pear run -d .",
+26
View File
@@ -0,0 +1,26 @@
/**
* Pear Bare worker entry for QVAC (optional).
*
* When using @qvac/bare-sdk in a Pear build, register only the plugins you need:
* npm i @qvac/bare-sdk @qvac/llm-llamacpp @qvac/embed-llamacpp
*
* Add to package.json pear.stage.entrypoints: "/qvac/worker.pear.entry.mjs"
*
* Electron uses @qvac/sdk's default worker this file is Pear-only.
*
* @see https://github.com/tetherto/qvac/tree/main/packages/bare-sdk
*/
// Uncomment when bare-sdk is installed:
//
// import { registerPlugin } from '@qvac/bare-sdk/plugins'
// import { llmPlugin } from '@qvac/bare-sdk/llamacpp-completion/plugin'
// // import { embedPlugin } from '@qvac/bare-sdk/llamacpp-embedding/plugin'
// registerPlugin(llmPlugin)
// // registerPlugin(embedPlugin)
// await import('../node_modules/@qvac/bare-sdk/worker.js').catch(() => {})
export const QVAC_PEAR_WORKER = {
status: 'stub',
note: 'Install @qvac/bare-sdk and enable plugin registration above for Pear native inference.',
}
+12
View File
@@ -44,6 +44,11 @@ import {
unsubscribeAnomalies, unsubscribeAnomalies,
} from '../services/subscriptions.js' } from '../services/subscriptions.js'
import { getJobs, knownJobNames } from '../services/jobs.js' import { getJobs, knownJobNames } from '../services/jobs.js'
import {
getHostSnapshot,
searchCharts,
summarizeChart,
} from '../services/ai-tools.js'
import { import {
getRetentionConfig, getRetentionConfig,
setRetentionConfig, setRetentionConfig,
@@ -158,6 +163,13 @@ export function registerMonitorHandlers(session) {
session.respond('queryData', async (args) => store.query(args), { hot: true }) session.respond('queryData', async (args) => store.query(args), { hot: true })
session.respond('getWeights', async (args) => computeWeights(args || {}), { hot: true }) session.respond('getWeights', async (args) => computeWeights(args || {}), { hot: true })
// QVAC / AI tool-friendly composites (viewer)
session.respond('getHostSnapshot', async () => getHostSnapshot(), { hot: true })
session.respond('searchCharts', async (args) => searchCharts(args || {}), { hot: true })
session.respond('summarizeChart', async (args) => summarizeChart(args || {}), {
hot: true,
})
session.respond('queryLogs', async (args) => session.respond('queryLogs', async (args) =>
queryLogs({ ...(args || {}), role: session.role }) queryLogs({ ...(args || {}), role: session.role })
) )
+27
View File
@@ -32,6 +32,11 @@ import { isSwarmEnabled } from '../db/replicate.js'
import { listRemoteDbs } from '../db/remote.js' import { listRemoteDbs } from '../db/remote.js'
import { getParentCollector, isParentEnabled } from '../services/collectors/parent.js' import { getParentCollector, isParentEnabled } from '../services/collectors/parent.js'
import { getRestTunnelInfo } from '../services/rest-tunnel.js' import { getRestTunnelInfo } from '../services/rest-tunnel.js'
import {
getHostSnapshot,
searchCharts,
summarizeChart,
} from '../services/ai-tools.js'
/** /**
* @param {string} pathname * @param {string} pathname
@@ -374,6 +379,28 @@ export async function handleRest(pathname, query) {
return json({ path: pathNodes }) return json({ path: pathNodes })
} }
// ── AI / QVAC tool helpers ───────────────────────────────
if (path === '/api/v3/ai/snapshot') {
return json(await getHostSnapshot())
}
if (path === '/api/v3/ai/charts') {
return json(searchCharts({ q: query.get('q') || '', limit: Number(query.get('limit')) || 30 }))
}
{
const m = path.match(/^\/api\/v3\/ai\/chart\/([^/]+)\/summary$/)
if (m) {
const chart = decodeURIComponent(m[1])
return json(
await summarizeChart({
chart,
after: query.get('after') != null ? Number(query.get('after')) : undefined,
points: query.get('points') != null ? Number(query.get('points')) : undefined,
group: query.get('group') || 'average',
})
)
}
}
// ── health / root ──────────────────────────────────────── // ── health / root ────────────────────────────────────────
if (path === '/api/v1/health' || path === '/health' || path === '/api/v3/health') { if (path === '/api/v1/health' || path === '/health' || path === '/api/v3/health') {
return json(getAnomalyEngine().getHealth()) return json(getAnomalyEngine().getHealth())
+296
View File
@@ -0,0 +1,296 @@
/**
* Tool-friendly composite reads for QVAC / local AI copilots.
* Keep these lean no LLM, no model load on the agent.
*/
import os from 'os'
import { APP_VERSION } from '../../shared/protocol.js'
import { CHART_BY_ID, chartSummary } from '../../shared/metrics.js'
import { getStore } from './store.js'
import { getCollector } from './collector.js'
import { getAnomalyEngine } from './anomaly.js'
import { listAlerts } from './alerts.js'
import { getStorageInfo, getRetentionConfig } from './retention.js'
import { getServerPublicKeyHex } from '../core/auth-keys.js'
const KPI_CHARTS = [
{ id: 'system.cpu', dim: 'user', label: 'cpu_user', alt: ['used'] },
{ id: 'system.ram', dim: 'used', label: 'ram_used' },
{ id: 'system.load', dim: 'load1', label: 'load1' },
{ id: 'system.net', dim: 'received', label: 'net_rx' },
{ id: 'system.io', dim: 'reads', label: 'io_reads', alt: ['in'] },
]
/**
* Latest value for a chart dimension from hot memory (no HyperDB).
* @param {string} chartId
* @param {string} dim
* @param {string[]} [alts]
*/
function latestDim(chartId, dim, alts = []) {
const store = getStore()
const entry = store.series?.get?.(chartId)
const pts = entry?.points
if (!pts?.length) return null
const last = pts[pts.length - 1]
const vals = last?.values || {}
if (vals[dim] != null && Number.isFinite(Number(vals[dim]))) {
return { value: Number(vals[dim]), ts: last.ts, dim }
}
for (const a of alts) {
if (vals[a] != null && Number.isFinite(Number(vals[a]))) {
return { value: Number(vals[a]), ts: last.ts, dim: a }
}
}
// first finite numeric
for (const [k, v] of Object.entries(vals)) {
if (v != null && Number.isFinite(Number(v))) {
return { value: Number(v), ts: last.ts, dim: k }
}
}
return null
}
/**
* Compact host snapshot for AI tools.
* @returns {Promise<object>}
*/
export async function getHostSnapshot() {
const store = getStore()
const collector = getCollector()
const anomalies = getAnomalyEngine()
const health = anomalies.getHealth()
const recent = anomalies.listRecent?.(20) || []
const openAlerts = (listAlerts() || []).filter(
(a) => a && !a.acked && !a.silenced && a.severity !== 'cleared'
)
/** @type {Record<string, { value: number, ts: number, dim: string, chart: string }|null>} */
const kpis = {}
for (const k of KPI_CHARTS) {
const hit = latestDim(k.id, k.dim, k.alt || [])
kpis[k.label] = hit
? { value: hit.value, ts: hit.ts, dim: hit.dim, chart: k.id }
: null
}
// Derived CPU used ≈ 100 - idle when available
const idle = latestDim('system.cpu', 'idle')
if (idle && kpis.cpu_user == null) {
kpis.cpu_used = {
value: Math.max(0, Math.min(100, 100 - idle.value)),
ts: idle.ts,
dim: 'used_est',
chart: 'system.cpu',
}
} else if (idle) {
kpis.cpu_used = {
value: Math.max(0, Math.min(100, 100 - idle.value)),
ts: idle.ts,
dim: 'used_est',
chart: 'system.cpu',
}
}
let storage = null
let retention = null
try {
storage = await getStorageInfo()
} catch {
storage = null
}
try {
retention = getRetentionConfig()
} catch {
retention = null
}
const mem = store.memoryStats?.() || {}
const charts = store.listChartSummaries?.() || {}
return {
ts: Date.now(),
hostname: os.hostname(),
platform: os.platform(),
release: os.release(),
cores: os.cpus()?.length || 0,
totalRamBytes: os.totalmem(),
freeRamBytes: os.freemem(),
agentVersion: APP_VERSION,
publicKeyHex: getServerPublicKeyHex(),
sampleCount: collector?.sampleCount ?? 0,
health,
kpis,
anomalies: recent.slice(0, 12).map(compactAnomaly),
alerts: openAlerts.slice(0, 12).map(compactAlert),
catalog: {
chartCount: Object.keys(charts).length,
memory: {
charts: mem.charts ?? 0,
tier0Points: mem.tier0Points ?? 0,
tier1Points: mem.tier1Points ?? 0,
},
},
storage: storage
? {
dataDir: storage.dataDir,
usage: storage.usage,
memory: storage.memory,
}
: null,
retention: retention || null,
}
}
/**
* Search chart catalog by free text.
* @param {{ q?: string, limit?: number }} args
*/
export function searchCharts(args = {}) {
const q = String(args.q || '')
.trim()
.toLowerCase()
const limit = Math.min(100, Math.max(1, Number(args.limit) || 30))
const store = getStore()
const charts = store.listChartSummaries?.() || {}
/** @type {Array<object>} */
const rows = []
for (const [id, meta] of Object.entries(charts)) {
const hay = [
id,
meta.title,
meta.context,
meta.family,
meta.plugin,
meta.units,
...(Array.isArray(meta.dimensions)
? meta.dimensions.map((d) => (typeof d === 'string' ? d : d?.id || d?.name || ''))
: []),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
if (!q || hay.includes(q)) {
rows.push({
id,
title: meta.title || id,
context: meta.context || '',
family: meta.family || '',
units: meta.units || '',
plugin: meta.plugin || '',
chartType: meta.chartType || meta.chart_type || 'line',
})
}
}
// Prefer exact id / title prefix matches
rows.sort((a, b) => {
if (!q) return a.id.localeCompare(b.id)
const as = scoreMatch(a, q)
const bs = scoreMatch(b, q)
if (as !== bs) return bs - as
return a.id.localeCompare(b.id)
})
return {
q,
count: rows.length,
results: rows.slice(0, limit),
}
}
function scoreMatch(row, q) {
let s = 0
if (row.id === q) s += 100
if (row.id.startsWith(q)) s += 40
if (row.id.includes(q)) s += 20
if ((row.title || '').toLowerCase().includes(q)) s += 15
if ((row.context || '').toLowerCase().includes(q)) s += 8
if ((row.family || '').toLowerCase().includes(q)) s += 5
return s
}
/**
* Windowed summary for one chart.
* @param {{ chart?: string, id?: string, after?: number, points?: number, group?: string }} args
*/
export async function summarizeChart(args = {}) {
const chart = String(args.chart || args.id || '')
if (!chart) return { error: 'chart required' }
const store = getStore()
const def = CHART_BY_ID.get(chart)
const meta = store.getMeta?.(chart) || (def ? chartSummary(def) : null)
if (!meta && !def) return { error: 'unknown chart', chart }
const points = Math.min(600, Math.max(10, Number(args.points) || 90))
const after = args.after != null ? Number(args.after) : -Math.min(points, 300)
const q = await store.query({
chart,
after,
points,
group: args.group || 'average',
})
if (q.error) return q
const labels = Array.isArray(q.labels) ? q.labels.filter((l) => l && l !== 'time') : []
const data = Array.isArray(q.data) ? q.data : []
/** @type {Record<string, { min: number, max: number, avg: number, last: number, n: number }>} */
const dims = {}
for (let di = 0; di < labels.length; di++) {
const name = labels[di]
let min = Infinity
let max = -Infinity
let sum = 0
let n = 0
let last = 0
for (const row of data) {
const v = Number(row[di + 1])
if (!Number.isFinite(v)) continue
min = Math.min(min, v)
max = Math.max(max, v)
sum += v
n++
last = v
}
if (n) {
dims[name] = { min, max, avg: sum / n, last, n }
}
}
const anomalies = getAnomalyEngine()
const thr = anomalies.getThreshold?.(chart) || anomalies.thresholds?.get?.(chart) || null
const recentForChart = (anomalies.listRecent?.(50) || []).filter((a) => a.chart === chart)
return {
chart,
meta: meta || chartSummary(def),
source: q.source || 'memory',
points: data.length,
after,
dims,
latestTs: data.length ? data[data.length - 1][0] : null,
threshold: thr,
recentAnomalies: recentForChart.slice(0, 5).map(compactAnomaly),
}
}
function compactAnomaly(a) {
if (!a) return a
return {
chart: a.chart,
severity: a.severity,
message: a.message,
score: a.score,
ts: a.ts,
cleared: Boolean(a.cleared),
}
}
function compactAlert(a) {
if (!a) return a
return {
id: a.id,
chart: a.chart,
severity: a.severity,
message: a.message || a.name,
ts: a.ts,
silenced: Boolean(a.silenced),
}
}
+8
View File
@@ -62,6 +62,11 @@ export const MethodRoles = Object.freeze({
getAllMetrics: Roles.viewer, getAllMetrics: Roles.viewer,
getWeights: Roles.viewer, getWeights: Roles.viewer,
// AI / QVAC tool-friendly composites (viewer read)
getHostSnapshot: Roles.viewer,
searchCharts: Roles.viewer,
summarizeChart: Roles.viewer,
// live subscription control // live subscription control
subscribeMetrics: Roles.viewer, subscribeMetrics: Roles.viewer,
unsubscribeMetrics: Roles.viewer, unsubscribeMetrics: Roles.viewer,
@@ -151,5 +156,8 @@ export const HotMethods = Object.freeze(
Methods.unsubscribeMetrics, Methods.unsubscribeMetrics,
Methods.getAllMetrics, Methods.getAllMetrics,
Methods.listProcesses, Methods.listProcesses,
Methods.getHostSnapshot,
Methods.searchCharts,
Methods.summarizeChart,
]) ])
) )
+36
View File
@@ -32,8 +32,44 @@ export function validateMethodArgs(method, args = {}) {
case 'listJobs': case 'listJobs':
case 'listPeers': case 'listPeers':
case 'getWeights': case 'getWeights':
case 'getHostSnapshot':
return { ok: true, args } return { ok: true, args }
case 'searchCharts': {
const limit = args.limit == null ? 30 : Number(args.limit)
if (!Number.isFinite(limit) || limit < 1 || limit > 100) {
return { ok: false, error: 'limit must be 1..100' }
}
return {
ok: true,
args: {
...args,
q: args.q != null ? String(args.q) : '',
limit,
},
}
}
case 'summarizeChart': {
const chart = String(args.chart || args.id || '').trim()
if (!chart) return { ok: false, error: 'chart or id is required' }
const points = args.points == null ? 90 : Number(args.points)
if (!Number.isFinite(points) || points < 1 || points > 600) {
return { ok: false, error: 'points must be 1..600' }
}
return {
ok: true,
args: {
...args,
chart,
id: chart,
points,
after: args.after != null ? Number(args.after) : -Math.min(points, 300),
group: String(args.group || 'average'),
},
}
}
case 'queryLogs': { case 'queryLogs': {
const source = String(args.source || 'journal').toLowerCase() const source = String(args.source || 'journal').toLowerCase()
if (!['anomaly', 'audit', 'journal'].includes(source)) { if (!['anomaly', 'audit', 'journal'].includes(source)) {
+151
View File
@@ -0,0 +1,151 @@
import test from 'brittle'
import { Methods, MethodRoles, Roles } from '../shared/protocol.js'
import { validateMethodArgs } from '../shared/schema.js'
import { suggestProfile, getProfile, QVAC_PROFILES } from '../ui/qvac/profiles.js'
import { buildSystemPrompt } from '../ui/qvac/prompts.js'
import { buildRagContext, retrieve, catalogDocs, GUIDE_DOCS } from '../ui/qvac/rag.js'
import { getStore } from '../server/services/store.js'
import { searchCharts, summarizeChart, getHostSnapshot } from '../server/services/ai-tools.js'
import { initAuthKeys } from '../server/core/auth-keys.js'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
const seed = crypto.randomBytes(32)
initAuthKeys({
seedHex: b4a.toString(seed, 'hex'),
publicKeyHex: b4a.toString(crypto.keyPair(seed).publicKey, 'hex'),
})
test('AI composite methods are viewer role', (t) => {
for (const m of ['getHostSnapshot', 'searchCharts', 'summarizeChart']) {
t.is(MethodRoles[m], Roles.viewer, m)
t.is(Methods[m], m)
}
})
test('schema validates searchCharts + summarizeChart', (t) => {
t.ok(validateMethodArgs('getHostSnapshot', {}).ok)
const s = validateMethodArgs('searchCharts', { q: 'cpu', limit: 10 })
t.ok(s.ok)
t.is(s.args.q, 'cpu')
const bad = validateMethodArgs('searchCharts', { limit: 999 })
t.absent(bad.ok)
const sum = validateMethodArgs('summarizeChart', { chart: 'system.cpu', points: 60 })
t.ok(sum.ok)
t.is(sum.args.chart, 'system.cpu')
const miss = validateMethodArgs('summarizeChart', {})
t.absent(miss.ok)
})
test('QVAC profiles suggest by RAM', (t) => {
t.is(suggestProfile({ totalRamBytes: 4e9 }), 'lite')
t.is(suggestProfile({ totalRamBytes: 10e9 }), 'recommended')
t.is(suggestProfile({ totalRamBytes: 32e9 }), 'strong')
t.ok(getProfile('recommended').tools)
t.ok(QVAC_PROFILES.lite.chatModel.includes('QWEN'))
})
test('system prompt forbids inventing metrics', (t) => {
const p = buildSystemPrompt({ peerAlias: 'lab', role: 'viewer', connected: true })
t.ok(p.includes('Never invent'))
t.ok(p.includes('lab'))
t.ok(p.includes('viewer'))
})
test('RAG retrieves guide + catalog', (t) => {
t.ok(GUIDE_DOCS.length >= 5)
const hits = retrieve('charts time presets pin', GUIDE_DOCS, 3)
t.ok(hits.length >= 1)
t.ok(hits[0].score > 0)
const cat = catalogDocs({
'system.cpu': { title: 'CPU', context: 'system.cpu', family: 'cpu', units: '%' },
'disk_io.sda': { title: 'Disk sda', context: 'disk', family: 'io' },
})
t.is(cat.length, 2)
const ctx = buildRagContext({ query: 'disk io', catalog: { 'disk_io.sda': { title: 'Disk' } } })
t.ok(ctx.includes('disk') || ctx.includes('Disk') || ctx.includes('local knowledge'))
})
test('searchCharts and summarizeChart against store', async (t) => {
const now = Date.now()
for (let i = 0; i < 8; i++) {
getStore().ingest([
{
chart: 'system.cpu',
context: 'system.cpu',
ts: now - (8 - i) * 1000,
values: {
user: 10 + i,
system: 2,
nice: 0,
iowait: 0,
irq: 0,
softirq: 0,
idle: 80 - i,
},
},
])
}
const found = searchCharts({ q: 'cpu', limit: 20 })
t.ok(found.results.some((r) => r.id === 'system.cpu'))
const sum = await summarizeChart({ chart: 'system.cpu', after: -30, points: 20 })
t.is(sum.chart, 'system.cpu')
t.ok(sum.dims)
const snap = await getHostSnapshot()
t.ok(snap.hostname)
t.ok(snap.kpis)
})
test('tool runner gates offline + silence role', async (t) => {
const { createToolRunner, fallbackComplete, TOOL_DEFS } = await import('../ui/qvac/tools.js')
t.ok(TOOL_DEFS.some((d) => d.function.name === 'host_snapshot'))
t.ok(TOOL_DEFS.some((d) => d.function.name === 'fleet_health'))
let connected = false
/** @type {string} */
let role = 'viewer'
const calls = []
const tools = createToolRunner({
manager: {
request: async (m, a) => {
calls.push({ m, a })
if (m === 'getHostSnapshot') return { hostname: 'lab', health: { status: 'ok' }, kpis: {} }
if (m === 'listAnomalies') return { anomalies: [] }
return { ok: true }
},
active: null,
},
getRole: () => role,
isConnected: () => connected,
getCatalog: () => ({ 'system.cpu': { title: 'CPU' } }),
})
const offline = await tools.run('host_snapshot', {})
t.ok(offline.error)
t.ok(String(offline.error).toLowerCase().includes('connect'))
connected = true
const snap = await tools.run('host_snapshot', {})
t.is(snap.hostname, 'lab')
const silence = await tools.run('silence_alert', { id: 'a1', confirmed: true })
t.ok(String(silence.error || '').includes('Operator') || silence.error)
role = 'operator'
const silence2 = await tools.run('silence_alert', { id: 'a1', confirmed: false })
t.is(silence2.error, 'confirmation_required')
const defsViewer = (() => {
role = 'viewer'
return tools.defsForRole().map((d) => d.function.name)
})()
t.absent(defsViewer.includes('silence_alert'))
const kn = await tools.run('local_knowledge', { q: 'charts' })
t.ok(kn.context)
const fb = await fallbackComplete('any open alerts?', tools)
t.is(fb.mode, 'fallback')
t.ok(fb.contentText)
t.ok((fb.toolCalls || []).length >= 1)
})
+3
View File
@@ -46,6 +46,9 @@ test('method roles cover monitoring surface', (t) => {
'getRetentionConfig', 'getRetentionConfig',
'setRetentionConfig', 'setRetentionConfig',
'pruneHistory', 'pruneHistory',
'getHostSnapshot',
'searchCharts',
'summarizeChart',
]) { ]) {
t.ok(MethodRoles[m], m) t.ok(MethodRoles[m], m)
t.is(Methods[m], m) t.is(Methods[m], m)
+39
View File
@@ -77,3 +77,42 @@ test('REST 404', async (t) => {
const res = await handleRest('/api/v9/nope', new URLSearchParams()) const res = await handleRest('/api/v9/nope', new URLSearchParams())
t.is(res.status, 404) t.is(res.status, 404)
}) })
test('REST /api/v3/ai/snapshot', async (t) => {
const res = await handleRest('/api/v3/ai/snapshot', new URLSearchParams())
t.is(res.status, 200)
t.ok(res.body.hostname)
t.ok(res.body.kpis)
t.ok(res.body.catalog)
})
test('REST /api/v3/ai/charts', async (t) => {
const res = await handleRest(
'/api/v3/ai/charts',
new URLSearchParams({ q: 'cpu', limit: '10' })
)
t.is(res.status, 200)
t.ok(Array.isArray(res.body.results))
t.ok(res.body.results.some((r) => String(r.id).includes('cpu')))
})
test('REST /api/v3/ai/chart summary', async (t) => {
const now = Date.now()
for (let i = 0; i < 5; i++) {
getStore().ingest([
{
chart: 'system.load',
context: 'system.load',
ts: now - (5 - i) * 1000,
values: { load1: i * 0.1, load5: 0.2, load15: 0.1 },
},
])
}
const res = await handleRest(
'/api/v3/ai/chart/system.load/summary',
new URLSearchParams({ after: '-30', points: '10' })
)
t.is(res.status, 200)
t.is(res.body.chart, 'system.load')
t.ok(res.body.dims?.load1 || res.body.points >= 0)
})
+331
View File
@@ -0,0 +1,331 @@
/**
* QVAC engine facade load models, stream completion, tool loop.
* Uses @qvac/sdk when available; otherwise tools-only fallback.
*/
import { getProfile } from './profiles.js'
import { buildSystemPrompt } from './prompts.js'
import { fallbackComplete } from './tools.js'
import { buildRagContext } from './rag.js'
/**
* @param {{
* tools: { run: Function, defsForRole: () => any[] },
* getContext: () => object,
* getCatalog?: () => Record<string, object>,
* getPrefs?: () => { rag?: boolean, idleUnloadMin?: number },
* log?: (msg: string) => void,
* }} deps
*/
export function createQvacEngine(deps) {
/** @type {any} */
let sdk = null
let sdkError = null
/** @type {string|null} */
let modelId = null
/** @type {string|null} */
let profileId = null
/** @type {'idle'|'checking'|'downloading'|'loading'|'ready'|'error'|'fallback'} */
let status = 'idle'
let lastProgress = null
/** @type {AbortController|null} */
let loadAbort = null
/** @type {ReturnType<typeof setTimeout>|null} */
let idleTimer = null
let lastActivity = Date.now()
function touchActivity() {
lastActivity = Date.now()
scheduleIdleUnload()
}
function scheduleIdleUnload() {
if (idleTimer) clearTimeout(idleTimer)
idleTimer = null
const mins = Number(deps.getPrefs?.()?.idleUnloadMin)
if (!mins || mins <= 0 || !modelId) return
idleTimer = setTimeout(
() => {
if (Date.now() - lastActivity >= mins * 60_000 && modelId) {
deps.log?.(`QVAC idle unload after ${mins}m`)
unload().catch(() => {})
}
},
mins * 60_000 + 500
)
}
async function tryLoadSdk() {
if (sdk) return sdk
try {
sdk = await import('@qvac/sdk')
sdkError = null
return sdk
} catch (err) {
sdkError = err?.message || String(err)
sdk = null
return null
}
}
function getStatus() {
return {
status,
modelId,
profileId,
sdkAvailable: Boolean(sdk) && !sdkError,
sdkError,
progress: lastProgress,
mode: sdk && modelId ? 'qvac' : status === 'fallback' || !sdk ? 'fallback' : status,
lastActivity,
}
}
/**
* Lightweight host check without full qvac doctor.
*/
async function checkEnvironment() {
status = 'checking'
const mem =
typeof performance !== 'undefined' && performance.memory
? performance.memory.jsHeapSizeLimit
: null
let totalRamBytes = null
let freeRamBytes = null
try {
const os = await import('os')
totalRamBytes = os.totalmem?.()
freeRamBytes = os.freemem?.()
} catch {
// browser/Pear without os
}
const s = await tryLoadSdk()
let resources = null
if (s?.getSystemResources) {
try {
resources = await s.getSystemResources({ sample: false })
} catch {
// ignore
}
}
status = 'idle'
return {
sdkAvailable: Boolean(s),
sdkError,
totalRamBytes: totalRamBytes ?? mem,
freeRamBytes,
resources,
platform: typeof process !== 'undefined' ? process.platform : 'unknown',
arch: typeof process !== 'undefined' ? process.arch : 'unknown',
}
}
/**
* @param {string} profile
* @param {{ onProgress?: (p: any) => void }} [opts]
*/
async function loadProfile(profile, opts = {}) {
const p = getProfile(profile)
profileId = p.id
touchActivity()
const s = await tryLoadSdk()
if (!s) {
status = 'fallback'
deps.log?.(`QVAC SDK unavailable (${sdkError}); using tools-only fallback`)
return { ok: true, mode: 'fallback', profile: p.id }
}
const modelSrc = s[p.chatModel] || p.chatModel
status = 'downloading'
lastProgress = { percentage: 0 }
loadAbort = new AbortController()
try {
// Unload previous model first
if (modelId) await unload()
const id = await s.loadModel({
modelSrc,
modelType: 'llm',
modelConfig: {
tools: p.tools,
ctx_size: 4096,
},
onProgress: (prog) => {
lastProgress = prog
status = prog?.percentage >= 100 ? 'loading' : 'downloading'
opts.onProgress?.(prog)
},
})
modelId = id
status = 'ready'
touchActivity()
deps.log?.(`QVAC model ready: ${p.chatModel}${id}`)
return { ok: true, mode: 'qvac', modelId: id, profile: p.id }
} catch (err) {
status = 'error'
const msg = err?.message || String(err)
deps.log?.(`QVAC load failed: ${msg}`)
status = 'fallback'
return { ok: false, error: msg, mode: 'fallback', profile: p.id }
} finally {
loadAbort = null
}
}
async function unload() {
if (idleTimer) {
clearTimeout(idleTimer)
idleTimer = null
}
if (sdk && modelId && sdk.unloadModel) {
try {
await sdk.unloadModel({ modelId })
} catch {
// ignore
}
}
modelId = null
if (status === 'ready' || status === 'loading' || status === 'downloading') status = 'idle'
}
/**
* @param {Array<{ role: string, content: string }>} history
* @param {{
* onToken?: (t: string) => void,
* onEvent?: (e: any) => void,
* onTool?: (name: string, args: object, result: any) => void,
* }} [opts]
*/
async function complete(history, opts = {}) {
touchActivity()
const profile = getProfile(profileId || 'recommended')
const prefs = deps.getPrefs?.() || {}
const userLast = [...history].reverse().find((m) => m.role === 'user')
let system = buildSystemPrompt(deps.getContext?.() || {})
if (prefs.rag !== false && userLast?.content) {
const rag = buildRagContext({
query: userLast.content,
catalog: deps.getCatalog?.() || {},
topK: 5,
})
if (rag) system += `\n\n${rag}`
}
const fullHistory = [
{ role: 'system', content: system },
...history.filter((m) => m.role !== 'system'),
]
if (sdk && modelId && sdk.completion) {
return completeWithSdk(fullHistory, profile, opts)
}
status = status === 'ready' ? status : 'fallback'
const result = await fallbackComplete(userLast?.content || '', deps.tools, {
catalog: deps.getCatalog?.() || {},
rag: prefs.rag !== false,
})
for (const c of result.toolCalls || []) {
opts.onTool?.(c.name, c.args, c.result)
}
if (opts.onToken) {
const text = result.contentText || ''
const chunk = 24
for (let i = 0; i < text.length; i += chunk) {
opts.onToken(text.slice(i, i + chunk))
}
}
opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' })
return result
}
async function completeWithSdk(history, profile, opts) {
const toolDefs = profile.tools ? deps.tools.defsForRole() : undefined
let messages = history.map((m) => ({ role: m.role, content: m.content }))
for (let round = 0; round < 4; round++) {
touchActivity()
const run = sdk.completion({
modelId,
history: messages,
stream: true,
tools: toolDefs,
captureThinking: false,
})
let content = ''
/** @type {Array<{ name: string, arguments?: any, id?: string }>} */
const toolCalls = []
if (run.events) {
for await (const ev of run.events) {
opts.onEvent?.(ev)
if (ev.type === 'contentDelta' && ev.text) {
content += ev.text
opts.onToken?.(ev.text)
} else if (ev.type === 'toolCall') {
toolCalls.push({
name: ev.name || ev.toolCall?.name,
arguments: ev.arguments || ev.toolCall?.arguments || {},
id: ev.id || ev.toolCall?.id,
})
}
}
} else if (run.tokenStream) {
for await (const token of run.tokenStream) {
content += token
opts.onToken?.(token)
}
}
const final = run.final ? await run.final : { contentText: content, toolCalls }
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
if (!calls.length) {
return {
contentText: final.contentText || content,
toolCalls: [],
mode: 'qvac',
stats: final.stats,
}
}
messages = [...messages, { role: 'assistant', content: final.contentText || content || '' }]
for (const tc of calls) {
const name = tc.name || tc.function?.name
let args = tc.arguments || tc.function?.arguments || {}
if (typeof args === 'string') {
try {
args = JSON.parse(args)
} catch {
args = {}
}
}
const result = await deps.tools.run(name, args)
opts.onTool?.(name, args, result)
messages.push({
role: 'tool',
content: JSON.stringify(result).slice(0, 12_000),
name,
})
}
}
return {
contentText: 'Tool loop limit reached. Try a more specific question.',
toolCalls: [],
mode: 'qvac',
}
}
return {
checkEnvironment,
loadProfile,
unload,
complete,
getStatus,
tryLoadSdk,
touchActivity,
}
}
+499
View File
@@ -0,0 +1,499 @@
/**
* QVAC tab onboarding + local AI chat for PearData.
*/
import { createQvacEngine } from './engine.js'
import { createToolRunner } from './tools.js'
import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
import { SAMPLE_PROMPTS } from './prompts.js'
/**
* @param {{
* els: {
* root: HTMLElement|null,
* setup: HTMLElement|null,
* chat: HTMLElement|null,
* messages: HTMLElement|null,
* input: HTMLTextAreaElement|null,
* sendBtn: HTMLElement|null,
* status: HTMLElement|null,
* modelChip: HTMLElement|null,
* samples: HTMLElement|null,
* setupSteps: HTMLElement|null,
* resetBtn?: HTMLElement|null,
* unloadBtn?: HTMLElement|null,
* newChatBtn?: HTMLElement|null,
* },
* manager: { request: Function, active: any },
* getRole: () => string,
* isConnected: () => boolean,
* getSettings: () => object,
* saveSettings: (patch: object) => void,
* getPeerLabel: () => string,
* getCatalog?: () => Record<string, object>,
* onOpenChart?: (id: string, ts?: number) => void,
* onOpenView?: (view: string) => void,
* log?: (msg: string) => void,
* }} opts
*/
export function createQvacView(opts) {
function settings() {
return opts.getSettings?.() || {}
}
function persist(patch) {
opts.saveSettings?.(patch)
}
const tools = createToolRunner({
manager: opts.manager,
getRole: opts.getRole,
isConnected: opts.isConnected,
getCatalog: () => opts.getCatalog?.() || {},
onOpenChart: opts.onOpenChart,
onOpenView: opts.onOpenView,
confirmAction: (msg) => {
try {
return typeof confirm === 'function' ? confirm(msg) : true
} catch {
return true
}
},
})
const engine = createQvacEngine({
tools,
getContext: () => ({
peerAlias: opts.getPeerLabel?.() || '',
peerId: opts.manager.active?.publicKeyHex || '',
role: opts.getRole?.() || 'viewer',
connected: Boolean(opts.isConnected?.()),
}),
getCatalog: () => opts.getCatalog?.() || {},
getPrefs: () => ({
rag: settings().qvacRag !== false,
idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0,
}),
log: opts.log,
})
/** @type {Array<{ role: string, content: string, tools?: any[] }>} */
let messages = []
let busy = false
let wizardStep = 0
/** @type {string} */
let selectedProfile = 'recommended'
function isOnboarded() {
return Boolean(settings().qvacOnboarded)
}
function setStatus(text, kind = '') {
const el = opts.els.status
if (!el) return
el.textContent = text || ''
el.dataset.kind = kind
}
function syncModelChip() {
const chip = opts.els.modelChip
if (!chip) return
const st = engine.getStatus()
const profile = getProfile(settings().qvacProfile || selectedProfile)
if (st.mode === 'fallback' || (!st.sdkAvailable && st.status !== 'ready')) {
chip.textContent = 'tools-only'
chip.dataset.mode = 'fallback'
chip.title = st.sdkError || 'Install @qvac/sdk for full local LLM'
} else if (st.status === 'ready') {
chip.textContent = profile.chatModel
chip.dataset.mode = 'ready'
} else if (st.status === 'downloading' || st.status === 'loading') {
const pct = st.progress?.percentage
chip.textContent =
pct != null ? `${st.status} ${Number(pct).toFixed(0)}%` : st.status
chip.dataset.mode = 'busy'
} else {
chip.textContent = st.status || 'idle'
chip.dataset.mode = st.status || 'idle'
}
}
function showPane() {
const setup = opts.els.setup
const chat = opts.els.chat
if (isOnboarded()) {
setup?.classList.add('hidden')
chat?.classList.remove('hidden')
} else {
setup?.classList.remove('hidden')
chat?.classList.add('hidden')
renderWizard()
}
}
function renderWizard() {
const host = opts.els.setupSteps
if (!host) return
host.innerHTML = ''
if (wizardStep === 0) {
host.appendChild(
stepCard(
'Welcome to QVAC',
`<p>Local-first AI for PearData — models run on <strong>your desktop</strong>, not the agent and not a cloud API.</p>
<p class="muted">The assistant uses live tools (<code>getHostSnapshot</code>, charts, processes) so answers stay grounded in agent data.</p>
<p class="muted">Powered by <a href="https://github.com/tetherto/qvac" target="_blank" rel="noreferrer">QVAC</a> (Tether).</p>`,
[{ label: 'Continue', primary: true, onClick: () => { wizardStep = 1; renderWizard() } }]
)
)
return
}
if (wizardStep === 1) {
const card = stepCard('System check', '<p class="muted">Checking environment…</p>', [])
host.appendChild(card)
engine.checkEnvironment().then((env) => {
const body = card.querySelector('.qvac-step-body')
if (!body) return
const ramGb = env.totalRamBytes ? (env.totalRamBytes / 1e9).toFixed(1) : '?'
selectedProfile = settings().qvacProfile || suggestProfile({ totalRamBytes: env.totalRamBytes })
body.innerHTML = `
<ul class="qvac-check-list">
<li class="${env.sdkAvailable ? 'ok' : 'warn'}">QVAC SDK: ${env.sdkAvailable ? 'available' : 'not installed — tools-only mode'}</li>
<li class="ok">Platform: ${escapeHtml(env.platform)} / ${escapeHtml(env.arch)}</li>
<li class="ok">RAM (est.): ${ramGb} GB</li>
${env.sdkError ? `<li class="warn">SDK note: ${escapeHtml(env.sdkError)}</li>` : ''}
</ul>
<p class="muted">Suggested profile: <strong>${escapeHtml(getProfile(selectedProfile).label)}</strong></p>
${
!env.sdkAvailable
? `<p class="muted">Install with <code>npm i @qvac/sdk</code> in the PearData project for full Qwen chat. You can continue in tools-only mode now.</p>`
: ''
}`
const actions = card.querySelector('.qvac-step-actions')
if (actions) {
actions.innerHTML = ''
actions.appendChild(
btn('Back', false, () => {
wizardStep = 0
renderWizard()
})
)
actions.appendChild(
btn('Choose model', true, () => {
wizardStep = 2
renderWizard()
})
)
}
})
return
}
if (wizardStep === 2) {
const profilesHtml = PROFILE_LIST.map((p) => {
const active = p.id === selectedProfile ? ' active' : ''
return `<button type="button" class="qvac-profile-card${active}" data-profile="${p.id}">
<strong>${escapeHtml(p.label)}</strong>
<span class="muted">${escapeHtml(p.description)}</span>
<span class="qvac-profile-meta">${escapeHtml(p.chatModel)} · ~${p.approxDownloadGb} GB · tools ${p.tools ? 'on' : 'off'}</span>
</button>`
}).join('')
const card = stepCard(
'Model profile',
`<div class="qvac-profile-grid">${profilesHtml}</div>`,
[
{
label: 'Back',
onClick: () => {
wizardStep = 1
renderWizard()
},
},
{
label: 'Download & load',
primary: true,
onClick: () => {
wizardStep = 3
renderWizard()
startLoad()
},
},
]
)
host.appendChild(card)
host.querySelectorAll('[data-profile]').forEach((el) => {
el.addEventListener('click', () => {
selectedProfile = el.getAttribute('data-profile') || 'recommended'
renderWizard()
})
})
return
}
if (wizardStep === 3) {
host.appendChild(
stepCard(
'Loading model',
`<div class="qvac-progress-wrap">
<div class="qvac-progress-bar"><i id="qvac-progress-fill" style="width:0%"></i></div>
<p class="muted" id="qvac-progress-label">Starting</p>
</div>`,
[]
)
)
}
}
async function startLoad() {
const fill = opts.els.root?.querySelector('#qvac-progress-fill')
const label = opts.els.root?.querySelector('#qvac-progress-label')
persist({ qvacProfile: selectedProfile })
setStatus('Loading model…', 'busy')
const result = await engine.loadProfile(selectedProfile, {
onProgress: (p) => {
const pct = Math.min(100, Number(p?.percentage) || 0)
if (fill) fill.style.width = `${pct}%`
if (label) {
const mb = (n) => ((Number(n) || 0) / 1e6).toFixed(1)
label.textContent =
pct >= 100
? 'Loading into memory…'
: `Downloading ${pct.toFixed(0)}% (${mb(p.downloaded)} / ${mb(p.total)} MB)`
}
syncModelChip()
},
})
persist({
qvacOnboarded: true,
qvacProfile: selectedProfile,
qvacMode: result.mode || 'fallback',
})
setStatus(result.ok ? 'Ready' : `Fallback: ${result.error || 'SDK unavailable'}`, result.ok ? 'ok' : 'warn')
syncModelChip()
showPane()
renderSamples()
if (!messages.length) {
appendMsg(
'assistant',
result.mode === 'fallback'
? 'Tools-only mode is ready. Ask about host health, charts, or anomalies — answers use live agent RPCs.\n\nInstall `@qvac/sdk` and re-run setup for full local Qwen chat.'
: `Model **${getProfile(selectedProfile).chatModel}** is loaded. Ask about this agents health, metrics, or processes.`
)
}
}
function stepCard(title, bodyHtml, actions) {
const el = document.createElement('div')
el.className = 'qvac-step-card'
el.innerHTML = `<h3>${escapeHtml(title)}</h3><div class="qvac-step-body">${bodyHtml}</div><div class="qvac-step-actions"></div>`
const act = el.querySelector('.qvac-step-actions')
for (const a of actions) {
act?.appendChild(btn(a.label, a.primary, a.onClick))
}
return el
}
function btn(label, primary, onClick) {
const b = document.createElement('button')
b.type = 'button'
b.className = primary ? 'btn' : 'btn btn-ghost'
b.textContent = label
b.addEventListener('click', onClick)
return b
}
function renderSamples() {
const host = opts.els.samples
if (!host) return
host.innerHTML = ''
for (const s of SAMPLE_PROMPTS) {
const b = document.createElement('button')
b.type = 'button'
b.className = 'qvac-sample'
b.textContent = s
b.addEventListener('click', () => {
if (opts.els.input) opts.els.input.value = s
send()
})
host.appendChild(b)
}
}
function appendMsg(role, content, meta = {}) {
messages.push({ role, content, tools: meta.tools })
const list = opts.els.messages
if (!list) return
const div = document.createElement('div')
div.className = `qvac-msg qvac-msg-${role}`
const body = document.createElement('div')
body.className = 'qvac-msg-body'
body.innerHTML = formatMdLite(content)
div.appendChild(body)
if (meta.tools?.length) {
const chips = document.createElement('div')
chips.className = 'qvac-tool-chips'
for (const t of meta.tools) {
const c = document.createElement('span')
c.className = 'qvac-tool-chip'
c.textContent = t.name
c.title = JSON.stringify(t.args || {}).slice(0, 200)
chips.appendChild(c)
}
div.appendChild(chips)
}
list.appendChild(div)
list.scrollTop = list.scrollHeight
return body
}
async function send() {
const input = opts.els.input
const text = (input?.value || '').trim()
if (!text || busy) return
if (input) input.value = ''
busy = true
opts.els.sendBtn && (opts.els.sendBtn.disabled = true)
appendMsg('user', text)
const streamBody = appendMsg('assistant', '…')
const toolLog = []
let acc = ''
setStatus('Thinking…', 'busy')
try {
const hist = messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.slice(0, -1) // drop placeholder assistant
.map((m) => ({ role: m.role, content: m.content }))
hist.push({ role: 'user', content: text })
const result = await engine.complete(hist, {
onToken: (t) => {
acc += t
if (streamBody) streamBody.innerHTML = formatMdLite(acc || '…')
opts.els.messages && (opts.els.messages.scrollTop = opts.els.messages.scrollHeight)
},
onTool: (name, args, res) => {
toolLog.push({ name, args, result: res })
},
})
acc = result.contentText || acc
if (streamBody) streamBody.innerHTML = formatMdLite(acc)
// update last message in state
const last = messages[messages.length - 1]
if (last?.role === 'assistant') {
last.content = acc
last.tools = toolLog
}
if (toolLog.length && streamBody?.parentElement) {
let chips = streamBody.parentElement.querySelector('.qvac-tool-chips')
if (!chips) {
chips = document.createElement('div')
chips.className = 'qvac-tool-chips'
streamBody.parentElement.appendChild(chips)
}
chips.innerHTML = ''
for (const t of toolLog) {
const c = document.createElement('span')
c.className = 'qvac-tool-chip'
c.textContent = t.name
chips.appendChild(c)
}
}
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
} catch (err) {
const msg = err?.message || String(err)
if (streamBody) streamBody.innerHTML = formatMdLite(`Error: ${msg}`)
setStatus(msg, 'error')
} finally {
busy = false
if (opts.els.sendBtn) opts.els.sendBtn.disabled = false
syncModelChip()
}
}
function newChat() {
messages = []
if (opts.els.messages) opts.els.messages.innerHTML = ''
appendMsg(
'assistant',
'New chat. Ask about host health, metrics, anomalies, or processes.'
)
}
function resetOnboarding() {
persist({ qvacOnboarded: false, qvacMode: '' })
wizardStep = 0
engine.unload().catch(() => {})
showPane()
}
function bind() {
opts.els.sendBtn?.addEventListener('click', () => send())
opts.els.input?.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' && !ev.shiftKey) {
ev.preventDefault()
send()
}
})
opts.els.resetBtn?.addEventListener('click', () => resetOnboarding())
opts.els.unloadBtn?.addEventListener('click', async () => {
await engine.unload()
setStatus('Model unloaded', 'ok')
syncModelChip()
})
opts.els.newChatBtn?.addEventListener('click', () => newChat())
}
function enter() {
showPane()
renderSamples()
syncModelChip()
if (isOnboarded() && !messages.length) {
// Auto warm fallback path; full model load is manual after onboarding
engine.tryLoadSdk().then(() => {
const mode = settings().qvacMode
if (mode === 'qvac' && settings().qvacProfile) {
setStatus('Loading saved model…', 'busy')
engine.loadProfile(settings().qvacProfile).then(() => {
setStatus('Ready', 'ok')
syncModelChip()
})
} else {
syncModelChip()
}
})
appendMsg(
'assistant',
'QVAC ready. Try “Summarize host health” or pick a sample prompt.'
)
}
}
bind()
return {
enter,
leave: () => {
// Keep weights on disk; optional idle unload still runs via engine timer
engine.touchActivity?.()
},
engine,
newChat,
resetOnboarding,
}
}
function formatMdLite(text) {
const esc = escapeHtml(String(text || ''))
return esc
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
+90
View File
@@ -0,0 +1,90 @@
/**
* QVAC model profiles for PearData onboarding.
* Constants match @qvac/sdk registry names (string form when SDK not loaded).
*/
/** @typedef {'lite'|'recommended'|'strong'|'tool-tiny'} QvacProfileId */
/**
* @typedef {{
* id: QvacProfileId,
* label: string,
* description: string,
* chatModel: string,
* embedModel: string|null,
* tools: boolean,
* minRamGb: number,
* minDiskGb: number,
* approxDownloadGb: number,
* }} QvacProfile
*/
/** @type {Record<QvacProfileId, QvacProfile>} */
export const QVAC_PROFILES = {
lite: {
id: 'lite',
label: 'Lite',
description: 'Smallest download. Good for weak machines; limited tool use.',
chatModel: 'QWEN3_600M_INST_Q4',
embedModel: null,
tools: false,
minRamGb: 4,
minDiskGb: 2,
approxDownloadGb: 0.5,
},
recommended: {
id: 'recommended',
label: 'Recommended',
description: 'Best balance for host monitoring with tool calling.',
chatModel: 'QWEN3_1_7B_INST_Q4',
embedModel: 'GTE_LARGE_FP16',
tools: true,
minRamGb: 8,
minDiskGb: 5,
approxDownloadGb: 2.5,
},
strong: {
id: 'strong',
label: 'Strong',
description: 'Better reasoning on incidents. Needs more RAM/disk.',
chatModel: 'QWEN3_4B_INST_Q4_K_M',
embedModel: 'GTE_LARGE_FP16',
tools: true,
minRamGb: 16,
minDiskGb: 8,
approxDownloadGb: 3.5,
},
'tool-tiny': {
id: 'tool-tiny',
label: 'Tool-tiny',
description: 'Llama tool-calling 1B fallback if Qwen tools misbehave.',
chatModel: 'LLAMA_TOOL_CALLING_1B_INST_Q4_K',
embedModel: null,
tools: true,
minRamGb: 6,
minDiskGb: 3,
approxDownloadGb: 1,
},
}
/**
* @param {{ totalRamBytes?: number, freeDiskBytes?: number }} hw
* @returns {QvacProfileId}
*/
export function suggestProfile(hw = {}) {
const ramGb = (Number(hw.totalRamBytes) || 0) / 1e9
if (ramGb >= 16) return 'strong'
if (ramGb >= 8) return 'recommended'
if (ramGb >= 4) return 'lite'
return 'lite'
}
/**
* @param {QvacProfileId|string} id
* @returns {QvacProfile}
*/
export function getProfile(id) {
return QVAC_PROFILES[id] || QVAC_PROFILES.recommended
}
export const PROFILE_LIST = Object.values(QVAC_PROFILES)
+44
View File
@@ -0,0 +1,44 @@
/**
* System prompt builder for the PearData QVAC copilot.
*/
/**
* @param {{
* peerAlias?: string,
* peerId?: string,
* role?: string,
* connected?: boolean,
* hostname?: string,
* }} ctx
*/
export function buildSystemPrompt(ctx = {}) {
const peer = ctx.peerAlias || (ctx.peerId ? `${String(ctx.peerId).slice(0, 12)}` : 'none')
const role = ctx.role || 'viewer'
const conn = ctx.connected ? 'connected' : 'disconnected'
return [
'You are PearData QVAC, a local-only SRE copilot for a P2P host monitoring agent.',
'You run on the operator desktop; metrics come from the connected agent via tools.',
'',
'Rules:',
'- Never invent metric values, timestamps, or alert states. Use tools first.',
'- If the agent is disconnected or a tool fails, say so clearly.',
'- Prefer short, operational answers: severity, numbers, chart ids, next steps.',
'- 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.',
'- Do not claim cloud access; all inference is local via QVAC.',
'- Use local_knowledge for product how-tos; use host_snapshot / summarize_chart for live numbers.',
'- Prefer open_chart / open_view when the user asks to see something in the UI.',
'',
`Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}.`,
].join('\n')
}
export const SAMPLE_PROMPTS = [
'Summarize host health right now',
'Why might CPU be high?',
'List charts related to disk or io',
'Any open alerts or anomalies?',
'Top processes by CPU if available',
'How do Charts time presets work?',
'What is retention / warm history?',
]
+139
View File
@@ -0,0 +1,139 @@
/**
* Lightweight local RAG for QVAC no native embeddings required.
* Keyword retrieval over guide snippets + live chart catalog.
*/
/** Built-in operator knowledge (subset of user-guide). */
const GUIDE_DOCS = [
{
id: 'guide:connect',
title: 'Connect',
text: 'Connect to a PearData agent with public key or pd1 invite. Viewer vs operator vs admin roles. Saved bookmarks restore agents. Active peer is used by all tools.',
},
{
id: 'guide:charts',
title: 'Charts',
text: 'Metrics wall with sections TOC, play pause, time presets 1m 5m 15m 1h 6h, pin board, correlate brush, related metrics, chart types line area stacked bar pie, dimension legend.',
},
{
id: 'guide:alerts',
title: 'Alerts',
text: 'Anomaly list with severity warning critical, Show jumps to chart, Correlate opens metric correlations around event time, silence TTL re-enables.',
},
{
id: 'guide:processes',
title: 'Processes',
text: 'Live process table from /proc when PEARDATA_PROCESSES=1, sort by cpu rss, tree view, CSV export, open top process charts.',
},
{
id: 'guide:logs',
title: 'Logs',
text: 'System logs journal default, anomalies source, audit admin, follow stream, search unit priority.',
},
{
id: 'guide:retention',
title: 'Data retention',
text: 'Settings Data Manager hot tier0 warm tier1 HyperDB prune age disk budget. Longer chart windows may be sparse until samples accumulate.',
},
{
id: 'guide:qvac',
title: 'QVAC',
text: 'Local AI copilot on desktop. Tools call agent RPC never invent metrics. Profiles lite recommended strong. Install @qvac/sdk for full LLM else tools-only fallback.',
},
{
id: 'guide:fleet',
title: 'Fleet',
text: 'Multi-host roster set active reconnect forget. Parent collector aggregates child getHealth. getFleetHealth RPC.',
},
{
id: 'guide:kpis',
title: 'Core KPIs',
text: 'system.cpu user system idle iowait, system.ram used, system.load load1, system.net received sent, system.io reads writes, mem.available.',
},
]
/**
* @param {string} text
* @returns {string[]}
*/
function tokens(text) {
return String(text || '')
.toLowerCase()
.split(/[^a-z0-9._-]+/)
.filter((t) => t.length > 1)
}
/**
* Build docs from chart catalog map.
* @param {Record<string, object>} catalog
* @param {number} [cap]
*/
export function catalogDocs(catalog, cap = 400) {
/** @type {Array<{ id: string, title: string, text: string }>} */
const docs = []
for (const [id, meta] of Object.entries(catalog || {})) {
if (docs.length >= cap) break
const dims = Array.isArray(meta.dimensions)
? meta.dimensions
.map((d) => (typeof d === 'string' ? d : d?.id || d?.name || ''))
.filter(Boolean)
.join(' ')
: ''
docs.push({
id: `chart:${id}`,
title: meta.title || id,
text: [id, meta.title, meta.context, meta.family, meta.plugin, meta.units, dims]
.filter(Boolean)
.join(' '),
})
}
return docs
}
/**
* @param {string} query
* @param {Array<{ id: string, title: string, text: string }>} docs
* @param {number} [topK]
*/
export function retrieve(query, docs, topK = 6) {
const qToks = tokens(query)
if (!qToks.length || !docs?.length) return []
/** @type {Array<{ id: string, title: string, text: string, score: number }>} */
const scored = []
for (const d of docs) {
const hay = tokens(`${d.title} ${d.text}`)
const set = new Set(hay)
let score = 0
for (const t of qToks) {
if (set.has(t)) score += 2
else if (hay.some((h) => h.includes(t) || t.includes(h))) score += 1
}
if (d.id.startsWith('chart:') && qToks.some((t) => d.id.includes(t))) score += 3
if (score > 0) scored.push({ ...d, score })
}
scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
return scored.slice(0, topK)
}
/**
* @param {{
* query: string,
* catalog?: Record<string, object>,
* topK?: number,
* includeGuide?: boolean,
* }} opts
*/
export function buildRagContext(opts) {
const docs = []
if (opts.includeGuide !== false) docs.push(...GUIDE_DOCS)
if (opts.catalog) docs.push(...catalogDocs(opts.catalog))
const hits = retrieve(opts.query, docs, opts.topK ?? 6)
if (!hits.length) return ''
const lines = hits.map((h) => {
const snippet = h.text.length > 180 ? h.text.slice(0, 177) + '…' : h.text
return `- [${h.id}] ${h.title}: ${snippet}`
})
return `Relevant local knowledge (do not invent beyond tools + this context):\n${lines.join('\n')}`
}
export { GUIDE_DOCS }
+513
View File
@@ -0,0 +1,513 @@
/**
* QVAC tool schemas + handlers PearData RPC / UI navigation.
*/
import { Methods, Roles, roleAllows } from '../../shared/protocol.js'
import { buildRagContext } from './rag.js'
/**
* OpenAI-style tool definitions for QVAC completion({ tools }).
*/
export const TOOL_DEFS = [
{
type: 'function',
function: {
name: 'host_snapshot',
description:
'Get a compact live snapshot: health, KPIs (cpu/ram/load/net/io), recent anomalies/alerts, catalog size.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
},
},
{
type: 'function',
function: {
name: 'search_charts',
description: 'Search the metrics chart catalog by free text (id, title, context, family).',
parameters: {
type: 'object',
properties: {
q: { type: 'string', description: 'Search query' },
limit: { type: 'number', description: 'Max results (default 20)' },
},
required: ['q'],
},
},
},
{
type: 'function',
function: {
name: 'summarize_chart',
description: 'Summarize one chart: min/avg/max/last per dimension for a time window.',
parameters: {
type: 'object',
properties: {
chart: { type: 'string', description: 'Chart id e.g. system.cpu' },
after: { type: 'number', description: 'Seconds relative (e.g. -300) or absolute unix' },
points: { type: 'number', description: 'Max points (default 90)' },
},
required: ['chart'],
},
},
},
{
type: 'function',
function: {
name: 'query_metric',
description: 'Raw queryData for a chart time series.',
parameters: {
type: 'object',
properties: {
chart: { type: 'string' },
after: { type: 'number' },
points: { type: 'number' },
group: { type: 'string', enum: ['average', 'min', 'max', 'sum'] },
},
required: ['chart'],
},
},
},
{
type: 'function',
function: {
name: 'list_anomalies',
description: 'List recent anomaly events.',
parameters: {
type: 'object',
properties: { limit: { type: 'number' } },
},
},
},
{
type: 'function',
function: {
name: 'list_alerts',
description: 'List configured/open alerts on the agent.',
parameters: { type: 'object', properties: {} },
},
},
{
type: 'function',
function: {
name: 'list_processes',
description: 'Live process table (when agent enables PEARDATA_PROCESSES).',
parameters: {
type: 'object',
properties: {
sort: { type: 'string' },
limit: { type: 'number' },
filter: { type: 'string' },
},
},
},
},
{
type: 'function',
function: {
name: 'query_logs',
description: 'Query agent logs: source journal|anomaly|audit, optional free-text q.',
parameters: {
type: 'object',
properties: {
source: { type: 'string', enum: ['journal', 'anomaly', 'audit'] },
q: { type: 'string' },
limit: { type: 'number' },
},
},
},
},
{
type: 'function',
function: {
name: 'fleet_health',
description: 'Fleet / parent-child health summary when parent mode is enabled.',
parameters: { type: 'object', properties: {} },
},
},
{
type: 'function',
function: {
name: 'storage_info',
description: 'Agent storage usage and retention config.',
parameters: { type: 'object', properties: {} },
},
},
{
type: 'function',
function: {
name: 'local_knowledge',
description:
'Search local PearData operator knowledge and chart catalog (no network). Use for product how-to questions.',
parameters: {
type: 'object',
properties: {
q: { type: 'string' },
},
required: ['q'],
},
},
},
{
type: 'function',
function: {
name: 'open_chart',
description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).',
parameters: {
type: 'object',
properties: {
chart: { type: 'string' },
ts: { type: 'number', description: 'Event time ms' },
},
required: ['chart'],
},
},
},
{
type: 'function',
function: {
name: 'open_view',
description:
'Navigate desktop to a view: overview|charts|processes|alerts|logs|fleet|settings|qvac',
parameters: {
type: 'object',
properties: {
view: { type: 'string' },
},
required: ['view'],
},
},
},
{
type: 'function',
function: {
name: 'silence_alert',
description:
'Operator only: silence an alert by id for durationMs (requires confirm). Prefer explaining first.',
parameters: {
type: 'object',
properties: {
id: { type: 'string' },
durationMs: { type: 'number', description: 'Silence duration ms (default 3600000)' },
confirmed: { type: 'boolean', description: 'Must be true after user confirms' },
},
required: ['id'],
},
},
},
]
/**
* @param {{
* manager: { request: (m: string, a?: object) => Promise<any>, active: any },
* getRole: () => string,
* isConnected: () => boolean,
* getCatalog?: () => Record<string, object>,
* onOpenChart?: (chartId: string, ts?: number) => void,
* onOpenView?: (view: string) => void,
* confirmAction?: (message: string) => boolean|Promise<boolean>,
* }} deps
*/
export function createToolRunner(deps) {
/**
* @param {string} name
* @param {object} args
*/
async function run(name, args = {}) {
const localOk = ['open_view', 'open_chart', 'local_knowledge'].includes(name)
if (!deps.isConnected?.() && !localOk) {
return { error: 'No agent connected. Connect from the Connect tab first.' }
}
const req = (m, a) => deps.manager.request(m, a || {})
try {
switch (name) {
case 'host_snapshot':
return await req(Methods.getHostSnapshot, {})
case 'search_charts':
return await req(Methods.searchCharts, {
q: args.q || '',
limit: args.limit ?? 20,
})
case 'summarize_chart':
return await req(Methods.summarizeChart, {
chart: args.chart || args.id,
after: args.after,
points: args.points,
group: args.group,
})
case 'query_metric':
return await req(Methods.queryData, {
chart: args.chart,
after: args.after ?? -90,
points: args.points ?? 90,
group: args.group || 'average',
})
case 'list_anomalies':
return await req(Methods.listAnomalies, { limit: args.limit ?? 30 })
case 'list_alerts':
return await req(Methods.listAlerts, {})
case 'list_processes':
return await req(Methods.listProcesses, {
sort: args.sort || 'cpu',
limit: args.limit ?? 25,
filter: args.filter || 'all',
})
case 'query_logs':
return await req(Methods.queryLogs, {
source: args.source || 'anomaly',
q: args.q || '',
limit: args.limit ?? 40,
})
case 'fleet_health':
return await req(Methods.getFleetHealth, {})
case 'storage_info': {
const [storage, retention] = await Promise.all([
req(Methods.getStorageInfo, {}),
req(Methods.getRetentionConfig, {}),
])
return { storage, retention }
}
case 'local_knowledge': {
const ctx = buildRagContext({
query: String(args.q || ''),
catalog: deps.getCatalog?.() || {},
topK: 8,
})
return { context: ctx || 'No matching local knowledge.', q: args.q }
}
case 'open_chart': {
deps.onOpenChart?.(String(args.chart), args.ts)
return { ok: true, opened: args.chart }
}
case 'open_view': {
deps.onOpenView?.(String(args.view || 'overview'))
return { ok: true, view: args.view }
}
case 'silence_alert': {
const role = deps.getRole?.() || Roles.viewer
if (!roleAllows(role, Roles.operator)) {
return { error: 'Operator role required to silence alerts' }
}
if (!args.confirmed) {
return {
error: 'confirmation_required',
message: `Confirm silencing alert ${args.id} before retrying with confirmed=true`,
}
}
const ok =
(await deps.confirmAction?.(
`Silence alert ${args.id} for ${Math.round((args.durationMs || 3_600_000) / 60000)} minutes?`
)) !== false
if (!ok) return { error: 'User declined silence' }
return await req(Methods.silenceAlert, {
id: args.id,
durationMs: args.durationMs ?? 3_600_000,
})
}
default:
return { error: `Unknown tool: ${name}` }
}
} catch (err) {
return { error: err?.message || String(err) }
}
}
/**
* Tools available for the current role.
*/
function defsForRole() {
const role = deps.getRole?.() || Roles.viewer
if (roleAllows(role, Roles.operator)) return TOOL_DEFS
return TOOL_DEFS.filter((t) => t.function.name !== 'silence_alert')
}
return { run, defsForRole, TOOL_DEFS }
}
/**
* Naive tool-using fallback when QVAC SDK is not installed.
* @param {string} userText
* @param {{ run: (name: string, args?: object) => Promise<any> }} tools
* @param {{ catalog?: Record<string, object>, rag?: boolean }} [opts]
*/
export async function fallbackComplete(userText, tools, opts = {}) {
const q = String(userText || '').toLowerCase()
/** @type {Array<{ name: string, args: object, result: any }>} */
const calls = []
// Product / how-to questions can skip live snapshot
const howTo =
q.includes('how do') ||
q.includes('what is qvac') ||
q.includes('how to') ||
q.includes('keyboard') ||
q.includes('retention')
if (!howTo) {
const snap = await tools.run('host_snapshot', {})
calls.push({ name: 'host_snapshot', args: {}, result: snap })
}
if (opts.rag !== false) {
const kn = await tools.run('local_knowledge', { q: userText })
calls.push({ name: 'local_knowledge', args: { q: userText }, result: kn })
}
if (q.includes('process') || q.includes('top ')) {
const p = await tools.run('list_processes', { limit: 10 })
calls.push({ name: 'list_processes', args: { limit: 10 }, result: p })
}
if (
q.includes('chart') ||
q.includes('metric') ||
q.includes('disk') ||
q.includes('redis') ||
q.includes('docker') ||
q.includes('nginx') ||
q.includes('postgres')
) {
const term =
(q.match(/\b(redis|docker|disk|cpu|ram|net|io|postgres|nginx|mem)\b/) || [])[0] ||
'system'
const s = await tools.run('search_charts', { q: term, limit: 12 })
calls.push({ name: 'search_charts', args: { q: term }, result: s })
}
if (q.includes('anomal') || q.includes('alert')) {
const a = await tools.run('list_anomalies', { limit: 15 })
calls.push({ name: 'list_anomalies', args: {}, result: a })
if (q.includes('alert')) {
const al = await tools.run('list_alerts', {})
calls.push({ name: 'list_alerts', args: {}, result: al })
}
}
if (q.includes('fleet') || q.includes('child')) {
const f = await tools.run('fleet_health', {})
calls.push({ name: 'fleet_health', args: {}, result: f })
}
if (q.includes('log') || q.includes('journal')) {
const l = await tools.run('query_logs', { source: 'anomaly', limit: 20 })
calls.push({ name: 'query_logs', args: { source: 'anomaly' }, result: l })
}
if (q.includes('storage') || q.includes('retention') || q.includes('prune')) {
const s = await tools.run('storage_info', {})
calls.push({ name: 'storage_info', args: {}, result: s })
}
if (/\bcpu\b/.test(q) || q.includes('load')) {
const s = await tools.run('summarize_chart', { chart: 'system.cpu', points: 60 })
calls.push({ name: 'summarize_chart', args: { chart: 'system.cpu' }, result: s })
}
if (/\bram\b/.test(q) || q.includes('memory')) {
const s = await tools.run('summarize_chart', { chart: 'system.ram', points: 60 })
calls.push({ name: 'summarize_chart', args: { chart: 'system.ram' }, result: s })
}
const snap = calls.find((c) => c.name === 'host_snapshot')?.result || null
const text = formatFallbackAnswer(userText, snap, calls, howTo)
return { contentText: text, toolCalls: calls, mode: 'fallback' }
}
function formatFallbackAnswer(userText, snap, calls, howTo) {
if (!howTo && snap?.error) {
return `I could not reach the agent: ${snap.error}\n\nConnect an agent from the Connect tab, then ask again.\n\n(Running in **tools-only fallback** — install @qvac/sdk for full local LLM chat.)`
}
const lines = []
if (snap && !snap.error) {
lines.push(`**Host snapshot** (${snap.hostname || 'agent'})`)
if (snap.health) {
lines.push(
`- Health: status=${snap.health.status || '—'}, warnings=${snap.health.warnings ?? '—'}, critical=${snap.health.critical ?? '—'}`
)
}
if (snap.kpis) {
for (const [k, v] of Object.entries(snap.kpis)) {
if (!v) continue
lines.push(`- ${k}: **${fmt(v.value)}** (${v.chart}.${v.dim})`)
}
}
if (snap.anomalies?.length) {
lines.push(`- Recent anomalies: ${snap.anomalies.length}`)
for (const a of snap.anomalies.slice(0, 5)) {
lines.push(` · ${a.severity || '?'} ${a.chart || ''}${a.message || ''}`)
}
} else {
lines.push('- No recent anomalies in the snapshot.')
}
}
for (const c of calls) {
if (c.name === 'host_snapshot') continue
if (c.name === 'local_knowledge' && c.result?.context) {
lines.push(`\n**Local knowledge**\n${c.result.context}`)
}
if (c.name === 'search_charts' && c.result?.results) {
lines.push(`\n**Charts matching “${c.args.q}”** (${c.result.results.length})`)
for (const r of c.result.results.slice(0, 8)) {
lines.push(`- \`${r.id}\`${r.title || ''}`)
}
}
if (c.name === 'summarize_chart' && c.result?.dims) {
lines.push(`\n**${c.result.chart}** (${c.result.points} pts, source=${c.result.source})`)
for (const [dim, st] of Object.entries(c.result.dims)) {
lines.push(
`- ${dim}: last=${fmt(st.last)} avg=${fmt(st.avg)} min=${fmt(st.min)} max=${fmt(st.max)}`
)
}
}
if (c.name === 'list_processes' && c.result?.processes) {
lines.push('\n**Top processes**')
for (const p of (c.result.processes || []).slice(0, 8)) {
lines.push(
`- pid ${p.pid} ${p.name || p.comm || ''} cpu=${fmt(p.cpu)}% rss=${fmt(p.rss)}`
)
}
}
if (c.name === 'list_anomalies' && c.result?.anomalies) {
lines.push(`\n**Anomalies** (${c.result.anomalies.length})`)
for (const a of c.result.anomalies.slice(0, 8)) {
lines.push(`- ${a.severity} ${a.chart}: ${a.message || ''}`)
}
}
if (c.name === 'list_alerts' && c.result?.alerts) {
lines.push(`\n**Alerts** (${c.result.alerts.length})`)
for (const a of (c.result.alerts || []).slice(0, 8)) {
lines.push(`- ${a.id || a.chart}: ${a.severity || ''} ${a.message || a.name || ''}`)
}
}
if (c.name === 'fleet_health' && c.result) {
lines.push(
`\n**Fleet** enabled=${c.result.enabled} children=${(c.result.children || []).length}`
)
if (c.result.summary) {
lines.push(`- summary: ${JSON.stringify(c.result.summary)}`)
}
}
if (c.name === 'query_logs' && (c.result?.entries || c.result?.lines || c.result?.events)) {
const rows = c.result.entries || c.result.lines || c.result.events || []
lines.push(`\n**Logs** (${rows.length})`)
for (const row of rows.slice(0, 6)) {
const msg = row.message || row.msg || row.line || JSON.stringify(row).slice(0, 120)
lines.push(`- ${msg}`)
}
}
if (c.name === 'storage_info' && c.result) {
lines.push('\n**Storage / retention**')
lines.push('```\n' + JSON.stringify(c.result, null, 0).slice(0, 800) + '\n```')
}
}
if (!lines.length) {
lines.push('No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).')
}
lines.push(
`\n_Question: ${userText}_\n_Mode: tools-only fallback (install **@qvac/sdk** for Qwen local chat)._`
)
return lines.join('\n')
}
function fmt(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)
}
+285
View File
@@ -3638,6 +3638,291 @@ html[data-theme='light'] .proc-detail-cmd {
} }
} }
/* ─── QVAC tab ─── */
#qvac-view {
display: flex;
flex-direction: column;
min-height: 0;
gap: 12px;
}
.qvac-header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.qvac-model-chip {
font-family: var(--font-mono);
font-size: 11px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
}
.qvac-model-chip[data-mode='ready'] {
border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-color));
color: var(--accent-primary);
}
.qvac-model-chip[data-mode='fallback'] {
border-color: rgba(251, 191, 36, 0.45);
color: #fbbf24;
}
.qvac-model-chip[data-mode='busy'] {
border-color: rgba(56, 189, 248, 0.45);
color: #38bdf8;
}
.qvac-setup {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
justify-content: center;
padding: 12px 8px 32px;
}
.qvac-setup-steps {
width: min(640px, 100%);
}
.qvac-step-card {
padding: 22px 24px;
border-radius: 16px;
border: 1px solid var(--border-color);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent 50%),
var(--bg-secondary);
}
.qvac-step-card h3 {
margin: 0 0 12px;
font-size: 18px;
}
.qvac-step-body {
font-size: 14px;
line-height: 1.5;
color: var(--text-secondary);
}
.qvac-step-body p {
margin: 0 0 10px;
}
.qvac-step-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 18px;
}
.qvac-check-list {
list-style: none;
padding: 0;
margin: 0 0 12px;
}
.qvac-check-list li {
padding: 6px 0;
border-bottom: 1px solid var(--border-color);
font-family: var(--font-mono);
font-size: 12px;
}
.qvac-check-list li.ok::before {
content: '✓ ';
color: var(--accent-primary);
}
.qvac-check-list li.warn::before {
content: '! ';
color: #fbbf24;
}
.qvac-profile-grid {
display: grid;
gap: 10px;
}
.qvac-profile-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
text-align: left;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-elevated, var(--bg-secondary));
color: var(--text-primary);
cursor: pointer;
}
.qvac-profile-card.active {
border-color: color-mix(in srgb, var(--accent-primary) 55%, var(--border-color));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-primary) 25%, transparent);
}
.qvac-profile-meta {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-faint, var(--text-secondary));
}
.qvac-progress-wrap {
margin-top: 8px;
}
.qvac-progress-bar {
height: 8px;
border-radius: 999px;
background: rgba(154, 168, 188, 0.15);
overflow: hidden;
}
.qvac-progress-bar i {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent-primary), #38bdf8);
transition: width 0.2s ease;
}
.qvac-chat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.qvac-samples {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.qvac-sample {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: 999px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
}
.qvac-sample:hover {
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--border-color));
color: var(--text-primary);
}
.qvac-messages {
flex: 1;
min-height: 200px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 8px 4px 16px;
}
.qvac-msg {
max-width: min(720px, 100%);
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.qvac-msg-user {
align-self: flex-end;
background: color-mix(in srgb, var(--accent-primary) 12%, var(--bg-secondary));
border-color: color-mix(in srgb, var(--accent-primary) 28%, var(--border-color));
}
.qvac-msg-assistant {
align-self: flex-start;
}
.qvac-msg-body {
font-size: 13.5px;
line-height: 1.5;
color: var(--text-primary);
word-break: break-word;
}
.qvac-msg-body code {
font-family: var(--font-mono);
font-size: 12px;
padding: 1px 5px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.25);
}
.qvac-tool-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
.qvac-tool-chip {
font-family: var(--font-mono);
font-size: 10px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(52, 211, 153, 0.12);
color: var(--accent-primary);
border: 1px solid rgba(52, 211, 153, 0.28);
}
.qvac-composer {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: end;
padding-bottom: 8px;
}
.qvac-composer textarea {
width: 100%;
resize: vertical;
min-height: 52px;
max-height: 160px;
border-radius: 12px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
padding: 10px 12px;
font: inherit;
font-size: 14px;
}
.qvac-composer textarea:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-color));
}
#qvac-status[data-kind='error'] {
color: #f87171;
}
#qvac-status[data-kind='warn'] {
color: #fbbf24;
}
#qvac-status[data-kind='ok'] {
color: var(--accent-primary);
}
#qvac-status[data-kind='busy'] {
color: #38bdf8;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.proc-side { .proc-side {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+3 -1
View File
@@ -21,6 +21,7 @@ For engineers (protocol, REST deep-dive, architecture), see [docs/](../docs/READ
| [Logs](./logs.md) | System log stream — journal (default), anomalies, audit; Follow / search | | [Logs](./logs.md) | System log stream — journal (default), anomalies, audit; Follow / search |
| [Containers](./containers.md) | Docker metrics with human names (installer socket access) | | [Containers](./containers.md) | Docker metrics with human names (installer socket access) |
| [Fleet](./fleet.md) | Multi-host roster, set active, reconnect, forget | | [Fleet](./fleet.md) | Multi-host roster, set active, reconnect, forget |
| [QVAC (local AI)](./qvac.md) | On-device SRE copilot, onboarding, tools → agent |
| [Settings](./settings.md) | Appearance, Data Manager (retention / prune), connections | | [Settings](./settings.md) | Appearance, Data Manager (retention / prune), connections |
| [Keyboard shortcuts](./keyboard.md) | Charts and shell shortcuts | | [Keyboard shortcuts](./keyboard.md) | Charts and shell shortcuts |
| [Weights API](./weights-api.md) | REST / RPC for Metric Correlations from scripts | | [Weights API](./weights-api.md) | REST / RPC for Metric Correlations from scripts |
@@ -33,4 +34,5 @@ For engineers (protocol, REST deep-dive, architecture), see [docs/](../docs/READ
2. Skim [Overview](./overview-tab.md) 2. Skim [Overview](./overview-tab.md)
3. Open **Charts** — [Charts guide](./charts.md) 3. Open **Charts** — [Charts guide](./charts.md)
4. Investigate with [Metric Correlations](./metric-correlations.md), [Logs](./logs.md), or [Related](./related-metrics.md) 4. Investigate with [Metric Correlations](./metric-correlations.md), [Logs](./logs.md), or [Related](./related-metrics.md)
5. Manage hosts in [Fleet](./fleet.md) 5. Ask the local copilot on the [QVAC](./qvac.md) tab
6. Manage hosts in [Fleet](./fleet.md)
+2
View File
@@ -12,4 +12,6 @@ Shortcuts apply when the Charts (or shell) focus is not in a text field.
| **b** | Toggle Board (pins only) | | **b** | Toggle Board (pins only) |
| **r** | Logs: refresh (when Logs tab is open) | | **r** | Logs: refresh (when Logs tab is open) |
The **QVAC** tab has no global letter shortcut yet — open it from the sidebar or **Settings → QVAC → Open QVAC tab**. In chat, **Enter** sends (Shift+Enter newline).
Mouse / trackpad gestures for pan, zoom, and brush are documented in [Charts](./charts.md). Logs shortcuts apply only on the [Logs](./logs.md) tab. Mouse / trackpad gestures for pan, zoom, and brush are documented in [Charts](./charts.md). Logs shortcuts apply only on the [Logs](./logs.md) tab.
+89
View File
@@ -0,0 +1,89 @@
# QVAC (local AI)
The **QVAC** tab is an on-device assistant for the connected agent. Models run on your desktop; the agent only answers metrics tools over P2P.
## First-time setup
1. Open **QVAC** in the sidebar.
2. **Continue** through the welcome screen.
3. Review the system check (RAM, whether `@qvac/sdk` is installed).
4. Pick a **model profile**:
- **Lite** — smallest download
- **Recommended** — best default (tool calling)
- **Strong** — larger model if you have RAM to spare
5. **Download & load** (or continue in tools-only mode if the SDK is not installed).
Re-run setup anytime with **Setup** in the QVAC header.
## Using chat
- Type a question or click a sample prompt.
- Answers use live tools (`host_snapshot`, charts, anomalies, processes).
- Tool chips under a reply show which RPCs ran.
- **Unload** frees model memory (weights stay on disk).
- **New chat** clears the conversation.
### Good questions
- “Summarize host health right now”
- “Any open alerts or anomalies?”
- “List charts related to disk”
- “Why might CPU be high?” (uses CPU summary + snapshot)
### Requires a connected agent
Connect from the **Connect** tab first. If offline, tools return a clear error instead of inventing numbers.
## Tools-only vs full QVAC
| Mode | When | Behavior |
|------|------|----------|
| Full QVAC | `@qvac/sdk` installed + model loaded | Local Qwen (or other profile) + tools |
| Tools-only | SDK missing or load failed | Grounded formatting of RPC results, no LLM |
```bash
npm i @qvac/sdk
```
Then re-run **Setup** in the QVAC tab.
## Settings
**Settings → QVAC**:
- Default model profile
- Inject local knowledge (guide + chart catalog into prompts)
- Idle unload (minutes) — free RAM when idle; weights stay on disk
## Settings
**Settings → QVAC**:
| Preference | Effect |
|------------|--------|
| Default model profile | Lite / Recommended / Strong / Tool-tiny (used on next Setup) |
| Inject local knowledge | Keyword RAG over user-guide snippets + chart catalog |
| Idle unload (minutes) | Unload model from RAM after inactivity (`0` = never) |
| Open QVAC tab | Jump to the chat view |
## Tools available to chat
| Tool | Purpose |
|------|---------|
| `host_snapshot` | Live health + KPIs |
| `search_charts` / `summarize_chart` / `query_metric` | Catalog + time series |
| `list_anomalies` / `list_alerts` | Anomaly / alert state |
| `list_processes` | Top processes (when enabled on agent) |
| `query_logs` | Journal / anomaly / audit lines |
| `fleet_health` / `storage_info` | Fleet + retention |
| `local_knowledge` | Offline product how-tos |
| `open_chart` / `open_view` | Navigate the desktop UI |
| `silence_alert` | Operator only, with confirmation |
## Privacy
- No cloud API keys for this feature.
- Inference stays on your machine.
- Agent never loads multi-GB models for the default design.
See also [docs/QVAC.md](../docs/QVAC.md).