Multi Agent Investigations
This commit is contained in:
+289
-12
@@ -6,6 +6,11 @@ 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'
|
||||
import {
|
||||
shouldUseSubAgents,
|
||||
runSubAgents,
|
||||
synthesizeFromAgents,
|
||||
} from './agents.js'
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
@@ -20,6 +25,9 @@ import { partitionThink, renderAssistantHtml } from './think.js'
|
||||
* modelChip: HTMLElement|null,
|
||||
* samples: HTMLElement|null,
|
||||
* setupSteps: HTMLElement|null,
|
||||
* agentBar?: HTMLElement|null,
|
||||
* settingsBtn?: HTMLElement|null,
|
||||
* settingsPanel?: HTMLElement|null,
|
||||
* resetBtn?: HTMLElement|null,
|
||||
* unloadBtn?: HTMLElement|null,
|
||||
* newChatBtn?: HTMLElement|null,
|
||||
@@ -68,11 +76,15 @@ export function createQvacView(opts) {
|
||||
peerId: opts.manager.active?.publicKeyHex || '',
|
||||
role: opts.getRole?.() || 'viewer',
|
||||
connected: Boolean(opts.isConnected?.()),
|
||||
subAgents: settings().qvacSubAgents !== false,
|
||||
}),
|
||||
getCatalog: () => opts.getCatalog?.() || {},
|
||||
getPrefs: () => ({
|
||||
rag: settings().qvacRag !== false,
|
||||
idleUnloadMin: Number(settings().qvacIdleUnloadMin) || 0,
|
||||
subAgents: settings().qvacSubAgents !== false,
|
||||
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
|
||||
toolDepth: settings().qvacToolDepth || 'auto',
|
||||
}),
|
||||
log: opts.log,
|
||||
})
|
||||
@@ -95,18 +107,24 @@ export function createQvacView(opts) {
|
||||
el.dataset.kind = kind
|
||||
}
|
||||
|
||||
function isModelLoaded() {
|
||||
const st = engine.getStatus()
|
||||
return st.status === 'ready' && (st.sdkLoaded || st.mode === 'qvac' || settings().qvacMode === 'qvac')
|
||||
}
|
||||
|
||||
function syncModelChip() {
|
||||
const chip = opts.els.modelChip
|
||||
if (!chip) return
|
||||
const st = engine.getStatus()
|
||||
const profile = getProfile(settings().qvacProfile || selectedProfile)
|
||||
if (st.mode === 'fallback' || (!st.sdkAvailable && st.status !== 'ready')) {
|
||||
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.dataset.mode = 'fallback'
|
||||
chip.title = st.sdkError || 'Install @qvac/sdk for full local LLM'
|
||||
} else if (st.status === 'ready') {
|
||||
chip.textContent = profile.chatModel
|
||||
chip.dataset.mode = 'ready'
|
||||
} else if (st.status === 'downloading' || st.status === 'loading') {
|
||||
const pct = st.progress?.percentage
|
||||
chip.textContent =
|
||||
@@ -116,6 +134,35 @@ export function createQvacView(opts) {
|
||||
chip.textContent = 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() {
|
||||
@@ -127,8 +174,123 @@ export function createQvacView(opts) {
|
||||
} else {
|
||||
setup?.classList.remove('hidden')
|
||||
chat?.classList.add('hidden')
|
||||
closeSettings()
|
||||
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). */
|
||||
@@ -632,6 +794,7 @@ export function createQvacView(opts) {
|
||||
let acc = ''
|
||||
let thinkingAcc = ''
|
||||
setStatus('Thinking…', 'busy')
|
||||
clearAgentBar()
|
||||
try {
|
||||
// History for the model: clean answers only (no think tags)
|
||||
const hist = messages
|
||||
@@ -644,14 +807,57 @@ export function createQvacView(opts) {
|
||||
})
|
||||
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, {
|
||||
...completeOpts,
|
||||
onToken: (t) => {
|
||||
acc += t
|
||||
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
|
||||
@@ -660,7 +866,6 @@ export function createQvacView(opts) {
|
||||
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()
|
||||
@@ -678,7 +883,6 @@ export function createQvacView(opts) {
|
||||
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
|
||||
@@ -724,6 +928,45 @@ export function createQvacView(opts) {
|
||||
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() {
|
||||
opts.els.sendBtn?.addEventListener('click', () => send())
|
||||
opts.els.input?.addEventListener('keydown', (ev) => {
|
||||
@@ -732,13 +975,47 @@ export function createQvacView(opts) {
|
||||
send()
|
||||
}
|
||||
})
|
||||
opts.els.resetBtn?.addEventListener('click', () => resetOnboarding())
|
||||
opts.els.unloadBtn?.addEventListener('click', async () => {
|
||||
await engine.unload()
|
||||
setStatus('Model unloaded', 'ok')
|
||||
opts.els.resetBtn?.addEventListener('click', () => {
|
||||
closeSettings()
|
||||
resetOnboarding()
|
||||
})
|
||||
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()
|
||||
})
|
||||
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} */
|
||||
|
||||
Reference in New Issue
Block a user