First Try: QVAC (QuantumVerse Automatic Computer)
This commit is contained in:
@@ -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 })
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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` 1–100) |
|
||||||
|
| `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
@@ -0,0 +1,115 @@
|
|||||||
|
# QVAC integration (local AI)
|
||||||
|
|
||||||
|
PearData’s **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
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 0–1: 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
@@ -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>
|
||||||
|
|||||||
@@ -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 .",
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 agent’s 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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 }
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
Reference in New Issue
Block a user