FIX: QVAC Updates
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Context window budgeting for small local QVAC models.
|
||||
* Rough token estimate + history compaction before completion.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Conservative token estimate (chars / 3.2) for English + JSON tool payloads.
|
||||
* @param {unknown} text
|
||||
*/
|
||||
export function estimateTokens(text) {
|
||||
const s = typeof text === 'string' ? text : JSON.stringify(text ?? '')
|
||||
return Math.max(1, Math.ceil(s.length / 3.2))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ role?: string, content?: string, name?: string }>} messages
|
||||
*/
|
||||
export function estimateMessagesTokens(messages) {
|
||||
let n = 0
|
||||
for (const m of messages || []) {
|
||||
n += 4 // role framing
|
||||
n += estimateTokens(m.content || '')
|
||||
if (m.name) n += estimateTokens(m.name)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any[]} tools
|
||||
*/
|
||||
export function estimateToolsTokens(tools) {
|
||||
if (!tools?.length) return 0
|
||||
return estimateTokens(JSON.stringify(tools)) + 32
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chat history to fit a token budget.
|
||||
* Always keeps: first system message(s), last user message, recent turns.
|
||||
* Truncates large tool payloads; drops oldest middle messages.
|
||||
*
|
||||
* @param {Array<{ role: string, content: string, name?: string }>} messages
|
||||
* @param {{
|
||||
* maxTokens?: number,
|
||||
* keepRecentUserTurns?: number,
|
||||
* maxToolChars?: number,
|
||||
* maxMsgChars?: number,
|
||||
* }} [opts]
|
||||
*/
|
||||
export function compactMessages(messages, opts = {}) {
|
||||
const maxTokens = Math.max(512, Number(opts.maxTokens) || 2800)
|
||||
const keepRecentUserTurns = Math.max(1, Number(opts.keepRecentUserTurns) || 3)
|
||||
const maxToolChars = Math.max(400, Number(opts.maxToolChars) || 2500)
|
||||
const maxMsgChars = Math.max(800, Number(opts.maxMsgChars) || 4000)
|
||||
|
||||
const src = (messages || []).map((m) => ({
|
||||
role: m.role,
|
||||
content: String(m.content ?? ''),
|
||||
...(m.name ? { name: m.name } : {}),
|
||||
}))
|
||||
|
||||
if (!src.length) return src
|
||||
|
||||
// Split leading system messages
|
||||
/** @type {typeof src} */
|
||||
const systems = []
|
||||
let i = 0
|
||||
while (i < src.length && src[i].role === 'system') {
|
||||
systems.push(truncateMsg(src[i], maxMsgChars * 2))
|
||||
i++
|
||||
}
|
||||
const rest = src.slice(i)
|
||||
|
||||
// Find last user message index in rest
|
||||
let lastUser = -1
|
||||
for (let j = rest.length - 1; j >= 0; j--) {
|
||||
if (rest[j].role === 'user') {
|
||||
lastUser = j
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate tool / long assistant bodies first
|
||||
const trimmed = rest.map((m) => {
|
||||
if (m.role === 'tool') return truncateMsg(m, maxToolChars)
|
||||
if (m.role === 'assistant') return truncateMsg(m, maxMsgChars)
|
||||
return truncateMsg(m, maxMsgChars)
|
||||
})
|
||||
|
||||
// Keep recent window ending at last message, including last N user turns
|
||||
let start = 0
|
||||
if (lastUser >= 0) {
|
||||
let users = 0
|
||||
start = lastUser
|
||||
for (let j = lastUser; j >= 0; j--) {
|
||||
if (trimmed[j].role === 'user') {
|
||||
users++
|
||||
start = j
|
||||
if (users >= keepRecentUserTurns) break
|
||||
}
|
||||
}
|
||||
}
|
||||
let window = trimmed.slice(start)
|
||||
|
||||
// Drop from front until under budget (keep systems + window)
|
||||
const pack = () => [...systems, ...window]
|
||||
while (window.length > 2 && estimateMessagesTokens(pack()) > maxTokens) {
|
||||
// Prefer dropping oldest non-user if possible
|
||||
if (window[0]?.role !== 'user' || window.length > 4) {
|
||||
window = window.slice(1)
|
||||
} else {
|
||||
window = window.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Still over budget: hard-trim contents
|
||||
if (estimateMessagesTokens(pack()) > maxTokens) {
|
||||
window = window.map((m) =>
|
||||
truncateMsg(m, m.role === 'tool' ? 600 : m.role === 'system' ? 2000 : 1200)
|
||||
)
|
||||
}
|
||||
|
||||
// Still over: keep only systems + last user + trailing assistant/tool chain
|
||||
if (estimateMessagesTokens(pack()) > maxTokens) {
|
||||
const last = window[window.length - 1]
|
||||
const lastU = [...window].reverse().find((m) => m.role === 'user')
|
||||
window = [lastU, last].filter(Boolean)
|
||||
// dedupe if same ref
|
||||
if (window.length === 2 && window[0] === window[1]) window = [window[0]]
|
||||
}
|
||||
|
||||
// Add a short note if we dropped history
|
||||
const dropped = rest.length - window.length
|
||||
if (dropped > 0 && systems[0]) {
|
||||
systems[0] = {
|
||||
...systems[0],
|
||||
content:
|
||||
systems[0].content +
|
||||
`\n\n[Context compacted: ${dropped} earlier messages omitted to fit the model window.]`,
|
||||
}
|
||||
}
|
||||
|
||||
return pack()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ role: string, content: string, name?: string }} m
|
||||
* @param {number} maxChars
|
||||
*/
|
||||
function truncateMsg(m, maxChars) {
|
||||
const c = String(m.content || '')
|
||||
if (c.length <= maxChars) return m
|
||||
return {
|
||||
...m,
|
||||
content: c.slice(0, maxChars - 20) + '\n…[truncated]',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Budget for prompt given model context size.
|
||||
* Reserves space for generation + tool schemas.
|
||||
* @param {number} ctxSize
|
||||
* @param {number} toolsTokens
|
||||
*/
|
||||
export function promptBudget(ctxSize, toolsTokens = 0) {
|
||||
const ctx = Math.max(2048, Number(ctxSize) || 4096)
|
||||
// Leave room for model output + thinking
|
||||
const reserveOut = Math.min(1024, Math.floor(ctx * 0.25))
|
||||
const reserveTools = Math.min(toolsTokens, Math.floor(ctx * 0.2))
|
||||
const budget = ctx - reserveOut - reserveTools - 64
|
||||
return Math.max(800, budget)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect context-overflow style errors from QVAC / llama.cpp.
|
||||
* @param {string} msg
|
||||
*/
|
||||
export function isContextOverflowError(msg) {
|
||||
const s = String(msg || '').toLowerCase()
|
||||
return (
|
||||
s.includes('context window') ||
|
||||
s.includes('context length') ||
|
||||
s.includes('exceeds the model') ||
|
||||
s.includes('prompt is too long') ||
|
||||
s.includes('n_keep') ||
|
||||
s.includes('too many tokens')
|
||||
)
|
||||
}
|
||||
+456
-50
@@ -2,10 +2,72 @@
|
||||
* QVAC engine facade — load models, stream completion, tool loop.
|
||||
* Uses @qvac/sdk when available; otherwise tools-only fallback.
|
||||
*/
|
||||
import { createRequire } from 'module'
|
||||
import { existsSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { getProfile } from './profiles.js'
|
||||
import { buildSystemPrompt } from './prompts.js'
|
||||
import { fallbackComplete } from './tools.js'
|
||||
import { buildRagContext } from './rag.js'
|
||||
import {
|
||||
compactMessages,
|
||||
estimateToolsTokens,
|
||||
isContextOverflowError,
|
||||
promptBudget,
|
||||
} from './context.js'
|
||||
|
||||
/** Resolve paths without evaluating package main (safe during onboarding). */
|
||||
function resolveSdkPackagePath() {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof require === 'function') {
|
||||
try {
|
||||
return require.resolve('@qvac/sdk')
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
try {
|
||||
let base
|
||||
try {
|
||||
base = fileURLToPath(import.meta.url)
|
||||
} catch {
|
||||
base = join(process.cwd(), 'package.json')
|
||||
}
|
||||
const req = createRequire(base)
|
||||
return req.resolve('@qvac/sdk')
|
||||
} catch {
|
||||
// fall through to disk probe
|
||||
}
|
||||
const candidates = [
|
||||
join(process.cwd(), 'node_modules', '@qvac', 'sdk', 'package.json'),
|
||||
join(process.cwd(), 'node_modules', '@qvac', 'sdk', 'dist', 'index.js'),
|
||||
]
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof __dirname !== 'undefined') {
|
||||
candidates.push(join(__dirname, '..', 'node_modules', '@qvac', 'sdk', 'package.json'))
|
||||
candidates.push(join(__dirname, '..', '..', 'node_modules', '@qvac', 'sdk', 'package.json'))
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
// Packaged Electron: Resources/app/node_modules
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof process !== 'undefined' && process.resourcesPath) {
|
||||
candidates.push(
|
||||
join(process.resourcesPath, 'app', 'node_modules', '@qvac', 'sdk', 'package.json')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
@@ -16,9 +78,27 @@ import { buildRagContext } from './rag.js'
|
||||
* log?: (msg: string) => void,
|
||||
* }} deps
|
||||
*/
|
||||
/**
|
||||
* Electron renderer? Never require('@qvac/sdk') here — use main-process IPC.
|
||||
*/
|
||||
function getElectronIpc() {
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof process !== 'undefined' && process.type === 'renderer') {
|
||||
// eslint-disable-next-line no-undef
|
||||
return typeof require === 'function' ? require('electron').ipcRenderer : null
|
||||
}
|
||||
} catch {
|
||||
// not electron
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createQvacEngine(deps) {
|
||||
/** @type {any} */
|
||||
let sdk = null
|
||||
/** @type {'direct'|'main'|null} */
|
||||
let sdkMode = null
|
||||
let sdkError = null
|
||||
/** @type {string|null} */
|
||||
let modelId = null
|
||||
@@ -32,6 +112,7 @@ export function createQvacEngine(deps) {
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let idleTimer = null
|
||||
let lastActivity = Date.now()
|
||||
const ipc = getElectronIpc()
|
||||
|
||||
function touchActivity() {
|
||||
lastActivity = Date.now()
|
||||
@@ -55,30 +136,63 @@ export function createQvacEngine(deps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve @qvac/sdk for both ESM (pear run) and CJS Electron bundle.
|
||||
* Packaged Electron: packages are external requires under app/node_modules.
|
||||
* Lightweight "is the package on disk?" check — never evaluates @qvac/sdk.
|
||||
* @returns {{ installed: boolean, error: string|null }}
|
||||
*/
|
||||
function detectSdkInstalled() {
|
||||
if (sdk || modelId) return { installed: true, error: null }
|
||||
if (ipc) {
|
||||
// Sync detect via invoke is async; use path resolve for UI, main confirms on load
|
||||
const path = resolveSdkPackagePath()
|
||||
if (path) return { installed: true, error: null }
|
||||
// Packaged app may resolve from Resources/app — still try disk
|
||||
return { installed: Boolean(resolveSdkPackagePath()), error: null }
|
||||
}
|
||||
const path = resolveSdkPackagePath()
|
||||
if (path) return { installed: true, error: null }
|
||||
return { installed: false, error: '@qvac/sdk not installed in this app' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we can talk to QVAC (main IPC in Electron, direct require only outside renderer).
|
||||
* @param {number} [timeoutMs]
|
||||
*/
|
||||
async function tryLoadSdk(timeoutMs = 8_000) {
|
||||
if (sdk) return sdk
|
||||
if (sdk || (sdkMode === 'main' && modelId)) {
|
||||
return sdk || { __viaMain: true }
|
||||
}
|
||||
if (ipc) {
|
||||
try {
|
||||
const det = await ipc.invoke('peardata:qvac-detect')
|
||||
if (!det?.installed) {
|
||||
sdkError = det?.error || '@qvac/sdk not installed'
|
||||
return null
|
||||
}
|
||||
sdkMode = 'main'
|
||||
sdk = { __viaMain: true }
|
||||
sdkError = null
|
||||
deps.log?.('QVAC: using main-process SDK host (safe for Electron UI)')
|
||||
return sdk
|
||||
} catch (err) {
|
||||
sdkError = err?.message || String(err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Non-Electron (e.g. pear / node): load SDK in-process
|
||||
try {
|
||||
const load = (async () => {
|
||||
// Prefer dynamic import (ESM / pear). Falls back to createRequire for CJS.
|
||||
try {
|
||||
return await import('@qvac/sdk')
|
||||
} catch (importErr) {
|
||||
try {
|
||||
const { createRequire } = await import('module')
|
||||
const req = createRequire(
|
||||
typeof __filename !== 'undefined'
|
||||
? __filename
|
||||
: `${process.cwd()}/package.json`
|
||||
)
|
||||
return req('@qvac/sdk')
|
||||
} catch {
|
||||
throw importErr
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof require === 'function') return require('@qvac/sdk')
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
const { createRequire: cr } = await import('module')
|
||||
const req = cr(
|
||||
typeof __filename !== 'undefined' ? __filename : `${process.cwd()}/package.json`
|
||||
)
|
||||
return req('@qvac/sdk')
|
||||
})()
|
||||
const timed =
|
||||
timeoutMs > 0
|
||||
@@ -93,24 +207,30 @@ export function createQvacEngine(deps) {
|
||||
])
|
||||
: load
|
||||
sdk = await timed
|
||||
sdkMode = 'direct'
|
||||
sdkError = null
|
||||
return sdk
|
||||
} catch (err) {
|
||||
sdkError = err?.message || String(err)
|
||||
sdk = null
|
||||
sdkMode = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
const disk = detectSdkInstalled()
|
||||
const loaded = Boolean(modelId) && (sdkMode === 'main' || Boolean(sdk))
|
||||
return {
|
||||
status,
|
||||
modelId,
|
||||
profileId,
|
||||
sdkAvailable: Boolean(sdk) && !sdkError,
|
||||
sdkError,
|
||||
sdkAvailable: loaded || disk.installed || sdkMode === 'main',
|
||||
sdkLoaded: loaded,
|
||||
sdkMode,
|
||||
sdkError: sdkError || (!disk.installed && !loaded ? disk.error : null),
|
||||
progress: lastProgress,
|
||||
mode: sdk && modelId ? 'qvac' : status === 'fallback' || !sdk ? 'fallback' : status,
|
||||
mode: loaded && status === 'ready' ? 'qvac' : status === 'fallback' || !loaded ? 'fallback' : status,
|
||||
lastActivity,
|
||||
}
|
||||
}
|
||||
@@ -122,14 +242,6 @@ export function createQvacEngine(deps) {
|
||||
: null
|
||||
let totalRamBytes = null
|
||||
let freeRamBytes = null
|
||||
// Prefer already-loaded process/os globals — avoid hanging dynamic imports in UI
|
||||
try {
|
||||
if (typeof process !== 'undefined' && process.memoryUsage) {
|
||||
// Node/Electron: totalmem via require('os') may work; try sync first
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
const osMod = typeof require === 'function' ? require('os') : null
|
||||
@@ -149,13 +261,14 @@ export function createQvacEngine(deps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight host check — never blocks on native SDK probes.
|
||||
* Lightweight host check for onboarding.
|
||||
* Default: NEVER import @qvac/sdk (probeSdk must be explicitly true).
|
||||
* @param {{ probeSdk?: boolean, timeoutMs?: number }} [opts]
|
||||
*/
|
||||
async function checkEnvironment(opts = {}) {
|
||||
status = 'checking'
|
||||
const host = hostPlatformInfo()
|
||||
// Optional async os.totalmem for pure ESM
|
||||
// Optional async os.totalmem for pure ESM only when sync path missed
|
||||
if (host.totalRamBytes == null) {
|
||||
try {
|
||||
const os = await Promise.race([
|
||||
@@ -171,11 +284,15 @@ export function createQvacEngine(deps) {
|
||||
|
||||
let sdkAvailable = Boolean(sdk)
|
||||
let err = sdkError
|
||||
if (opts.probeSdk !== false && !sdk) {
|
||||
// Short probe only — full model load happens later on user action
|
||||
const s = await tryLoadSdk(opts.timeoutMs ?? 2500)
|
||||
if (opts.probeSdk === true && !sdk) {
|
||||
// Explicit full load — only for model download path, not Continue
|
||||
const s = await tryLoadSdk(opts.timeoutMs ?? 8000)
|
||||
sdkAvailable = Boolean(s)
|
||||
err = sdkError
|
||||
} else {
|
||||
const disk = detectSdkInstalled()
|
||||
sdkAvailable = disk.installed || Boolean(sdk)
|
||||
if (!disk.installed && !sdk) err = disk.error
|
||||
}
|
||||
|
||||
status = 'idle'
|
||||
@@ -198,20 +315,70 @@ export function createQvacEngine(deps) {
|
||||
const p = getProfile(profile)
|
||||
profileId = p.id
|
||||
touchActivity()
|
||||
status = 'downloading'
|
||||
lastProgress = { percentage: 0 }
|
||||
|
||||
// Electron: main-process host (never require SDK in renderer)
|
||||
if (ipc) {
|
||||
/** @type {(( _e: any, prog: any) => void)|null} */
|
||||
let onProg = null
|
||||
try {
|
||||
const bridge = await tryLoadSdk()
|
||||
if (!bridge) {
|
||||
status = 'fallback'
|
||||
return { ok: true, mode: 'fallback', profile: p.id, error: sdkError }
|
||||
}
|
||||
onProg = (_e, prog) => {
|
||||
lastProgress = prog
|
||||
status = prog?.percentage >= 100 ? 'loading' : 'downloading'
|
||||
opts.onProgress?.(prog)
|
||||
}
|
||||
ipc.on('peardata:qvac-progress', onProg)
|
||||
const result = await ipc.invoke('peardata:qvac-load', {
|
||||
chatModel: p.chatModel,
|
||||
tools: p.tools,
|
||||
ctxSize: p.ctxSize || 8192,
|
||||
})
|
||||
if (!result?.ok) {
|
||||
status = 'fallback'
|
||||
const msg = result?.error || 'load failed'
|
||||
sdkError = msg
|
||||
deps.log?.(`QVAC main load failed: ${msg}`)
|
||||
return { ok: false, error: msg, mode: 'fallback', profile: p.id }
|
||||
}
|
||||
modelId = result.modelId
|
||||
status = 'ready'
|
||||
touchActivity()
|
||||
deps.log?.(`QVAC model ready (main): ${p.chatModel} → ${modelId}`)
|
||||
return { ok: true, mode: 'qvac', modelId, profile: p.id }
|
||||
} catch (err) {
|
||||
const msg = err?.message || String(err)
|
||||
status = 'fallback'
|
||||
sdkError = msg
|
||||
deps.log?.(`QVAC load failed: ${msg}`)
|
||||
return { ok: false, error: msg, mode: 'fallback', profile: p.id }
|
||||
} finally {
|
||||
if (onProg) {
|
||||
try {
|
||||
ipc.removeListener('peardata:qvac-progress', onProg)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = await tryLoadSdk()
|
||||
if (!s) {
|
||||
if (!s || s.__viaMain) {
|
||||
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({
|
||||
@@ -219,7 +386,7 @@ export function createQvacEngine(deps) {
|
||||
modelType: 'llm',
|
||||
modelConfig: {
|
||||
tools: p.tools,
|
||||
ctx_size: 4096,
|
||||
ctx_size: p.ctxSize || 8192,
|
||||
},
|
||||
onProgress: (prog) => {
|
||||
lastProgress = prog
|
||||
@@ -248,7 +415,13 @@ export function createQvacEngine(deps) {
|
||||
clearTimeout(idleTimer)
|
||||
idleTimer = null
|
||||
}
|
||||
if (sdk && modelId && sdk.unloadModel) {
|
||||
if (ipc && sdkMode === 'main') {
|
||||
try {
|
||||
await ipc.invoke('peardata:qvac-unload')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else if (sdk && modelId && sdk.unloadModel) {
|
||||
try {
|
||||
await sdk.unloadModel({ modelId })
|
||||
} catch {
|
||||
@@ -274,13 +447,14 @@ export function createQvacEngine(deps) {
|
||||
const userLast = [...history].reverse().find((m) => m.role === 'user')
|
||||
let system = buildSystemPrompt(deps.getContext?.() || {})
|
||||
|
||||
// Light RAG only — full catalog dumps blow small context windows
|
||||
if (prefs.rag !== false && userLast?.content) {
|
||||
const rag = buildRagContext({
|
||||
query: userLast.content,
|
||||
catalog: deps.getCatalog?.() || {},
|
||||
topK: 5,
|
||||
topK: 3,
|
||||
})
|
||||
if (rag) system += `\n\n${rag}`
|
||||
if (rag) system += `\n\n${rag.slice(0, 1200)}`
|
||||
}
|
||||
|
||||
const fullHistory = [
|
||||
@@ -288,7 +462,7 @@ export function createQvacEngine(deps) {
|
||||
...history.filter((m) => m.role !== 'system'),
|
||||
]
|
||||
|
||||
if (sdk && modelId && sdk.completion) {
|
||||
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
|
||||
return completeWithSdk(fullHistory, profile, opts)
|
||||
}
|
||||
|
||||
@@ -312,17 +486,142 @@ export function createQvacEngine(deps) {
|
||||
}
|
||||
|
||||
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 }))
|
||||
let toolDefs = profile.tools
|
||||
? deps.tools.defsForRole({ profileId: profile.id, profile })
|
||||
: undefined
|
||||
const ctxSize = profile.ctxSize || 8192
|
||||
let toolsTok = estimateToolsTokens(toolDefs)
|
||||
let budget = promptBudget(ctxSize, toolsTok)
|
||||
let overflowRetries = 0
|
||||
let messages = compactMessages(
|
||||
history.map((m) => ({ role: m.role, content: m.content, name: m.name })),
|
||||
{ maxTokens: budget, keepRecentUserTurns: 3, maxToolChars: 2000 }
|
||||
)
|
||||
|
||||
for (let round = 0; round < 4; round++) {
|
||||
touchActivity()
|
||||
// Re-compact each tool round (payloads grow fast)
|
||||
messages = compactMessages(messages, {
|
||||
maxTokens: budget,
|
||||
keepRecentUserTurns: round === 0 ? 3 : 2,
|
||||
maxToolChars: round === 0 ? 2000 : 1000,
|
||||
maxMsgChars: round === 0 ? 3000 : 1600,
|
||||
})
|
||||
|
||||
// Electron main-process completion (streaming via IPC events)
|
||||
if (sdkMode === 'main' && ipc) {
|
||||
/** @type {(( _e: any, t: string) => void)|null} */
|
||||
let onTok = null
|
||||
/** @type {(( _e: any, t: string) => void)|null} */
|
||||
let onThink = null
|
||||
try {
|
||||
onTok = (_e, t) => opts.onToken?.(t)
|
||||
onThink = (_e, t) => opts.onThinking?.(t)
|
||||
ipc.on('peardata:qvac-token', onTok)
|
||||
ipc.on('peardata:qvac-thinking', onThink)
|
||||
const final = await ipc.invoke('peardata:qvac-complete', {
|
||||
history: messages,
|
||||
tools: toolDefs,
|
||||
})
|
||||
if (final?.error && !final.contentText) {
|
||||
if (isContextOverflowError(final.error) && overflowRetries < 3) {
|
||||
overflowRetries++
|
||||
budget = Math.floor(budget * 0.55)
|
||||
if (overflowRetries === 1 && toolDefs?.length) {
|
||||
// First overflow: drop deep tools, keep core
|
||||
toolDefs = deps.tools.defsForRole({
|
||||
profileId: profile.id,
|
||||
depth: 'core',
|
||||
})
|
||||
toolsTok = estimateToolsTokens(toolDefs)
|
||||
budget = promptBudget(ctxSize, toolsTok)
|
||||
} else if (overflowRetries >= 2) {
|
||||
// Drop tools schema on last retries — frees a lot of context
|
||||
toolDefs = undefined
|
||||
toolsTok = 0
|
||||
budget = promptBudget(ctxSize, 0)
|
||||
}
|
||||
messages = compactMessages(messages, {
|
||||
maxTokens: Math.max(600, budget),
|
||||
keepRecentUserTurns: 1,
|
||||
maxToolChars: 500,
|
||||
maxMsgChars: 900,
|
||||
})
|
||||
deps.log?.(
|
||||
`QVAC context overflow — compact #${overflowRetries} budget~${budget}`
|
||||
)
|
||||
opts.onToken?.(
|
||||
'\n_[Context compacted to fit the model window — retrying…]_\n'
|
||||
)
|
||||
round = Math.max(-1, round - 1) // retry this tool-loop round
|
||||
continue
|
||||
}
|
||||
return {
|
||||
contentText: `Model error: ${final.error}`,
|
||||
toolCalls: [],
|
||||
mode: 'fallback',
|
||||
}
|
||||
}
|
||||
const calls = (final.toolCalls || []).filter(Boolean)
|
||||
// Prefer raw content (may include <think>); UI partitions it
|
||||
let text = final.contentText || ''
|
||||
if (final.thinkingText && !/<think/i.test(text)) {
|
||||
text = `<think>\n${final.thinkingText}\n</think>\n${text}`
|
||||
}
|
||||
if (!calls.length) {
|
||||
return {
|
||||
contentText: text,
|
||||
toolCalls: [],
|
||||
mode: 'qvac',
|
||||
stats: final.stats,
|
||||
}
|
||||
}
|
||||
messages = [...messages, { role: 'assistant', content: text }]
|
||||
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)
|
||||
// Compact tool results for model context (keep numbers, drop noise)
|
||||
const payload = compactToolResult(name, result)
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: payload,
|
||||
name,
|
||||
})
|
||||
}
|
||||
continue
|
||||
} finally {
|
||||
if (onTok) {
|
||||
try {
|
||||
ipc.removeListener('peardata:qvac-token', onTok)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (onThink) {
|
||||
try {
|
||||
ipc.removeListener('peardata:qvac-thinking', onThink)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const run = sdk.completion({
|
||||
modelId,
|
||||
history: messages,
|
||||
stream: true,
|
||||
tools: toolDefs,
|
||||
captureThinking: false,
|
||||
captureThinking: true,
|
||||
})
|
||||
|
||||
let content = ''
|
||||
@@ -332,14 +631,17 @@ export function createQvacEngine(deps) {
|
||||
if (run.events) {
|
||||
for await (const ev of run.events) {
|
||||
opts.onEvent?.(ev)
|
||||
if (ev.type === 'contentDelta' && ev.text) {
|
||||
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
|
||||
opts.onThinking?.(ev.text || ev.delta)
|
||||
} else if (ev.type === 'contentDelta' && ev.text) {
|
||||
content += ev.text
|
||||
opts.onToken?.(ev.text)
|
||||
} else if (ev.type === 'toolCall') {
|
||||
const call = ev.call || ev.toolCall || ev
|
||||
toolCalls.push({
|
||||
name: ev.name || ev.toolCall?.name,
|
||||
arguments: ev.arguments || ev.toolCall?.arguments || {},
|
||||
id: ev.id || ev.toolCall?.id,
|
||||
name: call.name || ev.name,
|
||||
arguments: call.arguments || ev.arguments || {},
|
||||
id: call.id || ev.id,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -377,7 +679,7 @@ export function createQvacEngine(deps) {
|
||||
opts.onTool?.(name, args, result)
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: JSON.stringify(result).slice(0, 12_000),
|
||||
content: compactToolResult(name, result),
|
||||
name,
|
||||
})
|
||||
}
|
||||
@@ -400,3 +702,107 @@ export function createQvacEngine(deps) {
|
||||
touchActivity,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep tool JSON small enough for small local models while preserving KPIs.
|
||||
* @param {string} name
|
||||
* @param {any} result
|
||||
*/
|
||||
function compactToolResult(name, result) {
|
||||
try {
|
||||
if (result == null) return 'null'
|
||||
if (typeof result === 'string') return result.slice(0, 8_000)
|
||||
if (result.error) return JSON.stringify({ error: result.error }).slice(0, 4_000)
|
||||
|
||||
if (name === 'investigate_host' && typeof result === 'object') {
|
||||
const slim = {
|
||||
hostname: result.hostname,
|
||||
summary: result.summary,
|
||||
findings: (result.findings || []).slice(0, 16),
|
||||
kpis: result.kpis,
|
||||
health: result.health,
|
||||
hot: (result.hot || []).slice(0, 10),
|
||||
processes: result.processes?.top
|
||||
? {
|
||||
supported: result.processes.supported,
|
||||
top: (result.processes.top || []).slice(0, 10),
|
||||
}
|
||||
: result.processes,
|
||||
catalog: result.catalog,
|
||||
}
|
||||
return JSON.stringify(slim).slice(0, 12_000)
|
||||
}
|
||||
|
||||
if (name === 'host_snapshot' && typeof result === 'object') {
|
||||
const slim = {
|
||||
hostname: result.hostname,
|
||||
health: result.health,
|
||||
kpis: result.kpis,
|
||||
anomalies: (result.anomalies || []).slice(0, 8),
|
||||
alerts: (result.alerts || []).slice(0, 8),
|
||||
catalog: result.catalog,
|
||||
cores: result.cores,
|
||||
totalRamBytes: result.totalRamBytes,
|
||||
freeRamBytes: result.freeRamBytes,
|
||||
}
|
||||
return JSON.stringify(slim).slice(0, 10_000)
|
||||
}
|
||||
|
||||
if (
|
||||
(name === 'summarize_chart' || name === 'compare_chart_windows') &&
|
||||
typeof result === 'object'
|
||||
) {
|
||||
return JSON.stringify(result).slice(0, 8_000)
|
||||
}
|
||||
|
||||
if (name === 'summarize_charts' && result.results) {
|
||||
return JSON.stringify({
|
||||
after: result.after,
|
||||
count: result.count,
|
||||
results: (result.results || []).slice(0, 12),
|
||||
}).slice(0, 10_000)
|
||||
}
|
||||
|
||||
if (name === 'hot_metrics' && result.results) {
|
||||
return JSON.stringify({
|
||||
window: result.window,
|
||||
count: result.count,
|
||||
results: (result.results || []).slice(0, 15),
|
||||
}).slice(0, 8_000)
|
||||
}
|
||||
|
||||
if (name === 'related_charts' && result.results) {
|
||||
return JSON.stringify({
|
||||
chart: result.chart,
|
||||
count: result.count,
|
||||
results: (result.results || []).slice(0, 12),
|
||||
}).slice(0, 6_000)
|
||||
}
|
||||
|
||||
if (name === 'list_processes' && result.processes) {
|
||||
return JSON.stringify({
|
||||
processes: (result.processes || []).slice(0, 12).map((p) => ({
|
||||
pid: p.pid,
|
||||
name: p.name || p.comm,
|
||||
cpu: p.cpu,
|
||||
rss: p.rss,
|
||||
})),
|
||||
}).slice(0, 6_000)
|
||||
}
|
||||
|
||||
if (name === 'query_metric' && typeof result === 'object') {
|
||||
// Raw series can be huge — keep labels + last N rows
|
||||
const data = Array.isArray(result.data) ? result.data.slice(-40) : result.data
|
||||
return JSON.stringify({
|
||||
labels: result.labels,
|
||||
points: data?.length,
|
||||
data,
|
||||
error: result.error,
|
||||
}).slice(0, 8_000)
|
||||
}
|
||||
|
||||
return JSON.stringify(result).slice(0, 10_000)
|
||||
} catch {
|
||||
return String(result).slice(0, 4_000)
|
||||
}
|
||||
}
|
||||
|
||||
+290
-75
@@ -5,6 +5,7 @@ import { createQvacEngine } from './engine.js'
|
||||
import { createToolRunner } from './tools.js'
|
||||
import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
|
||||
import { SAMPLE_PROMPTS } from './prompts.js'
|
||||
import { partitionThink, renderAssistantHtml } from './think.js'
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
@@ -160,7 +161,9 @@ export function createQvacView(opts) {
|
||||
const sdkLine = env.checking
|
||||
? '<li class="warn">QVAC SDK: checking…</li>'
|
||||
: `<li class="${env.sdkAvailable ? 'ok' : 'warn'}">QVAC SDK: ${
|
||||
env.sdkAvailable ? 'available' : 'not installed — tools-only mode'
|
||||
env.sdkAvailable
|
||||
? 'package found (loads only when you download a model)'
|
||||
: 'not installed — tools-only mode'
|
||||
}</li>`
|
||||
body.innerHTML = `
|
||||
<ul class="qvac-check-list">
|
||||
@@ -228,7 +231,7 @@ export function createQvacView(opts) {
|
||||
}
|
||||
|
||||
if (wizardStep === 1) {
|
||||
// Always paint a complete card with actions first — never wait on SDK import.
|
||||
// Paint immediately. Never import @qvac/sdk here — that freezes packaged Electron.
|
||||
const card = stepCard(
|
||||
'System check',
|
||||
'<p class="muted">Preparing environment check…</p>',
|
||||
@@ -245,27 +248,30 @@ export function createQvacView(opts) {
|
||||
paintSystemCheck(card, quick)
|
||||
bindSystemCheckActions(card)
|
||||
|
||||
engine
|
||||
.checkEnvironment({ probeSdk: true, timeoutMs: 2500 })
|
||||
.then((env) => {
|
||||
if (wizardStep !== 1) return
|
||||
if (!card.isConnected) return
|
||||
paintSystemCheck(card, { ...env, checking: false })
|
||||
bindSystemCheckActions(card)
|
||||
syncModelChip()
|
||||
})
|
||||
.catch((err) => {
|
||||
if (wizardStep !== 1 || !card.isConnected) return
|
||||
paintSystemCheck(card, {
|
||||
checking: false,
|
||||
sdkAvailable: false,
|
||||
sdkError: err?.message || String(err),
|
||||
platform: quick.platform,
|
||||
arch: quick.arch,
|
||||
totalRamBytes: null,
|
||||
// Yield a frame so the card paints before any async work
|
||||
requestAnimationFrame(() => {
|
||||
engine
|
||||
.checkEnvironment({ probeSdk: false })
|
||||
.then((env) => {
|
||||
if (wizardStep !== 1) return
|
||||
if (!card.isConnected) return
|
||||
paintSystemCheck(card, { ...env, checking: false })
|
||||
bindSystemCheckActions(card)
|
||||
syncModelChip()
|
||||
})
|
||||
bindSystemCheckActions(card)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (wizardStep !== 1 || !card.isConnected) return
|
||||
paintSystemCheck(card, {
|
||||
checking: false,
|
||||
sdkAvailable: false,
|
||||
sdkError: err?.message || String(err),
|
||||
platform: quick.platform,
|
||||
arch: quick.arch,
|
||||
totalRamBytes: null,
|
||||
})
|
||||
bindSystemCheckActions(card)
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -300,7 +306,17 @@ export function createQvacView(opts) {
|
||||
onClick: () => {
|
||||
wizardStep = 3
|
||||
renderWizard()
|
||||
startLoad()
|
||||
// Yield so the loading card paints before main-process model load starts
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
startLoad().catch((err) => {
|
||||
opts.log?.(`QVAC startLoad: ${err?.message || err}`)
|
||||
finishToolsOnly(
|
||||
`Model load failed (${err?.message || err}). Continuing in tools-only mode.`
|
||||
)
|
||||
})
|
||||
}, 50)
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -450,31 +466,156 @@ export function createQvacView(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} role
|
||||
* @param {string} content
|
||||
* @param {{ tools?: any[], streaming?: boolean }} [meta]
|
||||
*/
|
||||
function appendMsg(role, content, meta = {}) {
|
||||
messages.push({ role, content, tools: meta.tools })
|
||||
const entry = { role, content, tools: meta.tools, thinking: '' }
|
||||
messages.push(entry)
|
||||
const list = opts.els.messages
|
||||
if (!list) return
|
||||
if (!list) return null
|
||||
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)
|
||||
if (role === 'assistant') {
|
||||
body.innerHTML = renderAssistantHtml(content, formatMdLite, {
|
||||
openThink: Boolean(meta.streaming),
|
||||
})
|
||||
} else {
|
||||
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)
|
||||
div.appendChild(renderToolChips(meta.tools))
|
||||
}
|
||||
list.appendChild(div)
|
||||
list.scrollTop = list.scrollHeight
|
||||
return body
|
||||
return { div, body, entry }
|
||||
}
|
||||
|
||||
function renderToolChips(tools) {
|
||||
const chips = document.createElement('div')
|
||||
chips.className = 'qvac-tool-chips'
|
||||
for (const t of tools) {
|
||||
const c = document.createElement('span')
|
||||
c.className = 'qvac-tool-chip'
|
||||
c.textContent = t.name
|
||||
const preview =
|
||||
t.result != null
|
||||
? JSON.stringify(t.result).slice(0, 280)
|
||||
: JSON.stringify(t.args || {}).slice(0, 200)
|
||||
c.title = preview
|
||||
chips.appendChild(c)
|
||||
}
|
||||
return chips
|
||||
}
|
||||
|
||||
/** Near bottom of a scroll container? (for stick-to-bottom while streaming) */
|
||||
function isNearBottom(el, threshold = 56) {
|
||||
if (!el) return true
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the think panel pinned to the latest tokens while streaming.
|
||||
* Uses rAF so rapid token events coalesce into one smooth follow.
|
||||
* @param {HTMLElement|null|undefined} thinkBody
|
||||
* @param {{ force?: boolean }} [opts]
|
||||
*/
|
||||
function followThinkScroll(thinkBody, { force = true } = {}) {
|
||||
if (!thinkBody) return
|
||||
if (!force && !isNearBottom(thinkBody)) return
|
||||
const run = () => {
|
||||
thinkBody._qvacScrollRaf = 0
|
||||
// Instant pin each frame — feels continuous as text grows (smooth
|
||||
// scroll-behavior fights high-frequency stream updates).
|
||||
thinkBody.scrollTop = thinkBody.scrollHeight
|
||||
}
|
||||
if (thinkBody._qvacScrollRaf) cancelAnimationFrame(thinkBody._qvacScrollRaf)
|
||||
thinkBody._qvacScrollRaf = requestAnimationFrame(run)
|
||||
}
|
||||
|
||||
/** Keep the chat list following the live assistant message. */
|
||||
function followMessagesScroll({ force = true } = {}) {
|
||||
const list = opts.els.messages
|
||||
if (!list) return
|
||||
if (!force && !isNearBottom(list)) return
|
||||
if (list._qvacScrollRaf) cancelAnimationFrame(list._qvacScrollRaf)
|
||||
list._qvacScrollRaf = requestAnimationFrame(() => {
|
||||
list._qvacScrollRaf = 0
|
||||
list.scrollTop = list.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint / update assistant HTML. While streaming with an open think panel,
|
||||
* updates the think body in place so scroll position can stay pinned.
|
||||
* @param {HTMLElement|null|undefined} bodyEl
|
||||
* @param {string} raw
|
||||
* @param {boolean} streaming
|
||||
*/
|
||||
function paintAssistant(bodyEl, raw, streaming) {
|
||||
if (!bodyEl) return
|
||||
const parts = partitionThink(raw)
|
||||
let details = bodyEl.querySelector('details.qvac-think')
|
||||
let thinkBody = bodyEl.querySelector('.qvac-think-body')
|
||||
let answerEl = bodyEl.querySelector('.qvac-msg-answer')
|
||||
|
||||
// Fast path: structure already exists and we still have thinking text —
|
||||
// update DOM in place so the think scroller doesn't jump to top each token.
|
||||
if (parts.thinking && details && thinkBody) {
|
||||
details.open = true
|
||||
const summary = details.querySelector('.qvac-think-summary')
|
||||
let live = summary?.querySelector('.qvac-think-live')
|
||||
const showLive = Boolean(streaming || parts.thinkingOpen)
|
||||
if (showLive && summary && !live) {
|
||||
live = document.createElement('span')
|
||||
live.className = 'qvac-think-live'
|
||||
live.textContent = 'live'
|
||||
summary.appendChild(live)
|
||||
} else if (!showLive && live) {
|
||||
live.remove()
|
||||
}
|
||||
|
||||
const stick = streaming || isNearBottom(thinkBody)
|
||||
thinkBody.innerHTML = formatMdLite(parts.thinking)
|
||||
if (stick) followThinkScroll(thinkBody, { force: true })
|
||||
|
||||
if (parts.answer) {
|
||||
if (!answerEl) {
|
||||
answerEl = document.createElement('div')
|
||||
answerEl.className = 'qvac-msg-answer'
|
||||
bodyEl.appendChild(answerEl)
|
||||
}
|
||||
answerEl.className = 'qvac-msg-answer'
|
||||
answerEl.innerHTML = formatMdLite(parts.answer)
|
||||
} else if (streaming || parts.thinkingOpen) {
|
||||
if (!answerEl) {
|
||||
answerEl = document.createElement('div')
|
||||
bodyEl.appendChild(answerEl)
|
||||
}
|
||||
answerEl.className = 'qvac-msg-answer qvac-msg-pending muted'
|
||||
answerEl.textContent = 'Working…'
|
||||
} else if (answerEl) {
|
||||
answerEl.remove()
|
||||
}
|
||||
|
||||
if (streaming) followMessagesScroll({ force: true })
|
||||
return
|
||||
}
|
||||
|
||||
// Full re-render (first paint, or no think block)
|
||||
bodyEl.innerHTML = renderAssistantHtml(raw, formatMdLite, { openThink: streaming })
|
||||
thinkBody = bodyEl.querySelector('.qvac-think-body')
|
||||
if (streaming || parts.thinkingOpen) {
|
||||
followThinkScroll(thinkBody, { force: true })
|
||||
followMessagesScroll({ force: true })
|
||||
} else if (thinkBody && isNearBottom(thinkBody)) {
|
||||
followThinkScroll(thinkBody, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
@@ -485,49 +626,67 @@ export function createQvacView(opts) {
|
||||
busy = true
|
||||
opts.els.sendBtn && (opts.els.sendBtn.disabled = true)
|
||||
appendMsg('user', text)
|
||||
const streamBody = appendMsg('assistant', '…')
|
||||
const assistantUi = appendMsg('assistant', '…', { streaming: true })
|
||||
const streamBody = assistantUi?.body
|
||||
const toolLog = []
|
||||
let acc = ''
|
||||
let thinkingAcc = ''
|
||||
setStatus('Thinking…', 'busy')
|
||||
try {
|
||||
// History for the model: clean answers only (no think tags)
|
||||
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 }))
|
||||
.slice(0, -1)
|
||||
.map((m) => {
|
||||
if (m.role !== 'assistant') return { role: m.role, content: m.content }
|
||||
const { answer } = partitionThink(m.content)
|
||||
return { role: 'assistant', content: answer || 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)
|
||||
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
|
||||
},
|
||||
onThinking: (t) => {
|
||||
thinkingAcc += t
|
||||
// If model streams think separately, wrap for partitioner
|
||||
const raw = thinkingAcc
|
||||
? `<think>\n${thinkingAcc}\n</think>\n${acc}`
|
||||
: acc
|
||||
paintAssistant(streamBody, raw, true)
|
||||
},
|
||||
onTool: (name, args, res) => {
|
||||
toolLog.push({ name, args, result: res })
|
||||
setStatus(`Tool: ${name}…`, 'busy')
|
||||
// Live tool chips while model works
|
||||
if (assistantUi?.div) {
|
||||
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
|
||||
if (chips) chips.remove()
|
||||
assistantUi.div.appendChild(renderToolChips(toolLog))
|
||||
followMessagesScroll({ force: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
acc = result.contentText || acc
|
||||
if (streamBody) streamBody.innerHTML = formatMdLite(acc)
|
||||
// update last message in state
|
||||
if (thinkingAcc && !/<think/i.test(acc)) {
|
||||
acc = `<think>\n${thinkingAcc}\n</think>\n${acc}`
|
||||
}
|
||||
paintAssistant(streamBody, acc, false)
|
||||
followMessagesScroll({ force: true })
|
||||
const parts = partitionThink(acc)
|
||||
const last = messages[messages.length - 1]
|
||||
if (last?.role === 'assistant') {
|
||||
// Store full text (with think) for UI re-open; history path strips think
|
||||
last.content = acc
|
||||
last.thinking = parts.thinking
|
||||
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)
|
||||
}
|
||||
if (toolLog.length && assistantUi?.div) {
|
||||
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
|
||||
if (chips) chips.remove()
|
||||
assistantUi.div.appendChild(renderToolChips(toolLog))
|
||||
}
|
||||
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
|
||||
} catch (err) {
|
||||
@@ -541,6 +700,14 @@ export function createQvacView(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Merge separate thinking stream + content stream for progressive UI. */
|
||||
function mergeThinkStream(thinking, content) {
|
||||
if (thinking && !/<think/i.test(content)) {
|
||||
return `<think>\n${thinking}\n</think>\n${content}`
|
||||
}
|
||||
return content || (thinking ? `<think>\n${thinking}` : '…')
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
messages = []
|
||||
if (opts.els.messages) opts.els.messages.innerHTML = ''
|
||||
@@ -574,33 +741,81 @@ export function createQvacView(opts) {
|
||||
opts.els.newChatBtn?.addEventListener('click', () => newChat())
|
||||
}
|
||||
|
||||
/** @type {Promise<void>|null} */
|
||||
let restorePromise = null
|
||||
|
||||
/**
|
||||
* If onboarding finished with a full model, reload it via main-process IPC
|
||||
* (never require SDK in the renderer).
|
||||
*/
|
||||
async function restoreSavedModel() {
|
||||
const mode = settings().qvacMode
|
||||
const profile = settings().qvacProfile || 'recommended'
|
||||
if (mode !== 'qvac' || !profile) return
|
||||
const st = engine.getStatus()
|
||||
if (st.status === 'ready' && st.sdkLoaded) return
|
||||
if (st.status === 'downloading' || st.status === 'loading') return
|
||||
|
||||
setStatus('Loading saved model…', 'busy')
|
||||
syncModelChip()
|
||||
try {
|
||||
const result = await engine.loadProfile(profile, {
|
||||
onProgress: (p) => {
|
||||
const pct = p?.percentage
|
||||
if (pct != null) setStatus(`Loading model ${Number(pct).toFixed(0)}%…`, 'busy')
|
||||
syncModelChip()
|
||||
},
|
||||
})
|
||||
if (result.mode === 'qvac' && result.ok !== false) {
|
||||
persist({ qvacMode: 'qvac', qvacProfile: profile })
|
||||
setStatus('Ready', 'ok')
|
||||
} else {
|
||||
persist({ qvacMode: result.mode || 'fallback' })
|
||||
setStatus(
|
||||
result.error
|
||||
? `Ready (tools-only: ${result.error})`
|
||||
: 'Ready (tools-only)',
|
||||
'warn'
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus(`Ready (tools-only: ${err?.message || err})`, 'warn')
|
||||
}
|
||||
syncModelChip()
|
||||
}
|
||||
|
||||
function enter() {
|
||||
showPane()
|
||||
renderSamples()
|
||||
syncModelChip()
|
||||
if (isOnboarded() && !messages.length) {
|
||||
// Auto warm fallback path; full model load is manual after onboarding
|
||||
engine.tryLoadSdk().then(() => {
|
||||
if (isOnboarded()) {
|
||||
// Restore model in background if user previously completed Download & load
|
||||
if (!restorePromise) {
|
||||
restorePromise = restoreSavedModel().finally(() => {
|
||||
restorePromise = null
|
||||
})
|
||||
}
|
||||
if (!messages.length) {
|
||||
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.'
|
||||
)
|
||||
appendMsg(
|
||||
'assistant',
|
||||
mode === 'qvac'
|
||||
? 'QVAC ready — restoring your saved model if needed. Ask about host health, metrics, or processes.'
|
||||
: 'QVAC ready (tools-only). Use **Setup → Download & load** for full local chat, or ask for live metrics now.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bind()
|
||||
|
||||
// Warm model as soon as the view is constructed (if already onboarded)
|
||||
if (isOnboarded() && settings().qvacMode === 'qvac') {
|
||||
restorePromise = restoreSavedModel().finally(() => {
|
||||
restorePromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
enter,
|
||||
leave: () => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* minRamGb: number,
|
||||
* minDiskGb: number,
|
||||
* approxDownloadGb: number,
|
||||
* ctxSize: number,
|
||||
* }} QvacProfile
|
||||
*/
|
||||
|
||||
@@ -31,6 +32,7 @@ export const QVAC_PROFILES = {
|
||||
minRamGb: 4,
|
||||
minDiskGb: 2,
|
||||
approxDownloadGb: 0.5,
|
||||
ctxSize: 4096,
|
||||
},
|
||||
recommended: {
|
||||
id: 'recommended',
|
||||
@@ -42,6 +44,7 @@ export const QVAC_PROFILES = {
|
||||
minRamGb: 8,
|
||||
minDiskGb: 5,
|
||||
approxDownloadGb: 2.5,
|
||||
ctxSize: 8192,
|
||||
},
|
||||
strong: {
|
||||
id: 'strong',
|
||||
@@ -53,6 +56,7 @@ export const QVAC_PROFILES = {
|
||||
minRamGb: 16,
|
||||
minDiskGb: 8,
|
||||
approxDownloadGb: 3.5,
|
||||
ctxSize: 8192,
|
||||
},
|
||||
'tool-tiny': {
|
||||
id: 'tool-tiny',
|
||||
@@ -64,6 +68,7 @@ export const QVAC_PROFILES = {
|
||||
minRamGb: 6,
|
||||
minDiskGb: 3,
|
||||
approxDownloadGb: 1,
|
||||
ctxSize: 4096,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+22
-2
@@ -20,22 +20,42 @@ export function buildSystemPrompt(ctx = {}) {
|
||||
'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.',
|
||||
'- Never invent metric values, timestamps, or alert states. Always call tools first for live data.',
|
||||
'- 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.',
|
||||
'',
|
||||
'Tool playbook (use these exact tool names and chart ids):',
|
||||
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).',
|
||||
' Lighter alternative: host_snapshot. Do NOT use md.health for general health.',
|
||||
'- Spikes / unusual activity → hot_metrics, then summarize_chart or compare_chart_windows on top hits.',
|
||||
'- CPU high / load → investigate_host or host_snapshot + summarize_chart chart=system.cpu + list_processes.',
|
||||
'- Memory → summarize_chart chart=system.ram (and search_charts q=mem if needed).',
|
||||
'- Disk / IO / docker / redis / nginx / postgres → search_charts, then summarize_chart on a concrete id.',
|
||||
'- Follow-up on one chart → related_charts, get_weights, compare_chart_windows, summarize_charts (batch).',
|
||||
'- Alerts / anomalies → list_anomalies and/or list_alerts; get_alert for one id.',
|
||||
'- Fleet → fleet_health + list_child_peers.',
|
||||
'- Storage / retention / prune questions → storage_info (+ local_knowledge for product how-to).',
|
||||
'- Logs → query_logs source=anomaly (or journal/audit when role allows).',
|
||||
'- Agent meta → agent_health, node_info, db_info, list_contexts, list_jobs.',
|
||||
'- Product how-to → local_knowledge; do not invent UI steps.',
|
||||
'- Operator actions (silence_alert, ack_alert, run_job): explain first, then call with confirmed=true only after user agrees.',
|
||||
'- NEVER use chart id "md.health" for general host health — that is MD RAID only and is often empty.',
|
||||
'- Prefer chart ids that appear in tool results. If summarize_chart returns points=0, try investigate_host or another chart.',
|
||||
'- After tools return, give a clear human summary with numbers; do not only restate empty charts.',
|
||||
'',
|
||||
`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',
|
||||
"What's wrong — investigate this host",
|
||||
'Why might CPU be high?',
|
||||
'Show hot / spiking metrics',
|
||||
'List charts related to disk or io',
|
||||
'Any open alerts or anomalies?',
|
||||
'Top processes by CPU if available',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Split Qwen-style thinking blocks from assistant text for UI.
|
||||
* Supports complete and in-progress streams.
|
||||
*
|
||||
* Common tags: <think>…</think>, <think>…</think>, <thinking>…
|
||||
*/
|
||||
|
||||
const TAG = String.raw`think|thinking|redacted[_-]?thinking|redacted[_-]?reasoning`
|
||||
const THINK_OPEN = new RegExp(`<\\s*(?:${TAG})\\s*>`, 'i')
|
||||
const THINK_PAIR = new RegExp(
|
||||
`<\\s*(?:${TAG})\\s*>([\\s\\S]*?)<\\s*/\\s*(?:${TAG})\\s*>`,
|
||||
'gi'
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {{ thinking: string, answer: string, thinkingOpen: boolean }}
|
||||
*/
|
||||
export function partitionThink(text) {
|
||||
const full = String(text || '')
|
||||
/** @type {string[]} */
|
||||
const blocks = []
|
||||
let answer = full.replace(THINK_PAIR, (_, body) => {
|
||||
const t = String(body || '').trim()
|
||||
if (t) blocks.push(t)
|
||||
return ''
|
||||
})
|
||||
|
||||
// Incomplete open block at end of stream
|
||||
let thinkingOpen = false
|
||||
const openMatch = answer.match(THINK_OPEN)
|
||||
if (openMatch && openMatch.index != null) {
|
||||
thinkingOpen = true
|
||||
const openIdx = openMatch.index
|
||||
const after = answer.slice(openIdx + openMatch[0].length)
|
||||
if (after.trim()) blocks.push(after.trim())
|
||||
answer = answer.slice(0, openIdx)
|
||||
}
|
||||
|
||||
return {
|
||||
thinking: blocks.join('\n\n').trim(),
|
||||
answer: answer.replace(/^\s+/, '').replace(/\s+$/, ''),
|
||||
thinkingOpen,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render assistant body HTML: optional collapsible think + answer.
|
||||
* @param {string} raw
|
||||
* @param {(s: string) => string} formatBody
|
||||
* @param {{ openThink?: boolean }} [opts]
|
||||
*/
|
||||
export function renderAssistantHtml(raw, formatBody, opts = {}) {
|
||||
const { thinking, answer, thinkingOpen } = partitionThink(raw)
|
||||
const open = opts.openThink !== false && (thinkingOpen || Boolean(thinking))
|
||||
let html = ''
|
||||
if (thinking) {
|
||||
html += `<details class="qvac-think"${open ? ' open' : ''}>
|
||||
<summary class="qvac-think-summary">
|
||||
<span class="qvac-think-ico" aria-hidden="true">◐</span>
|
||||
<span>Thinking</span>
|
||||
${thinkingOpen ? '<span class="qvac-think-live">live</span>' : ''}
|
||||
</summary>
|
||||
<div class="qvac-think-body">${formatBody(thinking)}</div>
|
||||
</details>`
|
||||
}
|
||||
const ans = answer || (!thinking ? '…' : '')
|
||||
if (ans) {
|
||||
html += `<div class="qvac-msg-answer">${formatBody(ans)}</div>`
|
||||
} else if (thinkingOpen) {
|
||||
html += `<div class="qvac-msg-answer qvac-msg-pending muted">Working…</div>`
|
||||
}
|
||||
return html || formatBody(raw || '…')
|
||||
}
|
||||
+605
-172
@@ -1,199 +1,472 @@
|
||||
/**
|
||||
* QVAC tool schemas + handlers → PearData RPC / UI navigation.
|
||||
*
|
||||
* Tool defs use the **@qvac/sdk** wire shape (flat), not OpenAI nested
|
||||
* `{ type, function: { name, parameters } }`:
|
||||
* { type: 'function', name, description, parameters: { type:'object', properties, required? } }
|
||||
* Property values may only include type / description / enum (see @qvac/sdk toolSchema).
|
||||
*
|
||||
* Tools are tiered so small-context models stay lean:
|
||||
* core → always (tool-tiny + recommended + strong)
|
||||
* deep → recommended + strong (investigation / fleet / storage)
|
||||
* write → operator+ only (silence / ack / run_job)
|
||||
*/
|
||||
import { Methods, Roles, roleAllows } from '../../shared/protocol.js'
|
||||
import { buildRagContext } from './rag.js'
|
||||
|
||||
/** @param {Record<string, { type: string, description?: string, enum?: any[] }>} properties */
|
||||
function params(properties = {}, required) {
|
||||
const out = {
|
||||
type: 'object',
|
||||
properties: properties || {},
|
||||
}
|
||||
if (required?.length) out.required = required
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-style tool definitions for QVAC completion({ tools }).
|
||||
* @typedef {'core'|'deep'|'write'} ToolTier
|
||||
* @typedef {{ type: 'function', name: string, description: string, parameters: object, tier?: ToolTier }} ToolDef
|
||||
*/
|
||||
|
||||
/** @type {ToolDef[]} */
|
||||
export const TOOL_DEFS = [
|
||||
// ── core: diagnosis + navigation ─────────────────────────────────────
|
||||
{
|
||||
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 },
|
||||
},
|
||||
name: 'investigate_host',
|
||||
tier: 'core',
|
||||
description:
|
||||
'BEST first call for "what\'s wrong" / diagnose / investigate. One shot: findings ranked by severity, KPIs, hot charts, top processes, anomalies/alerts. Prefer this over calling host_snapshot + list_processes + list_anomalies separately.',
|
||||
parameters: params({
|
||||
processLimit: { type: 'number', description: 'Top processes (default 12, max 25)' },
|
||||
hotLimit: { type: 'number', description: 'Hot charts to include (default 12, max 30)' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
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)' },
|
||||
name: 'host_snapshot',
|
||||
tier: 'core',
|
||||
description:
|
||||
'Compact live host KPIs (cpu, ram, load, net, io), health, recent anomalies/alerts. Use when you need a lighter snapshot than investigate_host.',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'search_charts',
|
||||
tier: 'core',
|
||||
description: 'Search the metrics chart catalog by free text (id, title, context, family).',
|
||||
parameters: params(
|
||||
{
|
||||
q: { type: 'string', description: 'Search query' },
|
||||
limit: { type: 'number', description: 'Max results (default 20)' },
|
||||
},
|
||||
['q']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'summarize_chart',
|
||||
tier: 'core',
|
||||
description: 'Summarize one chart: min/avg/max/last per dimension for a time window.',
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Chart id e.g. system.cpu' },
|
||||
after: {
|
||||
type: 'number',
|
||||
description: 'Seconds relative (e.g. -300) or absolute unix',
|
||||
},
|
||||
required: ['q'],
|
||||
points: { type: 'number', description: 'Max points (default 90)' },
|
||||
},
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
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'],
|
||||
name: 'list_anomalies',
|
||||
tier: 'core',
|
||||
description: 'List recent anomaly events.',
|
||||
parameters: params({
|
||||
limit: { type: 'number', description: 'Max events' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'list_alerts',
|
||||
tier: 'core',
|
||||
description: 'List configured/open alerts on the agent.',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'list_processes',
|
||||
tier: 'core',
|
||||
description: 'Live process table (when agent enables PEARDATA_PROCESSES).',
|
||||
parameters: params({
|
||||
sort: {
|
||||
type: 'string',
|
||||
description: 'cpu|rss|name',
|
||||
enum: ['cpu', 'rss', 'name'],
|
||||
},
|
||||
},
|
||||
limit: { type: 'number', description: 'Max rows' },
|
||||
filter: { type: 'string', description: 'Filter string (all|user|…)' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
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'],
|
||||
name: 'local_knowledge',
|
||||
tier: 'core',
|
||||
description:
|
||||
'Search local PearData operator knowledge and chart catalog (no network). Use for product how-to questions.',
|
||||
parameters: params(
|
||||
{
|
||||
q: { type: 'string', description: 'Question or keywords' },
|
||||
},
|
||||
},
|
||||
['q']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_anomalies',
|
||||
description: 'List recent anomaly events.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { limit: { type: 'number' } },
|
||||
name: 'open_chart',
|
||||
tier: 'core',
|
||||
description: 'Navigate the desktop UI to a chart (optional pause near timestamp ms).',
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Chart id' },
|
||||
ts: { type: 'number', description: 'Event time ms' },
|
||||
},
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
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' },
|
||||
name: 'open_view',
|
||||
tier: 'core',
|
||||
description:
|
||||
'Navigate desktop to a view: overview|charts|processes|alerts|logs|fleet|settings|qvac',
|
||||
parameters: params(
|
||||
{
|
||||
view: {
|
||||
type: 'string',
|
||||
description: 'View name',
|
||||
enum: [
|
||||
'overview',
|
||||
'charts',
|
||||
'processes',
|
||||
'alerts',
|
||||
'logs',
|
||||
'fleet',
|
||||
'settings',
|
||||
'qvac',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
['view']
|
||||
),
|
||||
},
|
||||
|
||||
// ── deep: investigation, fleet, storage, catalog ───────────────────
|
||||
{
|
||||
type: 'function',
|
||||
name: 'hot_metrics',
|
||||
tier: 'deep',
|
||||
description:
|
||||
'Charts with strongest recent change / anomaly signal. Great investigation starting points when investigate_host is too broad.',
|
||||
parameters: params({
|
||||
limit: { type: 'number', description: 'Max charts (default 15, max 50)' },
|
||||
window: { type: 'number', description: 'Lookback seconds (default 120, 20..600)' },
|
||||
family: { type: 'string', description: 'Optional family/context filter e.g. disk, docker' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
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' },
|
||||
name: 'related_charts',
|
||||
tier: 'deep',
|
||||
description:
|
||||
'Related charts for a seed chart (catalog family/context + optional alert weights). Use after finding a suspicious chart.',
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Seed chart id' },
|
||||
limit: { type: 'number', description: 'Max results (default 12)' },
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'compare_chart_windows',
|
||||
tier: 'deep',
|
||||
description:
|
||||
'Compare two time windows on one chart (highlight vs baseline). Defaults: last 5m vs prior 20m. Returns per-dim relative change.',
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Chart id' },
|
||||
after: { type: 'number', description: 'Highlight window start (default -300)' },
|
||||
before: { type: 'number', description: 'Highlight window end (default 0)' },
|
||||
baselineAfter: {
|
||||
type: 'number',
|
||||
description: 'Baseline window start (default -1500)',
|
||||
},
|
||||
baselineBefore: {
|
||||
type: 'number',
|
||||
description: 'Baseline window end (default -300)',
|
||||
},
|
||||
points: { type: 'number', description: 'Points per window (default 60)' },
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'summarize_charts',
|
||||
tier: 'deep',
|
||||
description: 'Batch summarize up to 12 charts in one call (compact stats).',
|
||||
parameters: params(
|
||||
{
|
||||
charts: {
|
||||
type: 'string',
|
||||
description: 'Comma or space separated chart ids (max 12)',
|
||||
},
|
||||
after: { type: 'number', description: 'Window start (default -120)' },
|
||||
points: { type: 'number', description: 'Points per chart (default 60)' },
|
||||
},
|
||||
['charts']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'query_metric',
|
||||
tier: 'deep',
|
||||
description: 'Raw queryData time series for a chart (full points). Prefer summarize_chart when stats suffice.',
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Chart id' },
|
||||
after: { type: 'number', description: 'Window start (relative or unix)' },
|
||||
points: { type: 'number', description: 'Max points' },
|
||||
group: {
|
||||
type: 'string',
|
||||
description: 'Aggregation: average|min|max|sum',
|
||||
enum: ['average', 'min', 'max', 'sum'],
|
||||
},
|
||||
},
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
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'],
|
||||
name: 'get_weights',
|
||||
tier: 'deep',
|
||||
description:
|
||||
'Metric correlation / influence weights for a chart (alerts method). Helps find what moves with an incident chart.',
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Chart id' },
|
||||
limit: { type: 'number', description: 'Max related (default 20)' },
|
||||
},
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
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'],
|
||||
name: 'query_logs',
|
||||
tier: 'deep',
|
||||
description:
|
||||
'Query agent logs: source journal|anomaly|audit, optional free-text q. journal/audit may require admin on the agent.',
|
||||
parameters: params({
|
||||
source: {
|
||||
type: 'string',
|
||||
description: 'journal|anomaly|audit',
|
||||
enum: ['journal', 'anomaly', 'audit'],
|
||||
},
|
||||
},
|
||||
q: { type: 'string', description: 'Search text' },
|
||||
limit: { type: 'number', description: 'Max lines' },
|
||||
}),
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
},
|
||||
name: 'fleet_health',
|
||||
tier: 'deep',
|
||||
description: 'Fleet / parent-child health summary when parent mode is enabled.',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
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'],
|
||||
name: 'list_child_peers',
|
||||
tier: 'deep',
|
||||
description: 'List child peers in fleet/parent mode (hostname, key, hops).',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'storage_info',
|
||||
tier: 'deep',
|
||||
description: 'Agent storage usage and retention config (warm/history sizes).',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'agent_health',
|
||||
tier: 'deep',
|
||||
description: 'Raw agent health object from anomaly engine (status, warnings, critical).',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'node_info',
|
||||
tier: 'deep',
|
||||
description: 'Agent node metadata: version, hostname, platform, uptime, collectors.',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'db_info',
|
||||
tier: 'deep',
|
||||
description: 'HyperDB / replication db info for the agent store.',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'list_contexts',
|
||||
tier: 'deep',
|
||||
description: 'List metric contexts (families of charts) on the agent.',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'get_chart',
|
||||
tier: 'deep',
|
||||
description: 'Chart metadata: title, family, units, dimensions (no series points).',
|
||||
parameters: params(
|
||||
{
|
||||
chart: { type: 'string', description: 'Chart id' },
|
||||
},
|
||||
},
|
||||
['chart']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'list_jobs',
|
||||
tier: 'deep',
|
||||
description: 'List on-demand agent jobs (collectOnce, snapshot, retrainAnomaly, …).',
|
||||
parameters: params({}),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'get_alert',
|
||||
tier: 'deep',
|
||||
description: 'Fetch one alert by id.',
|
||||
parameters: params(
|
||||
{
|
||||
id: { type: 'string', description: 'Alert id' },
|
||||
},
|
||||
['id']
|
||||
),
|
||||
},
|
||||
|
||||
// ── write: operator actions (gated by role + confirm) ────────────────
|
||||
{
|
||||
type: 'function',
|
||||
name: 'silence_alert',
|
||||
tier: 'write',
|
||||
description:
|
||||
'Operator only: silence an alert by id for durationMs (requires confirm). Prefer explaining first.',
|
||||
parameters: params(
|
||||
{
|
||||
id: { type: 'string', description: 'Alert id' },
|
||||
durationMs: {
|
||||
type: 'number',
|
||||
description: 'Silence duration ms (default 3600000)',
|
||||
},
|
||||
confirmed: {
|
||||
type: 'boolean',
|
||||
description: 'Must be true after user confirms',
|
||||
},
|
||||
},
|
||||
['id']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'ack_alert',
|
||||
tier: 'write',
|
||||
description: 'Operator only: acknowledge an alert by id (requires confirm).',
|
||||
parameters: params(
|
||||
{
|
||||
id: { type: 'string', description: 'Alert id' },
|
||||
confirmed: {
|
||||
type: 'boolean',
|
||||
description: 'Must be true after user confirms',
|
||||
},
|
||||
},
|
||||
['id']
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'run_job',
|
||||
tier: 'write',
|
||||
description:
|
||||
'Operator only: run an on-demand job (collectOnce, snapshot, retrainAnomaly, gcBuffers, …). Requires confirm.',
|
||||
parameters: params(
|
||||
{
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Job name from list_jobs',
|
||||
},
|
||||
confirmed: {
|
||||
type: 'boolean',
|
||||
description: 'Must be true after user confirms',
|
||||
},
|
||||
},
|
||||
['name']
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
/** Tools that work without an agent connection. */
|
||||
const LOCAL_TOOLS = new Set(['open_view', 'open_chart', 'local_knowledge'])
|
||||
|
||||
/** Operator write tools. */
|
||||
const WRITE_TOOLS = new Set(
|
||||
TOOL_DEFS.filter((t) => t.tier === 'write').map((t) => t.name)
|
||||
)
|
||||
|
||||
/**
|
||||
* Map profile id → max tool tier depth.
|
||||
* @param {string} profileId
|
||||
* @returns {'core'|'deep'}
|
||||
*/
|
||||
export function toolDepthForProfile(profileId) {
|
||||
const id = String(profileId || 'recommended')
|
||||
if (id === 'lite' || id === 'tool-tiny') return 'core'
|
||||
return 'deep'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ToolDef} def
|
||||
* @param {'core'|'deep'} depth
|
||||
* @param {string} role
|
||||
*/
|
||||
function includeTool(def, depth, role) {
|
||||
const tier = def.tier || 'core'
|
||||
if (tier === 'write') return roleAllows(role, Roles.operator)
|
||||
if (tier === 'deep') return depth === 'deep'
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip internal `tier` before sending schemas to the model.
|
||||
* @param {ToolDef[]} defs
|
||||
*/
|
||||
function wireDefs(defs) {
|
||||
return defs.map(({ type, name, description, parameters }) => ({
|
||||
type,
|
||||
name,
|
||||
description,
|
||||
parameters,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* manager: { request: (m: string, a?: object) => Promise<any>, active: any },
|
||||
@@ -211,14 +484,24 @@ export function createToolRunner(deps) {
|
||||
* @param {object} args
|
||||
*/
|
||||
async function run(name, args = {}) {
|
||||
const localOk = ['open_view', 'open_chart', 'local_knowledge'].includes(name)
|
||||
if (!deps.isConnected?.() && !localOk) {
|
||||
if (!LOCAL_TOOLS.has(name) && !deps.isConnected?.()) {
|
||||
return { error: 'No agent connected. Connect from the Connect tab first.' }
|
||||
}
|
||||
if (WRITE_TOOLS.has(name)) {
|
||||
const role = deps.getRole?.() || Roles.viewer
|
||||
if (!roleAllows(role, Roles.operator)) {
|
||||
return { error: 'Operator role required for this action' }
|
||||
}
|
||||
}
|
||||
const req = (m, a) => deps.manager.request(m, a || {})
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'investigate_host':
|
||||
return await req(Methods.investigateHost, {
|
||||
processLimit: args.processLimit,
|
||||
hotLimit: args.hotLimit,
|
||||
})
|
||||
case 'host_snapshot':
|
||||
return await req(Methods.getHostSnapshot, {})
|
||||
case 'search_charts':
|
||||
@@ -233,6 +516,20 @@ export function createToolRunner(deps) {
|
||||
points: args.points,
|
||||
group: args.group,
|
||||
})
|
||||
case 'summarize_charts': {
|
||||
let charts = args.charts
|
||||
if (typeof charts === 'string') {
|
||||
charts = charts.split(/[\s,]+/).filter(Boolean)
|
||||
}
|
||||
if (!Array.isArray(charts) && args.chart) {
|
||||
charts = [args.chart]
|
||||
}
|
||||
return await req(Methods.summarizeCharts, {
|
||||
charts,
|
||||
after: args.after,
|
||||
points: args.points,
|
||||
})
|
||||
}
|
||||
case 'query_metric':
|
||||
return await req(Methods.queryData, {
|
||||
chart: args.chart,
|
||||
@@ -240,10 +537,39 @@ export function createToolRunner(deps) {
|
||||
points: args.points ?? 90,
|
||||
group: args.group || 'average',
|
||||
})
|
||||
case 'hot_metrics':
|
||||
return await req(Methods.hotMetrics, {
|
||||
limit: args.limit,
|
||||
window: args.window,
|
||||
family: args.family || args.q || '',
|
||||
})
|
||||
case 'related_charts':
|
||||
return await req(Methods.relatedCharts, {
|
||||
chart: args.chart || args.id,
|
||||
limit: args.limit,
|
||||
})
|
||||
case 'compare_chart_windows':
|
||||
return await req(Methods.compareChartWindows, {
|
||||
chart: args.chart || args.id,
|
||||
after: args.after,
|
||||
before: args.before,
|
||||
baselineAfter: args.baselineAfter,
|
||||
baselineBefore: args.baselineBefore,
|
||||
points: args.points,
|
||||
group: args.group,
|
||||
})
|
||||
case 'get_weights':
|
||||
return await req(Methods.getWeights, {
|
||||
chart: args.chart || args.id,
|
||||
limit: args.limit ?? 20,
|
||||
method: args.method || 'alerts',
|
||||
})
|
||||
case 'list_anomalies':
|
||||
return await req(Methods.listAnomalies, { limit: args.limit ?? 30 })
|
||||
case 'list_alerts':
|
||||
return await req(Methods.listAlerts, {})
|
||||
case 'get_alert':
|
||||
return await req(Methods.getAlert, { id: args.id })
|
||||
case 'list_processes':
|
||||
return await req(Methods.listProcesses, {
|
||||
sort: args.sort || 'cpu',
|
||||
@@ -258,6 +584,8 @@ export function createToolRunner(deps) {
|
||||
})
|
||||
case 'fleet_health':
|
||||
return await req(Methods.getFleetHealth, {})
|
||||
case 'list_child_peers':
|
||||
return await req(Methods.listChildPeers, {})
|
||||
case 'storage_info': {
|
||||
const [storage, retention] = await Promise.all([
|
||||
req(Methods.getStorageInfo, {}),
|
||||
@@ -265,6 +593,20 @@ export function createToolRunner(deps) {
|
||||
])
|
||||
return { storage, retention }
|
||||
}
|
||||
case 'agent_health':
|
||||
return await req(Methods.getHealth, {})
|
||||
case 'node_info':
|
||||
return await req(Methods.getNodeInfo, {})
|
||||
case 'db_info':
|
||||
return await req(Methods.getDbInfo, {})
|
||||
case 'list_contexts':
|
||||
return await req(Methods.listContexts, {})
|
||||
case 'get_chart':
|
||||
return await req(Methods.getChart, {
|
||||
id: args.chart || args.id,
|
||||
})
|
||||
case 'list_jobs':
|
||||
return await req(Methods.listJobs, {})
|
||||
case 'local_knowledge': {
|
||||
const ctx = buildRagContext({
|
||||
query: String(args.q || ''),
|
||||
@@ -282,10 +624,6 @@ export function createToolRunner(deps) {
|
||||
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',
|
||||
@@ -302,6 +640,33 @@ export function createToolRunner(deps) {
|
||||
durationMs: args.durationMs ?? 3_600_000,
|
||||
})
|
||||
}
|
||||
case 'ack_alert': {
|
||||
if (!args.confirmed) {
|
||||
return {
|
||||
error: 'confirmation_required',
|
||||
message: `Confirm acknowledging alert ${args.id} before retrying with confirmed=true`,
|
||||
}
|
||||
}
|
||||
const ok =
|
||||
(await deps.confirmAction?.(`Acknowledge alert ${args.id}?`)) !== false
|
||||
if (!ok) return { error: 'User declined acknowledge' }
|
||||
return await req(Methods.ackAlert, { id: args.id })
|
||||
}
|
||||
case 'run_job': {
|
||||
if (!args.confirmed) {
|
||||
return {
|
||||
error: 'confirmation_required',
|
||||
message: `Confirm running job "${args.name}" before retrying with confirmed=true`,
|
||||
}
|
||||
}
|
||||
const ok =
|
||||
(await deps.confirmAction?.(`Run job "${args.name}" on the agent?`)) !== false
|
||||
if (!ok) return { error: 'User declined run_job' }
|
||||
return await req(Methods.runJob, {
|
||||
name: args.name,
|
||||
args: args.args || {},
|
||||
})
|
||||
}
|
||||
default:
|
||||
return { error: `Unknown tool: ${name}` }
|
||||
}
|
||||
@@ -311,15 +676,18 @@ export function createToolRunner(deps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools available for the current role.
|
||||
* Tools available for the current role + profile depth.
|
||||
* @param {{ profileId?: string, profile?: { id?: string }, depth?: 'core'|'deep' }} [opts]
|
||||
*/
|
||||
function defsForRole() {
|
||||
function defsForRole(opts = {}) {
|
||||
const role = deps.getRole?.() || Roles.viewer
|
||||
if (roleAllows(role, Roles.operator)) return TOOL_DEFS
|
||||
return TOOL_DEFS.filter((t) => t.function.name !== 'silence_alert')
|
||||
const profileId = opts.profileId || opts.profile?.id || 'recommended'
|
||||
const depth = opts.depth || toolDepthForProfile(profileId)
|
||||
const filtered = TOOL_DEFS.filter((t) => includeTool(t, depth, role))
|
||||
return wireDefs(filtered)
|
||||
}
|
||||
|
||||
return { run, defsForRole, TOOL_DEFS }
|
||||
return { run, defsForRole, TOOL_DEFS, toolDepthForProfile }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -333,7 +701,6 @@ export async function fallbackComplete(userText, tools, opts = {}) {
|
||||
/** @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') ||
|
||||
@@ -341,7 +708,21 @@ export async function fallbackComplete(userText, tools, opts = {}) {
|
||||
q.includes('keyboard') ||
|
||||
q.includes('retention')
|
||||
|
||||
if (!howTo) {
|
||||
const wantsDiagnose =
|
||||
!howTo &&
|
||||
(q.includes("what's wrong") ||
|
||||
q.includes('whats wrong') ||
|
||||
q.includes('diagnose') ||
|
||||
q.includes('investigat') ||
|
||||
q.includes('summarize host') ||
|
||||
q.includes('host health') ||
|
||||
q.includes('what is wrong') ||
|
||||
(q.includes('health') && !q.includes('md.')))
|
||||
|
||||
if (wantsDiagnose) {
|
||||
const inv = await tools.run('investigate_host', { processLimit: 10, hotLimit: 10 })
|
||||
calls.push({ name: 'investigate_host', args: {}, result: inv })
|
||||
} else if (!howTo) {
|
||||
const snap = await tools.run('host_snapshot', {})
|
||||
calls.push({ name: 'host_snapshot', args: {}, result: snap })
|
||||
}
|
||||
@@ -355,6 +736,10 @@ export async function fallbackComplete(userText, tools, opts = {}) {
|
||||
const p = await tools.run('list_processes', { limit: 10 })
|
||||
calls.push({ name: 'list_processes', args: { limit: 10 }, result: p })
|
||||
}
|
||||
if (q.includes('hot') || q.includes('spiking') || q.includes('unusual')) {
|
||||
const h = await tools.run('hot_metrics', { limit: 12 })
|
||||
calls.push({ name: 'hot_metrics', args: { limit: 12 }, result: h })
|
||||
}
|
||||
if (
|
||||
q.includes('chart') ||
|
||||
q.includes('metric') ||
|
||||
@@ -370,6 +755,13 @@ export async function fallbackComplete(userText, tools, opts = {}) {
|
||||
const s = await tools.run('search_charts', { q: term, limit: 12 })
|
||||
calls.push({ name: 'search_charts', args: { q: term }, result: s })
|
||||
}
|
||||
if (q.includes('related') || q.includes('correlat')) {
|
||||
const chartMatch = q.match(/\b([a-z][a-z0-9_.-]+\.[a-z0-9_.-]+)\b/)
|
||||
if (chartMatch) {
|
||||
const r = await tools.run('related_charts', { chart: chartMatch[1], limit: 10 })
|
||||
calls.push({ name: 'related_charts', args: { chart: chartMatch[1] }, result: r })
|
||||
}
|
||||
}
|
||||
if (q.includes('anomal') || q.includes('alert')) {
|
||||
const a = await tools.run('list_anomalies', { limit: 15 })
|
||||
calls.push({ name: 'list_anomalies', args: {}, result: a })
|
||||
@@ -399,7 +791,10 @@ export async function fallbackComplete(userText, tools, opts = {}) {
|
||||
calls.push({ name: 'summarize_chart', args: { chart: 'system.ram' }, result: s })
|
||||
}
|
||||
|
||||
const snap = calls.find((c) => c.name === 'host_snapshot')?.result || null
|
||||
const snap =
|
||||
calls.find((c) => c.name === 'investigate_host')?.result ||
|
||||
calls.find((c) => c.name === 'host_snapshot')?.result ||
|
||||
null
|
||||
const text = formatFallbackAnswer(userText, snap, calls, howTo)
|
||||
return { contentText: text, toolCalls: calls, mode: 'fallback' }
|
||||
}
|
||||
@@ -411,30 +806,54 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
|
||||
|
||||
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.findings || snap.summary) {
|
||||
lines.push(`**Investigation** (${snap.hostname || 'agent'})`)
|
||||
if (snap.summary) {
|
||||
lines.push(
|
||||
`- findings=${snap.summary.findingCount ?? 0} top=${snap.summary.topSeverity || 'ok'} health=${snap.summary.health || '—'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
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 || ''}`)
|
||||
for (const f of (snap.findings || []).slice(0, 8)) {
|
||||
lines.push(`- [${f.severity || 'info'}] ${f.area || ''}: ${f.message || ''}`)
|
||||
}
|
||||
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.processes?.top?.length) {
|
||||
lines.push('**Top processes**')
|
||||
for (const p of snap.processes.top.slice(0, 6)) {
|
||||
lines.push(`- pid ${p.pid} ${p.name || ''} cpu=${fmt(p.cpu)}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push('- No recent anomalies in the snapshot.')
|
||||
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 === 'host_snapshot' || c.name === 'investigate_host') continue
|
||||
if (c.name === 'local_knowledge' && c.result?.context) {
|
||||
lines.push(`\n**Local knowledge**\n${c.result.context}`)
|
||||
}
|
||||
@@ -444,6 +863,18 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
|
||||
lines.push(`- \`${r.id}\` — ${r.title || ''}`)
|
||||
}
|
||||
}
|
||||
if (c.name === 'hot_metrics' && c.result?.results) {
|
||||
lines.push(`\n**Hot metrics** (${c.result.results.length})`)
|
||||
for (const r of c.result.results.slice(0, 8)) {
|
||||
lines.push(`- \`${r.chart}\` score=${fmt(r.score)} — ${r.reason || ''}`)
|
||||
}
|
||||
}
|
||||
if (c.name === 'related_charts' && c.result?.results) {
|
||||
lines.push(`\n**Related to ${c.result.chart}**`)
|
||||
for (const r of c.result.results.slice(0, 8)) {
|
||||
lines.push(`- \`${r.id}\` (${fmt(r.score)}) ${r.reason || ''}`)
|
||||
}
|
||||
}
|
||||
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)) {
|
||||
@@ -495,7 +926,9 @@ function formatFallbackAnswer(userText, snap, calls, howTo) {
|
||||
}
|
||||
|
||||
if (!lines.length) {
|
||||
lines.push('No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).')
|
||||
lines.push(
|
||||
'No tool results yet. Connect an agent or ask about PearData features (charts, alerts, QVAC).'
|
||||
)
|
||||
}
|
||||
|
||||
lines.push(
|
||||
|
||||
Reference in New Issue
Block a user