Multi Agent Investigations
This commit is contained in:
@@ -308,6 +308,9 @@ const qvacView = createQvacView({
|
|||||||
resetBtn: $('qvac-reset'),
|
resetBtn: $('qvac-reset'),
|
||||||
unloadBtn: $('qvac-unload'),
|
unloadBtn: $('qvac-unload'),
|
||||||
newChatBtn: $('qvac-new-chat'),
|
newChatBtn: $('qvac-new-chat'),
|
||||||
|
settingsBtn: $('qvac-settings-btn'),
|
||||||
|
settingsPanel: $('qvac-settings-panel'),
|
||||||
|
agentBar: $('qvac-agent-bar'),
|
||||||
},
|
},
|
||||||
manager,
|
manager,
|
||||||
getRole: () => currentRole,
|
getRole: () => currentRole,
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export const SETTINGS_LOCALSTORAGE_KEY = 'peardata.settings.v1'
|
|||||||
* qvacCacheDir?: string,
|
* qvacCacheDir?: string,
|
||||||
* qvacRag?: boolean,
|
* qvacRag?: boolean,
|
||||||
* qvacIdleUnloadMin?: number,
|
* qvacIdleUnloadMin?: number,
|
||||||
|
* qvacSubAgents?: boolean,
|
||||||
|
* qvacMaxSubAgents?: number,
|
||||||
|
* qvacToolDepth?: 'auto'|'core'|'deep',
|
||||||
* }} UiSettings */
|
* }} UiSettings */
|
||||||
|
|
||||||
/** @returns {UiSettings} */
|
/** @returns {UiSettings} */
|
||||||
@@ -68,6 +71,9 @@ export function defaultSettings() {
|
|||||||
qvacCacheDir: '',
|
qvacCacheDir: '',
|
||||||
qvacRag: true,
|
qvacRag: true,
|
||||||
qvacIdleUnloadMin: 30,
|
qvacIdleUnloadMin: 30,
|
||||||
|
qvacSubAgents: true,
|
||||||
|
qvacMaxSubAgents: 3,
|
||||||
|
qvacToolDepth: 'auto',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+57
-1
@@ -355,14 +355,70 @@
|
|||||||
<span id="qvac-status" class="muted" data-kind=""></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-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-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>
|
<button type="button" id="qvac-settings-btn" class="btn btn-ghost" aria-expanded="false" aria-controls="qvac-settings-panel" title="Model & multi-agent settings">Settings</button>
|
||||||
|
<button type="button" id="qvac-reset" class="btn btn-ghost" title="First-time setup / re-run onboarding">Setup</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<div id="qvac-settings-panel" class="qvac-settings-panel hidden" role="region" aria-label="QVAC settings">
|
||||||
|
<div class="qvac-settings-grid">
|
||||||
|
<div class="qvac-settings-block">
|
||||||
|
<h4>Model</h4>
|
||||||
|
<p id="qvac-settings-model-status" class="muted settings-hint">Status: —</p>
|
||||||
|
<label class="settings-field">
|
||||||
|
Profile
|
||||||
|
<select id="qvac-set-profile">
|
||||||
|
<option value="lite">Lite</option>
|
||||||
|
<option value="recommended" selected>Recommended</option>
|
||||||
|
<option value="strong">Strong</option>
|
||||||
|
<option value="tool-tiny">Tool-tiny</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-check">
|
||||||
|
<input type="checkbox" id="qvac-set-rag" checked />
|
||||||
|
Inject local knowledge (RAG)
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Idle unload (minutes, 0 = never)
|
||||||
|
<input id="qvac-set-idle" type="number" min="0" max="240" step="5" value="30" />
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Tool depth
|
||||||
|
<select id="qvac-set-tool-depth">
|
||||||
|
<option value="auto" selected>Auto (by profile)</option>
|
||||||
|
<option value="core">Core only</option>
|
||||||
|
<option value="deep">Deep (full catalog)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="qvac-settings-block">
|
||||||
|
<h4>Multi-agent</h4>
|
||||||
|
<p class="muted settings-hint">
|
||||||
|
Spin up specialist investigators (host, hot metrics, processes, anomalies…) in parallel before the main reply.
|
||||||
|
</p>
|
||||||
|
<label class="settings-check">
|
||||||
|
<input type="checkbox" id="qvac-set-subagents" checked />
|
||||||
|
Enable multi-agent investigations
|
||||||
|
</label>
|
||||||
|
<label class="settings-field">
|
||||||
|
Max parallel agents
|
||||||
|
<input id="qvac-set-max-agents" type="number" min="1" max="6" step="1" value="3" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="qvac-settings-block qvac-settings-actions">
|
||||||
|
<h4>Actions</h4>
|
||||||
|
<button type="button" id="qvac-set-unload" class="btn btn-ghost">Unload model</button>
|
||||||
|
<button type="button" id="qvac-set-reload" class="btn btn-ghost" title="Load current profile">Load / reload model</button>
|
||||||
|
<button type="button" id="qvac-set-rerun-setup" class="btn btn-ghost">Re-run setup</button>
|
||||||
|
<button type="button" id="qvac-settings-close" class="btn">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="qvac-setup" class="qvac-setup">
|
<div id="qvac-setup" class="qvac-setup">
|
||||||
<div id="qvac-setup-steps" class="qvac-setup-steps"></div>
|
<div id="qvac-setup-steps" class="qvac-setup-steps"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="qvac-chat" class="qvac-chat hidden">
|
<div id="qvac-chat" class="qvac-chat hidden">
|
||||||
<div id="qvac-samples" class="qvac-samples" aria-label="Sample prompts"></div>
|
<div id="qvac-samples" class="qvac-samples" aria-label="Sample prompts"></div>
|
||||||
|
<div id="qvac-agent-bar" class="qvac-agent-bar hidden" aria-live="polite"></div>
|
||||||
<div id="qvac-messages" class="qvac-messages" aria-live="polite"></div>
|
<div id="qvac-messages" class="qvac-messages" aria-live="polite"></div>
|
||||||
<form id="qvac-form" class="qvac-composer" onsubmit="return false">
|
<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>
|
<textarea id="qvac-input" rows="2" placeholder="Ask about host health, metrics, anomalies, processes…" autocomplete="off"></textarea>
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* Multi-agent investigation fan-out for QVAC.
|
||||||
|
*
|
||||||
|
* Each "sub-agent" is a specialist tool worker (not a second model load).
|
||||||
|
* They run in parallel, report progress, and produce a compact briefing the
|
||||||
|
* primary model (or tools-only fallback) synthesizes into one answer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{
|
||||||
|
* id: string,
|
||||||
|
* label: string,
|
||||||
|
* description: string,
|
||||||
|
* match?: (q: string) => boolean,
|
||||||
|
* run: (tools: { run: (name: string, args?: object) => Promise<any> }) => Promise<any>,
|
||||||
|
* summarize: (result: any) => string,
|
||||||
|
* }} AgentSpec
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {AgentSpec[]} */
|
||||||
|
export const AGENT_SPECS = [
|
||||||
|
{
|
||||||
|
id: 'host',
|
||||||
|
label: 'Host investigator',
|
||||||
|
description: 'KPIs, findings, health, top processes pack',
|
||||||
|
match: (q) =>
|
||||||
|
!q ||
|
||||||
|
/health|wrong|diagnos|investigat|summar|status|load|cpu|ram|memory|host|alert|anomal/.test(
|
||||||
|
q
|
||||||
|
),
|
||||||
|
run: (tools) => tools.run('investigate_host', { processLimit: 10, hotLimit: 10 }),
|
||||||
|
summarize: (r) => {
|
||||||
|
if (r?.error) return `error: ${r.error}`
|
||||||
|
const s = r?.summary || {}
|
||||||
|
const findings = (r?.findings || [])
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((f) => `[${f.severity}] ${f.area}: ${f.message}`)
|
||||||
|
.join('; ')
|
||||||
|
return `host=${r?.hostname || '?'} severity=${s.topSeverity || 'ok'} findings=${s.findingCount ?? 0}${findings ? ` · ${findings}` : ''}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hot',
|
||||||
|
label: 'Hot metrics',
|
||||||
|
description: 'Charts with strongest recent change',
|
||||||
|
match: (q) =>
|
||||||
|
!q ||
|
||||||
|
/hot|spik|unusual|metric|chart|cpu|disk|io|net|redis|docker|postgres|nginx/.test(q),
|
||||||
|
run: (tools) => tools.run('hot_metrics', { limit: 12, window: 120 }),
|
||||||
|
summarize: (r) => {
|
||||||
|
if (r?.error) return `error: ${r.error}`
|
||||||
|
const rows = (r?.results || [])
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((h) => `${h.chart}(${h.score}) ${h.reason || ''}`)
|
||||||
|
return rows.length ? rows.join('; ') : 'no hot charts'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'processes',
|
||||||
|
label: 'Process scout',
|
||||||
|
description: 'Top CPU processes',
|
||||||
|
match: (q) =>
|
||||||
|
!q || /process|top|cpu|load|rss|memory|pid|runaway|hung/.test(q),
|
||||||
|
run: (tools) => tools.run('list_processes', { sort: 'cpu', limit: 12, filter: 'all' }),
|
||||||
|
summarize: (r) => {
|
||||||
|
if (r?.error) return `error: ${r.error}`
|
||||||
|
if (r?.supported === false) return 'processes not enabled on agent'
|
||||||
|
const rows = (r?.processes || r?.top || [])
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((p) => `${p.name || p.comm || '?'} cpu=${p.cpu ?? '—'} rss=${p.rss ?? '—'}`)
|
||||||
|
return rows.length ? rows.join('; ') : 'no process rows'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'anomalies',
|
||||||
|
label: 'Anomaly / alerts',
|
||||||
|
description: 'Recent anomalies and open alerts',
|
||||||
|
match: (q) =>
|
||||||
|
!q || /anomal|alert|warn|crit|threshold|silence|page/.test(q),
|
||||||
|
run: async (tools) => {
|
||||||
|
const [anoms, alerts] = await Promise.all([
|
||||||
|
tools.run('list_anomalies', { limit: 15 }),
|
||||||
|
tools.run('list_alerts', {}),
|
||||||
|
])
|
||||||
|
return { anomalies: anoms, alerts }
|
||||||
|
},
|
||||||
|
summarize: (r) => {
|
||||||
|
const a = r?.anomalies?.anomalies || r?.anomalies || []
|
||||||
|
const al = r?.alerts?.alerts || r?.alerts || []
|
||||||
|
const aN = Array.isArray(a) ? a.length : 0
|
||||||
|
const alN = Array.isArray(al) ? al.length : 0
|
||||||
|
const head = (Array.isArray(a) ? a : [])
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((x) => `${x.severity || '?'} ${x.chart || ''}: ${x.message || ''}`)
|
||||||
|
.join('; ')
|
||||||
|
return `anomalies=${aN} alerts=${alN}${head ? ` · ${head}` : ''}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fleet',
|
||||||
|
label: 'Fleet scout',
|
||||||
|
description: 'Parent/child fleet health',
|
||||||
|
match: (q) => /fleet|child|peer|parent|cluster|all hosts/.test(q),
|
||||||
|
run: async (tools) => {
|
||||||
|
const [health, children] = await Promise.all([
|
||||||
|
tools.run('fleet_health', {}),
|
||||||
|
tools.run('list_child_peers', {}),
|
||||||
|
])
|
||||||
|
return { health, children }
|
||||||
|
},
|
||||||
|
summarize: (r) => {
|
||||||
|
const h = r?.health || {}
|
||||||
|
const ch = r?.children?.children || r?.children || []
|
||||||
|
return `enabled=${h.enabled} children=${Array.isArray(ch) ? ch.length : 0} local=${h.local?.status || '—'}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'storage',
|
||||||
|
label: 'Storage / retention',
|
||||||
|
description: 'Agent disk and retention config',
|
||||||
|
match: (q) => /storage|retention|prune|disk|warm|history|size|hyperdb/.test(q),
|
||||||
|
run: (tools) => tools.run('storage_info', {}),
|
||||||
|
summarize: (r) => {
|
||||||
|
if (r?.error) return `error: ${r.error}`
|
||||||
|
try {
|
||||||
|
return JSON.stringify(r).slice(0, 280)
|
||||||
|
} catch {
|
||||||
|
return 'storage ok'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this turn should fan out sub-agents.
|
||||||
|
* @param {string} userText
|
||||||
|
* @param {{ subAgents?: boolean }} prefs
|
||||||
|
*/
|
||||||
|
export function shouldUseSubAgents(userText, prefs = {}) {
|
||||||
|
if (prefs.subAgents === false) return false
|
||||||
|
const q = String(userText || '').toLowerCase()
|
||||||
|
// Product how-tos: skip multi-agent (use local_knowledge only)
|
||||||
|
if (
|
||||||
|
q.includes('how do') ||
|
||||||
|
q.includes('how to') ||
|
||||||
|
q.includes('what is qvac') ||
|
||||||
|
q.includes('keyboard') ||
|
||||||
|
q.includes('time preset')
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Default on for operational questions when feature enabled
|
||||||
|
if (prefs.subAgents === true || prefs.subAgents == null) {
|
||||||
|
return (
|
||||||
|
/health|wrong|diagnos|investigat|summar|status|cpu|ram|memory|disk|alert|anomal|process|load|hot|spik|fleet|storage|retention|metric|chart|nginx|redis|docker|postgres|io\b/.test(
|
||||||
|
q
|
||||||
|
) || q.length < 4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick agent specs for this query (capped).
|
||||||
|
* @param {string} userText
|
||||||
|
* @param {number} maxAgents
|
||||||
|
* @returns {AgentSpec[]}
|
||||||
|
*/
|
||||||
|
export function pickAgents(userText, maxAgents = 3) {
|
||||||
|
const q = String(userText || '').toLowerCase()
|
||||||
|
const max = Math.min(6, Math.max(1, Number(maxAgents) || 3))
|
||||||
|
/** @type {AgentSpec[]} */
|
||||||
|
const picked = []
|
||||||
|
for (const spec of AGENT_SPECS) {
|
||||||
|
if (spec.match && !spec.match(q)) continue
|
||||||
|
picked.push(spec)
|
||||||
|
if (picked.length >= max) break
|
||||||
|
}
|
||||||
|
// Always ensure host agent if empty
|
||||||
|
if (!picked.length) picked.push(AGENT_SPECS[0])
|
||||||
|
// Prefer host first when investigating
|
||||||
|
picked.sort((a, b) => (a.id === 'host' ? -1 : b.id === 'host' ? 1 : 0))
|
||||||
|
return picked.slice(0, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run specialist agents in parallel.
|
||||||
|
* @param {{
|
||||||
|
* tools: { run: (name: string, args?: object) => Promise<any> },
|
||||||
|
* query: string,
|
||||||
|
* maxAgents?: number,
|
||||||
|
* onAgent?: (ev: {
|
||||||
|
* id: string,
|
||||||
|
* label: string,
|
||||||
|
* status: 'start'|'done'|'error',
|
||||||
|
* summary?: string,
|
||||||
|
* error?: string,
|
||||||
|
* }) => void,
|
||||||
|
* }} opts
|
||||||
|
*/
|
||||||
|
export async function runSubAgents(opts) {
|
||||||
|
const specs = pickAgents(opts.query, opts.maxAgents ?? 3)
|
||||||
|
const tools = opts.tools
|
||||||
|
|
||||||
|
/** @type {Array<{ id: string, label: string, status: string, summary: string, result?: any, error?: string }>} */
|
||||||
|
const agents = []
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
specs.map(async (spec) => {
|
||||||
|
opts.onAgent?.({ id: spec.id, label: spec.label, status: 'start' })
|
||||||
|
try {
|
||||||
|
const result = await spec.run(tools)
|
||||||
|
const summary = spec.summarize(result)
|
||||||
|
const row = {
|
||||||
|
id: spec.id,
|
||||||
|
label: spec.label,
|
||||||
|
status: result?.error ? 'error' : 'done',
|
||||||
|
summary,
|
||||||
|
result,
|
||||||
|
error: result?.error ? String(result.error) : undefined,
|
||||||
|
}
|
||||||
|
agents.push(row)
|
||||||
|
opts.onAgent?.({
|
||||||
|
id: spec.id,
|
||||||
|
label: spec.label,
|
||||||
|
status: row.status === 'error' ? 'error' : 'done',
|
||||||
|
summary,
|
||||||
|
error: row.error,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err?.message || String(err)
|
||||||
|
agents.push({
|
||||||
|
id: spec.id,
|
||||||
|
label: spec.label,
|
||||||
|
status: 'error',
|
||||||
|
summary: msg,
|
||||||
|
error: msg,
|
||||||
|
})
|
||||||
|
opts.onAgent?.({
|
||||||
|
id: spec.id,
|
||||||
|
label: spec.label,
|
||||||
|
status: 'error',
|
||||||
|
error: msg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stable order by AGENT_SPECS
|
||||||
|
const order = new Map(AGENT_SPECS.map((s, i) => [s.id, i]))
|
||||||
|
agents.sort((a, b) => (order.get(a.id) ?? 99) - (order.get(b.id) ?? 99))
|
||||||
|
|
||||||
|
const contextText = formatAgentBriefing(agents)
|
||||||
|
return { agents, contextText, specs }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{ id: string, label: string, status: string, summary: string }>} agents
|
||||||
|
*/
|
||||||
|
export function formatAgentBriefing(agents) {
|
||||||
|
if (!agents?.length) return ''
|
||||||
|
const lines = [
|
||||||
|
'## Multi-agent investigation briefing',
|
||||||
|
'Specialist agents already collected live data. Use these facts; call extra tools only if a gap remains.',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
for (const a of agents) {
|
||||||
|
lines.push(`### ${a.label} (${a.id}) — ${a.status}`)
|
||||||
|
lines.push(a.summary || '(no summary)')
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
return lines.join('\n').slice(0, 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tools-only synthesis when no LLM is loaded.
|
||||||
|
* @param {string} userText
|
||||||
|
* @param {{ agents: Array<{ label: string, summary: string, status: string }>, contextText: string }} pack
|
||||||
|
*/
|
||||||
|
export function synthesizeFromAgents(userText, pack) {
|
||||||
|
const lines = [
|
||||||
|
`**Multi-agent investigation** (${pack.agents.length} specialists)`,
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
for (const a of pack.agents) {
|
||||||
|
lines.push(`- **${a.label}**: ${a.summary || a.status}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`_Question: ${userText}_`)
|
||||||
|
lines.push('_Mode: multi-agent tools pack (enable a loaded model for narrative synthesis)._')
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
+28
-4
@@ -445,7 +445,11 @@ export function createQvacEngine(deps) {
|
|||||||
const profile = getProfile(profileId || 'recommended')
|
const profile = getProfile(profileId || 'recommended')
|
||||||
const prefs = deps.getPrefs?.() || {}
|
const prefs = deps.getPrefs?.() || {}
|
||||||
const userLast = [...history].reverse().find((m) => m.role === 'user')
|
const userLast = [...history].reverse().find((m) => m.role === 'user')
|
||||||
let system = buildSystemPrompt(deps.getContext?.() || {})
|
const ctx = {
|
||||||
|
...(deps.getContext?.() || {}),
|
||||||
|
subAgents: prefs.subAgents !== false,
|
||||||
|
}
|
||||||
|
let system = buildSystemPrompt(ctx)
|
||||||
|
|
||||||
// Light RAG only — full catalog dumps blow small context windows
|
// Light RAG only — full catalog dumps blow small context windows
|
||||||
if (prefs.rag !== false && userLast?.content) {
|
if (prefs.rag !== false && userLast?.content) {
|
||||||
@@ -457,16 +461,31 @@ export function createQvacEngine(deps) {
|
|||||||
if (rag) system += `\n\n${rag.slice(0, 1200)}`
|
if (rag) system += `\n\n${rag.slice(0, 1200)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multi-agent briefing from parallel specialists (optional)
|
||||||
|
if (opts.agentBriefing) {
|
||||||
|
system += `\n\n${String(opts.agentBriefing).slice(0, 5000)}`
|
||||||
|
}
|
||||||
|
|
||||||
const fullHistory = [
|
const fullHistory = [
|
||||||
{ role: 'system', content: system },
|
{ role: 'system', content: system },
|
||||||
...history.filter((m) => m.role !== 'system'),
|
...history.filter((m) => m.role !== 'system'),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
|
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
|
||||||
return completeWithSdk(fullHistory, profile, opts)
|
return completeWithSdk(fullHistory, profile, opts, prefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
status = status === 'ready' ? status : 'fallback'
|
status = status === 'ready' ? status : 'fallback'
|
||||||
|
// If multi-agent pack already ran, prefer its synthesis over naive fallback
|
||||||
|
if (opts.agentFallbackText) {
|
||||||
|
const text = String(opts.agentFallbackText)
|
||||||
|
if (opts.onToken) {
|
||||||
|
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 { contentText: text, toolCalls: opts.agentToolCalls || [], mode: 'fallback' }
|
||||||
|
}
|
||||||
const result = await fallbackComplete(userLast?.content || '', deps.tools, {
|
const result = await fallbackComplete(userLast?.content || '', deps.tools, {
|
||||||
catalog: deps.getCatalog?.() || {},
|
catalog: deps.getCatalog?.() || {},
|
||||||
rag: prefs.rag !== false,
|
rag: prefs.rag !== false,
|
||||||
@@ -485,9 +504,14 @@ export function createQvacEngine(deps) {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeWithSdk(history, profile, opts) {
|
async function completeWithSdk(history, profile, opts, prefs = {}) {
|
||||||
|
const depthPref = prefs.toolDepth || 'auto'
|
||||||
|
const depth =
|
||||||
|
depthPref === 'core' || depthPref === 'deep'
|
||||||
|
? depthPref
|
||||||
|
: undefined
|
||||||
let toolDefs = profile.tools
|
let toolDefs = profile.tools
|
||||||
? deps.tools.defsForRole({ profileId: profile.id, profile })
|
? deps.tools.defsForRole({ profileId: profile.id, profile, depth })
|
||||||
: undefined
|
: undefined
|
||||||
const ctxSize = profile.ctxSize || 8192
|
const ctxSize = profile.ctxSize || 8192
|
||||||
let toolsTok = estimateToolsTokens(toolDefs)
|
let toolsTok = estimateToolsTokens(toolDefs)
|
||||||
|
|||||||
+289
-12
@@ -6,6 +6,11 @@ import { createToolRunner } from './tools.js'
|
|||||||
import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
|
import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
|
||||||
import { SAMPLE_PROMPTS } from './prompts.js'
|
import { SAMPLE_PROMPTS } from './prompts.js'
|
||||||
import { partitionThink, renderAssistantHtml } from './think.js'
|
import { partitionThink, renderAssistantHtml } from './think.js'
|
||||||
|
import {
|
||||||
|
shouldUseSubAgents,
|
||||||
|
runSubAgents,
|
||||||
|
synthesizeFromAgents,
|
||||||
|
} from './agents.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{
|
* @param {{
|
||||||
@@ -20,6 +25,9 @@ import { partitionThink, renderAssistantHtml } from './think.js'
|
|||||||
* modelChip: HTMLElement|null,
|
* modelChip: HTMLElement|null,
|
||||||
* samples: HTMLElement|null,
|
* samples: HTMLElement|null,
|
||||||
* setupSteps: HTMLElement|null,
|
* setupSteps: HTMLElement|null,
|
||||||
|
* agentBar?: HTMLElement|null,
|
||||||
|
* settingsBtn?: HTMLElement|null,
|
||||||
|
* settingsPanel?: HTMLElement|null,
|
||||||
* resetBtn?: HTMLElement|null,
|
* resetBtn?: HTMLElement|null,
|
||||||
* unloadBtn?: HTMLElement|null,
|
* unloadBtn?: HTMLElement|null,
|
||||||
* newChatBtn?: HTMLElement|null,
|
* newChatBtn?: HTMLElement|null,
|
||||||
@@ -68,11 +76,15 @@ export function createQvacView(opts) {
|
|||||||
peerId: opts.manager.active?.publicKeyHex || '',
|
peerId: opts.manager.active?.publicKeyHex || '',
|
||||||
role: opts.getRole?.() || 'viewer',
|
role: opts.getRole?.() || 'viewer',
|
||||||
connected: Boolean(opts.isConnected?.()),
|
connected: Boolean(opts.isConnected?.()),
|
||||||
|
subAgents: settings().qvacSubAgents !== false,
|
||||||
}),
|
}),
|
||||||
getCatalog: () => opts.getCatalog?.() || {},
|
getCatalog: () => opts.getCatalog?.() || {},
|
||||||
getPrefs: () => ({
|
getPrefs: () => ({
|
||||||
rag: settings().qvacRag !== false,
|
rag: settings().qvacRag !== false,
|
||||||
idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0,
|
idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0,
|
||||||
|
subAgents: settings().qvacSubAgents !== false,
|
||||||
|
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
|
||||||
|
toolDepth: settings().qvacToolDepth || 'auto',
|
||||||
}),
|
}),
|
||||||
log: opts.log,
|
log: opts.log,
|
||||||
})
|
})
|
||||||
@@ -95,18 +107,24 @@ export function createQvacView(opts) {
|
|||||||
el.dataset.kind = kind
|
el.dataset.kind = kind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isModelLoaded() {
|
||||||
|
const st = engine.getStatus()
|
||||||
|
return st.status === 'ready' && (st.sdkLoaded || st.mode === 'qvac' || settings().qvacMode === 'qvac')
|
||||||
|
}
|
||||||
|
|
||||||
function syncModelChip() {
|
function syncModelChip() {
|
||||||
const chip = opts.els.modelChip
|
const chip = opts.els.modelChip
|
||||||
if (!chip) return
|
if (!chip) return
|
||||||
const st = engine.getStatus()
|
const st = engine.getStatus()
|
||||||
const profile = getProfile(settings().qvacProfile || selectedProfile)
|
const profile = getProfile(settings().qvacProfile || selectedProfile)
|
||||||
if (st.mode === 'fallback' || (!st.sdkAvailable && st.status !== 'ready')) {
|
if (st.status === 'ready' && (st.sdkLoaded || st.mode === 'qvac')) {
|
||||||
|
chip.textContent = profile.chatModel
|
||||||
|
chip.dataset.mode = 'ready'
|
||||||
|
chip.title = 'Model loaded'
|
||||||
|
} else if (st.mode === 'fallback' || (!st.sdkAvailable && st.status !== 'ready')) {
|
||||||
chip.textContent = 'tools-only'
|
chip.textContent = 'tools-only'
|
||||||
chip.dataset.mode = 'fallback'
|
chip.dataset.mode = 'fallback'
|
||||||
chip.title = st.sdkError || 'Install @qvac/sdk for full local LLM'
|
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') {
|
} else if (st.status === 'downloading' || st.status === 'loading') {
|
||||||
const pct = st.progress?.percentage
|
const pct = st.progress?.percentage
|
||||||
chip.textContent =
|
chip.textContent =
|
||||||
@@ -116,6 +134,35 @@ export function createQvacView(opts) {
|
|||||||
chip.textContent = st.status || 'idle'
|
chip.textContent = st.status || 'idle'
|
||||||
chip.dataset.mode = st.status || 'idle'
|
chip.dataset.mode = st.status || 'idle'
|
||||||
}
|
}
|
||||||
|
syncHeaderActions()
|
||||||
|
syncSettingsForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header: always Settings when onboarded.
|
||||||
|
* Setup only when model is NOT loaded (first-time / tools-only).
|
||||||
|
* When loaded, re-run setup lives inside the settings panel.
|
||||||
|
*/
|
||||||
|
function syncHeaderActions() {
|
||||||
|
const reset = opts.els.resetBtn
|
||||||
|
const settingsBtn = opts.els.settingsBtn
|
||||||
|
const unload = opts.els.unloadBtn
|
||||||
|
if (!isOnboarded()) {
|
||||||
|
// Still in wizard — Setup is the primary path; Settings optional if present
|
||||||
|
reset?.classList.add('hidden')
|
||||||
|
settingsBtn?.classList.add('hidden')
|
||||||
|
unload?.classList.add('hidden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settingsBtn?.classList.remove('hidden')
|
||||||
|
const loaded = isModelLoaded()
|
||||||
|
if (loaded) {
|
||||||
|
reset?.classList.add('hidden')
|
||||||
|
unload?.classList.remove('hidden')
|
||||||
|
} else {
|
||||||
|
reset?.classList.remove('hidden')
|
||||||
|
unload?.classList.add('hidden')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPane() {
|
function showPane() {
|
||||||
@@ -127,8 +174,123 @@ export function createQvacView(opts) {
|
|||||||
} else {
|
} else {
|
||||||
setup?.classList.remove('hidden')
|
setup?.classList.remove('hidden')
|
||||||
chat?.classList.add('hidden')
|
chat?.classList.add('hidden')
|
||||||
|
closeSettings()
|
||||||
renderWizard()
|
renderWizard()
|
||||||
}
|
}
|
||||||
|
syncHeaderActions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSettings() {
|
||||||
|
const panel = opts.els.settingsPanel
|
||||||
|
const btn = opts.els.settingsBtn
|
||||||
|
if (!panel) return
|
||||||
|
panel.classList.remove('hidden')
|
||||||
|
btn?.setAttribute('aria-expanded', 'true')
|
||||||
|
syncSettingsForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettings() {
|
||||||
|
const panel = opts.els.settingsPanel
|
||||||
|
const btn = opts.els.settingsBtn
|
||||||
|
panel?.classList.add('hidden')
|
||||||
|
btn?.setAttribute('aria-expanded', 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSettings() {
|
||||||
|
const panel = opts.els.settingsPanel
|
||||||
|
if (!panel || panel.classList.contains('hidden')) openSettings()
|
||||||
|
else closeSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSettingsForm() {
|
||||||
|
const s = settings()
|
||||||
|
const root = opts.els.root
|
||||||
|
if (!root) return
|
||||||
|
const st = engine.getStatus()
|
||||||
|
const profile = getProfile(s.qvacProfile || selectedProfile)
|
||||||
|
const statusEl = root.querySelector('#qvac-settings-model-status')
|
||||||
|
if (statusEl) {
|
||||||
|
const loaded = isModelLoaded()
|
||||||
|
const multi = s.qvacSubAgents !== false
|
||||||
|
statusEl.textContent = [
|
||||||
|
`Status: ${st.status || 'idle'}`,
|
||||||
|
loaded ? `model=${profile.chatModel}` : 'no model loaded',
|
||||||
|
s.qvacMode ? `mode=${s.qvacMode}` : '',
|
||||||
|
multi ? 'multi-agent=on' : 'multi-agent=off',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')
|
||||||
|
}
|
||||||
|
const set = (sel, val, isCheck) => {
|
||||||
|
const el = root.querySelector(sel)
|
||||||
|
if (!el) return
|
||||||
|
if (isCheck) el.checked = Boolean(val)
|
||||||
|
else el.value = String(val ?? '')
|
||||||
|
}
|
||||||
|
set('#qvac-set-profile', s.qvacProfile || 'recommended')
|
||||||
|
set('#qvac-set-rag', s.qvacRag !== false, true)
|
||||||
|
set('#qvac-set-idle', s.qvacIdleUnloadMin ?? 30)
|
||||||
|
set('#qvac-set-tool-depth', s.qvacToolDepth || 'auto')
|
||||||
|
set('#qvac-set-subagents', s.qvacSubAgents !== false, true)
|
||||||
|
set('#qvac-set-max-agents', s.qvacMaxSubAgents ?? 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettingsForm() {
|
||||||
|
const root = opts.els.root
|
||||||
|
if (!root) return
|
||||||
|
const profile = root.querySelector('#qvac-set-profile')?.value || 'recommended'
|
||||||
|
const rag = root.querySelector('#qvac-set-rag')?.checked !== false
|
||||||
|
const idle = Number(root.querySelector('#qvac-set-idle')?.value) || 0
|
||||||
|
const toolDepth = root.querySelector('#qvac-set-tool-depth')?.value || 'auto'
|
||||||
|
const subAgents = root.querySelector('#qvac-set-subagents')?.checked !== false
|
||||||
|
let maxAgents = Number(root.querySelector('#qvac-set-max-agents')?.value) || 3
|
||||||
|
maxAgents = Math.min(6, Math.max(1, maxAgents))
|
||||||
|
selectedProfile = profile
|
||||||
|
persist({
|
||||||
|
qvacProfile: profile,
|
||||||
|
qvacRag: rag,
|
||||||
|
qvacIdleUnloadMin: idle,
|
||||||
|
qvacToolDepth: toolDepth,
|
||||||
|
qvacSubAgents: subAgents,
|
||||||
|
qvacMaxSubAgents: maxAgents,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Map<string, HTMLElement>} */
|
||||||
|
const agentChipEls = new Map()
|
||||||
|
|
||||||
|
function clearAgentBar() {
|
||||||
|
const bar = opts.els.agentBar
|
||||||
|
if (!bar) return
|
||||||
|
bar.innerHTML = ''
|
||||||
|
bar.classList.add('hidden')
|
||||||
|
agentChipEls.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ id: string, label: string, status: string, summary?: string, error?: string }} ev
|
||||||
|
*/
|
||||||
|
function paintAgentEvent(ev) {
|
||||||
|
const bar = opts.els.agentBar
|
||||||
|
if (!bar) return
|
||||||
|
bar.classList.remove('hidden')
|
||||||
|
let chip = agentChipEls.get(ev.id)
|
||||||
|
if (!chip) {
|
||||||
|
chip = document.createElement('span')
|
||||||
|
chip.className = 'qvac-agent-chip'
|
||||||
|
chip.dataset.agent = ev.id
|
||||||
|
bar.appendChild(chip)
|
||||||
|
agentChipEls.set(ev.id, chip)
|
||||||
|
}
|
||||||
|
chip.dataset.status = ev.status
|
||||||
|
const tip = ev.summary || ev.error || ''
|
||||||
|
chip.title = tip
|
||||||
|
chip.textContent =
|
||||||
|
ev.status === 'start'
|
||||||
|
? `⟳ ${ev.label}`
|
||||||
|
: ev.status === 'error'
|
||||||
|
? `✕ ${ev.label}`
|
||||||
|
: `✓ ${ev.label}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Finish onboarding in tools-only mode (no model download). */
|
/** Finish onboarding in tools-only mode (no model download). */
|
||||||
@@ -632,6 +794,7 @@ export function createQvacView(opts) {
|
|||||||
let acc = ''
|
let acc = ''
|
||||||
let thinkingAcc = ''
|
let thinkingAcc = ''
|
||||||
setStatus('Thinking…', 'busy')
|
setStatus('Thinking…', 'busy')
|
||||||
|
clearAgentBar()
|
||||||
try {
|
try {
|
||||||
// History for the model: clean answers only (no think tags)
|
// History for the model: clean answers only (no think tags)
|
||||||
const hist = messages
|
const hist = messages
|
||||||
@@ -644,14 +807,57 @@ export function createQvacView(opts) {
|
|||||||
})
|
})
|
||||||
hist.push({ role: 'user', content: text })
|
hist.push({ role: 'user', content: text })
|
||||||
|
|
||||||
|
/** @type {{ agentBriefing?: string, agentFallbackText?: string, agentToolCalls?: any[] }} */
|
||||||
|
const completeOpts = {}
|
||||||
|
const prefs = {
|
||||||
|
subAgents: settings().qvacSubAgents !== false,
|
||||||
|
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldUseSubAgents(text, prefs) && opts.isConnected?.()) {
|
||||||
|
setStatus('Spinning up agents…', 'busy')
|
||||||
|
if (streamBody) {
|
||||||
|
streamBody.innerHTML = formatMdLite('_Dispatching multi-agent investigation…_')
|
||||||
|
}
|
||||||
|
const pack = await runSubAgents({
|
||||||
|
tools,
|
||||||
|
query: text,
|
||||||
|
maxAgents: prefs.maxSubAgents,
|
||||||
|
onAgent: (ev) => {
|
||||||
|
paintAgentEvent(ev)
|
||||||
|
setStatus(`Agent: ${ev.label}…`, 'busy')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
completeOpts.agentBriefing = pack.contextText
|
||||||
|
completeOpts.agentFallbackText = synthesizeFromAgents(text, pack)
|
||||||
|
completeOpts.agentToolCalls = pack.agents.map((a) => ({
|
||||||
|
name: `agent:${a.id}`,
|
||||||
|
args: {},
|
||||||
|
result: { summary: a.summary, status: a.status },
|
||||||
|
}))
|
||||||
|
for (const a of pack.agents) {
|
||||||
|
toolLog.push({
|
||||||
|
name: `agent:${a.id}`,
|
||||||
|
args: {},
|
||||||
|
result: { summary: a.summary, status: a.status },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (assistantUi?.div) {
|
||||||
|
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
|
||||||
|
if (chips) chips.remove()
|
||||||
|
assistantUi.div.appendChild(renderToolChips(toolLog))
|
||||||
|
}
|
||||||
|
setStatus('Synthesizing…', 'busy')
|
||||||
|
}
|
||||||
|
|
||||||
const result = await engine.complete(hist, {
|
const result = await engine.complete(hist, {
|
||||||
|
...completeOpts,
|
||||||
onToken: (t) => {
|
onToken: (t) => {
|
||||||
acc += t
|
acc += t
|
||||||
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
|
paintAssistant(streamBody, mergeThinkStream(thinkingAcc, acc), true)
|
||||||
},
|
},
|
||||||
onThinking: (t) => {
|
onThinking: (t) => {
|
||||||
thinkingAcc += t
|
thinkingAcc += t
|
||||||
// If model streams think separately, wrap for partitioner
|
|
||||||
const raw = thinkingAcc
|
const raw = thinkingAcc
|
||||||
? `<think>\n${thinkingAcc}\n</think>\n${acc}`
|
? `<think>\n${thinkingAcc}\n</think>\n${acc}`
|
||||||
: acc
|
: acc
|
||||||
@@ -660,7 +866,6 @@ export function createQvacView(opts) {
|
|||||||
onTool: (name, args, res) => {
|
onTool: (name, args, res) => {
|
||||||
toolLog.push({ name, args, result: res })
|
toolLog.push({ name, args, result: res })
|
||||||
setStatus(`Tool: ${name}…`, 'busy')
|
setStatus(`Tool: ${name}…`, 'busy')
|
||||||
// Live tool chips while model works
|
|
||||||
if (assistantUi?.div) {
|
if (assistantUi?.div) {
|
||||||
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
|
let chips = assistantUi.div.querySelector('.qvac-tool-chips')
|
||||||
if (chips) chips.remove()
|
if (chips) chips.remove()
|
||||||
@@ -678,7 +883,6 @@ export function createQvacView(opts) {
|
|||||||
const parts = partitionThink(acc)
|
const parts = partitionThink(acc)
|
||||||
const last = messages[messages.length - 1]
|
const last = messages[messages.length - 1]
|
||||||
if (last?.role === 'assistant') {
|
if (last?.role === 'assistant') {
|
||||||
// Store full text (with think) for UI re-open; history path strips think
|
|
||||||
last.content = acc
|
last.content = acc
|
||||||
last.thinking = parts.thinking
|
last.thinking = parts.thinking
|
||||||
last.tools = toolLog
|
last.tools = toolLog
|
||||||
@@ -724,6 +928,45 @@ export function createQvacView(opts) {
|
|||||||
showPane()
|
showPane()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function unloadModel() {
|
||||||
|
await engine.unload()
|
||||||
|
persist({ qvacMode: settings().qvacMode === 'qvac' ? 'fallback' : settings().qvacMode })
|
||||||
|
setStatus('Model unloaded', 'ok')
|
||||||
|
syncModelChip()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadModelFromSettings() {
|
||||||
|
readSettingsForm()
|
||||||
|
const profile = settings().qvacProfile || selectedProfile || 'recommended'
|
||||||
|
selectedProfile = profile
|
||||||
|
setStatus('Loading 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()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
persist({
|
||||||
|
qvacOnboarded: true,
|
||||||
|
qvacProfile: profile,
|
||||||
|
qvacMode: result.mode || 'fallback',
|
||||||
|
})
|
||||||
|
setStatus(
|
||||||
|
result.ok && result.mode === 'qvac'
|
||||||
|
? 'Ready'
|
||||||
|
: `Ready (tools-only${result.error ? `: ${result.error}` : ''})`,
|
||||||
|
result.mode === 'qvac' ? 'ok' : 'warn'
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Load failed: ${err?.message || err}`, 'error')
|
||||||
|
}
|
||||||
|
syncModelChip()
|
||||||
|
showPane()
|
||||||
|
}
|
||||||
|
|
||||||
function bind() {
|
function bind() {
|
||||||
opts.els.sendBtn?.addEventListener('click', () => send())
|
opts.els.sendBtn?.addEventListener('click', () => send())
|
||||||
opts.els.input?.addEventListener('keydown', (ev) => {
|
opts.els.input?.addEventListener('keydown', (ev) => {
|
||||||
@@ -732,13 +975,47 @@ export function createQvacView(opts) {
|
|||||||
send()
|
send()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
opts.els.resetBtn?.addEventListener('click', () => resetOnboarding())
|
opts.els.resetBtn?.addEventListener('click', () => {
|
||||||
opts.els.unloadBtn?.addEventListener('click', async () => {
|
closeSettings()
|
||||||
await engine.unload()
|
resetOnboarding()
|
||||||
setStatus('Model unloaded', 'ok')
|
})
|
||||||
|
opts.els.unloadBtn?.addEventListener('click', () => unloadModel())
|
||||||
|
opts.els.newChatBtn?.addEventListener('click', () => newChat())
|
||||||
|
opts.els.settingsBtn?.addEventListener('click', () => toggleSettings())
|
||||||
|
|
||||||
|
const root = opts.els.root
|
||||||
|
root?.querySelector('#qvac-settings-close')?.addEventListener('click', () => {
|
||||||
|
readSettingsForm()
|
||||||
|
closeSettings()
|
||||||
|
setStatus('Settings saved', 'ok')
|
||||||
syncModelChip()
|
syncModelChip()
|
||||||
})
|
})
|
||||||
opts.els.newChatBtn?.addEventListener('click', () => newChat())
|
root?.querySelector('#qvac-set-rerun-setup')?.addEventListener('click', () => {
|
||||||
|
readSettingsForm()
|
||||||
|
closeSettings()
|
||||||
|
resetOnboarding()
|
||||||
|
})
|
||||||
|
root?.querySelector('#qvac-set-unload')?.addEventListener('click', () => {
|
||||||
|
readSettingsForm()
|
||||||
|
unloadModel()
|
||||||
|
})
|
||||||
|
root?.querySelector('#qvac-set-reload')?.addEventListener('click', () => {
|
||||||
|
reloadModelFromSettings()
|
||||||
|
})
|
||||||
|
// Live-persist toggles
|
||||||
|
for (const sel of [
|
||||||
|
'#qvac-set-profile',
|
||||||
|
'#qvac-set-rag',
|
||||||
|
'#qvac-set-idle',
|
||||||
|
'#qvac-set-tool-depth',
|
||||||
|
'#qvac-set-subagents',
|
||||||
|
'#qvac-set-max-agents',
|
||||||
|
]) {
|
||||||
|
root?.querySelector(sel)?.addEventListener('change', () => {
|
||||||
|
readSettingsForm()
|
||||||
|
syncSettingsForm()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {Promise<void>|null} */
|
/** @type {Promise<void>|null} */
|
||||||
|
|||||||
+10
-2
@@ -9,12 +9,14 @@
|
|||||||
* role?: string,
|
* role?: string,
|
||||||
* connected?: boolean,
|
* connected?: boolean,
|
||||||
* hostname?: string,
|
* hostname?: string,
|
||||||
|
* subAgents?: boolean,
|
||||||
* }} ctx
|
* }} ctx
|
||||||
*/
|
*/
|
||||||
export function buildSystemPrompt(ctx = {}) {
|
export function buildSystemPrompt(ctx = {}) {
|
||||||
const peer = ctx.peerAlias || (ctx.peerId ? `${String(ctx.peerId).slice(0, 12)}…` : 'none')
|
const peer = ctx.peerAlias || (ctx.peerId ? `${String(ctx.peerId).slice(0, 12)}…` : 'none')
|
||||||
const role = ctx.role || 'viewer'
|
const role = ctx.role || 'viewer'
|
||||||
const conn = ctx.connected ? 'connected' : 'disconnected'
|
const conn = ctx.connected ? 'connected' : 'disconnected'
|
||||||
|
const multi = ctx.subAgents !== false
|
||||||
return [
|
return [
|
||||||
'You are PearData QVAC, a local-only SRE copilot for a P2P host monitoring agent.',
|
'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.',
|
'You run on the operator desktop; metrics come from the connected agent via tools.',
|
||||||
@@ -27,6 +29,9 @@ export function buildSystemPrompt(ctx = {}) {
|
|||||||
'- You are read-only unless the user explicitly asks for an operator action and their role allows it.',
|
'- You are read-only unless the user explicitly asks for an operator action and their role allows it.',
|
||||||
'- Do not claim cloud access; all inference is local via QVAC.',
|
'- Do not claim cloud access; all inference is local via QVAC.',
|
||||||
'- Prefer open_chart / open_view when the user asks to see something in the UI.',
|
'- Prefer open_chart / open_view when the user asks to see something in the UI.',
|
||||||
|
multi
|
||||||
|
? '- Multi-agent mode: specialist investigators may already have collected a briefing. Trust those facts; only call extra tools for gaps.'
|
||||||
|
: '',
|
||||||
'',
|
'',
|
||||||
'Tool playbook (use these exact tool names and chart ids):',
|
'Tool playbook (use these exact tool names and chart ids):',
|
||||||
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).',
|
'- Host health / "what\'s wrong" / diagnose / investigate → investigate_host FIRST (one-shot findings).',
|
||||||
@@ -47,8 +52,10 @@ export function buildSystemPrompt(ctx = {}) {
|
|||||||
'- Prefer chart ids that appear in tool results. If summarize_chart returns points=0, try investigate_host or another chart.',
|
'- Prefer chart ids that appear in tool results. If summarize_chart returns points=0, try investigate_host or another chart.',
|
||||||
'- After tools return, give a clear human summary with numbers; do not only restate empty charts.',
|
'- After tools return, give a clear human summary with numbers; do not only restate empty charts.',
|
||||||
'',
|
'',
|
||||||
`Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}.`,
|
`Session: agent=${peer} (${conn}), role=${role}${ctx.hostname ? `, host hints may appear in tool results` : ''}${multi ? ', multi-agent=on' : ''}.`,
|
||||||
].join('\n')
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SAMPLE_PROMPTS = [
|
export const SAMPLE_PROMPTS = [
|
||||||
@@ -59,6 +66,7 @@ export const SAMPLE_PROMPTS = [
|
|||||||
'List charts related to disk or io',
|
'List charts related to disk or io',
|
||||||
'Any open alerts or anomalies?',
|
'Any open alerts or anomalies?',
|
||||||
'Top processes by CPU if available',
|
'Top processes by CPU if available',
|
||||||
|
'Run a multi-agent investigation of this host',
|
||||||
'How do Charts time presets work?',
|
'How do Charts time presets work?',
|
||||||
'What is retention / warm history?',
|
'What is retention / warm history?',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3659,6 +3659,104 @@ html[data-theme='light'] .proc-detail-cmd {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qvac-header-actions .hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-settings-panel {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-settings-panel.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-settings-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-settings-block h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-settings-block .settings-field,
|
||||||
|
.qvac-settings-block .settings-check {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-settings-block .settings-check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-settings-block select,
|
||||||
|
.qvac-settings-block input[type='number'] {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-primary, var(--bg));
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-settings-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-agent-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-agent-bar.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-agent-chip {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: color-mix(in srgb, var(--accent-primary) 12%, transparent);
|
||||||
|
color: var(--text-primary, var(--text));
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-agent-chip[data-status='start'] {
|
||||||
|
border-color: color-mix(in srgb, #60a5fa 50%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-agent-chip[data-status='done'] {
|
||||||
|
border-color: color-mix(in srgb, #34d399 50%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-agent-chip[data-status='error'] {
|
||||||
|
border-color: color-mix(in srgb, #f87171 55%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
.qvac-model-chip {
|
.qvac-model-chip {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
Reference in New Issue
Block a user