This commit is contained in:
@@ -338,6 +338,7 @@ const qvacView = createQvacView({
|
|||||||
messages: $('qvac-messages'),
|
messages: $('qvac-messages'),
|
||||||
input: /** @type {HTMLTextAreaElement|null} */ ($('qvac-input')),
|
input: /** @type {HTMLTextAreaElement|null} */ ($('qvac-input')),
|
||||||
sendBtn: $('qvac-send'),
|
sendBtn: $('qvac-send'),
|
||||||
|
stopBtn: $('qvac-stop'),
|
||||||
status: $('qvac-status'),
|
status: $('qvac-status'),
|
||||||
modelChip: $('qvac-model-chip'),
|
modelChip: $('qvac-model-chip'),
|
||||||
samples: $('qvac-samples'),
|
samples: $('qvac-samples'),
|
||||||
|
|||||||
+124
-43
@@ -19,6 +19,10 @@ let modelId = null
|
|||||||
let loadedChatModel = null
|
let loadedChatModel = null
|
||||||
/** @type {Error|string|null} */
|
/** @type {Error|string|null} */
|
||||||
let lastError = null
|
let lastError = null
|
||||||
|
/** Active completion requestId for cancel() */
|
||||||
|
let activeRequestId = null
|
||||||
|
/** Soft flag — event loop checks between iterations */
|
||||||
|
let cancelRequested = false
|
||||||
|
|
||||||
function appRoot() {
|
function appRoot() {
|
||||||
try {
|
try {
|
||||||
@@ -162,6 +166,7 @@ async function complete(opts, onToken, onThinking) {
|
|||||||
const s = await ensureSdk()
|
const s = await ensureSdk()
|
||||||
if (!modelId) throw new Error('No model loaded')
|
if (!modelId) throw new Error('No model loaded')
|
||||||
|
|
||||||
|
cancelRequested = false
|
||||||
const run = s.completion({
|
const run = s.completion({
|
||||||
modelId,
|
modelId,
|
||||||
history: opts.history || [],
|
history: opts.history || [],
|
||||||
@@ -170,60 +175,117 @@ async function complete(opts, onToken, onThinking) {
|
|||||||
// Best-effort: emit thinkingDelta when model uses <think> blocks
|
// Best-effort: emit thinkingDelta when model uses <think> blocks
|
||||||
captureThinking: true,
|
captureThinking: true,
|
||||||
})
|
})
|
||||||
|
activeRequestId = run.requestId || run.id || null
|
||||||
|
|
||||||
let content = ''
|
let content = ''
|
||||||
let thinking = ''
|
let thinking = ''
|
||||||
/** @type {any[]} */
|
/** @type {any[]} */
|
||||||
const toolCalls = []
|
const toolCalls = []
|
||||||
|
let stopped = false
|
||||||
|
|
||||||
if (run.events) {
|
try {
|
||||||
for await (const ev of run.events) {
|
if (run.events) {
|
||||||
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
|
for await (const ev of run.events) {
|
||||||
const t = ev.text || ev.delta || ''
|
if (cancelRequested) {
|
||||||
thinking += t
|
stopped = true
|
||||||
onThinking?.(t)
|
break
|
||||||
} else if (ev.type === 'contentDelta' && ev.text) {
|
}
|
||||||
content += ev.text
|
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
|
||||||
onToken?.(ev.text)
|
const t = ev.text || ev.delta || ''
|
||||||
} else if (ev.type === 'toolCall') {
|
thinking += t
|
||||||
// Prefer structured call; also support nested toolCall
|
onThinking?.(t)
|
||||||
const call = ev.call || ev.toolCall || ev
|
} else if (ev.type === 'contentDelta' && ev.text) {
|
||||||
toolCalls.push({
|
content += ev.text
|
||||||
name: call.name || ev.name,
|
onToken?.(ev.text)
|
||||||
arguments: call.arguments || ev.arguments || {},
|
} else if (ev.type === 'toolCall') {
|
||||||
id: call.id || ev.id,
|
// Prefer structured call; also support nested toolCall
|
||||||
})
|
const call = ev.call || ev.toolCall || ev
|
||||||
} else if (ev.type === 'toolCallError') {
|
toolCalls.push({
|
||||||
// surface as content note; tool loop may continue
|
name: call.name || ev.name,
|
||||||
const msg = ev.error?.message || 'tool call error'
|
arguments: call.arguments || ev.arguments || {},
|
||||||
content += `\n[tool error: ${msg}]\n`
|
id: call.id || ev.id,
|
||||||
|
})
|
||||||
|
} else if (ev.type === 'toolCallError') {
|
||||||
|
// surface as content note; tool loop may continue
|
||||||
|
const msg = ev.error?.message || 'tool call error'
|
||||||
|
content += `\n[tool error: ${msg}]\n`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (run.tokenStream) {
|
||||||
|
for await (const token of run.tokenStream) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
stopped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
content += token
|
||||||
|
onToken?.(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (run.tokenStream) {
|
|
||||||
for await (const token of run.tokenStream) {
|
|
||||||
content += token
|
|
||||||
onToken?.(token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const final = run.final ? await run.final : { contentText: content, toolCalls }
|
if (stopped || cancelRequested) {
|
||||||
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
|
return {
|
||||||
// Prefer full text that may still include think tags if captureThinking missed
|
contentText: content,
|
||||||
const contentText = final.contentText || final.raw?.fullText || content
|
thinkingText: thinking,
|
||||||
const thinkingText =
|
toolCalls: [],
|
||||||
thinking ||
|
mode: 'qvac',
|
||||||
final.thinking ||
|
stopped: true,
|
||||||
final.raw?.thinking ||
|
error: null,
|
||||||
''
|
}
|
||||||
return {
|
}
|
||||||
contentText,
|
|
||||||
thinkingText,
|
const final = run.final ? await run.final : { contentText: content, toolCalls }
|
||||||
toolCalls: calls,
|
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
|
||||||
stats: final.stats,
|
// Prefer full text that may still include think tags if captureThinking missed
|
||||||
mode: 'qvac',
|
const contentText = final.contentText || final.raw?.fullText || content
|
||||||
|
const thinkingText =
|
||||||
|
thinking ||
|
||||||
|
final.thinking ||
|
||||||
|
final.raw?.thinking ||
|
||||||
|
''
|
||||||
|
return {
|
||||||
|
contentText,
|
||||||
|
thinkingText,
|
||||||
|
toolCalls: calls,
|
||||||
|
stats: final.stats,
|
||||||
|
mode: 'qvac',
|
||||||
|
stopped: Boolean(cancelRequested),
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeRequestId = null
|
||||||
|
cancelRequested = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel in-flight completion (and optional model-wide inference).
|
||||||
|
*/
|
||||||
|
async function cancelInference() {
|
||||||
|
cancelRequested = true
|
||||||
|
const rid = activeRequestId
|
||||||
|
try {
|
||||||
|
const s = sdk || (await ensureSdk().catch(() => null))
|
||||||
|
if (s?.cancel) {
|
||||||
|
if (rid) {
|
||||||
|
try {
|
||||||
|
await s.cancel({ requestId: rid })
|
||||||
|
} catch {
|
||||||
|
// fall through to broad cancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modelId) {
|
||||||
|
try {
|
||||||
|
await s.cancel({ modelId, kind: 'completion' })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { ok: true, requestId: rid }
|
||||||
|
}
|
||||||
|
|
||||||
function getStatus() {
|
function getStatus() {
|
||||||
const det = detectInstalled()
|
const det = detectInstalled()
|
||||||
return {
|
return {
|
||||||
@@ -288,15 +350,34 @@ function registerIpc(ipcMain) {
|
|||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err
|
lastError = err
|
||||||
|
const msg = err?.message || String(err)
|
||||||
|
// Cancel often surfaces as inference cancelled
|
||||||
|
if (/cancel|abort|stopped/i.test(msg)) {
|
||||||
|
return {
|
||||||
|
contentText: '',
|
||||||
|
thinkingText: '',
|
||||||
|
toolCalls: [],
|
||||||
|
stopped: true,
|
||||||
|
mode: 'qvac',
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
contentText: '',
|
contentText: '',
|
||||||
thinkingText: '',
|
thinkingText: '',
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
error: err?.message || String(err),
|
error: msg,
|
||||||
mode: 'error',
|
mode: 'error',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('peardata:qvac-cancel', async () => {
|
||||||
|
try {
|
||||||
|
return await cancelInference()
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err?.message || String(err) }
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -483,6 +483,7 @@
|
|||||||
<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>
|
||||||
<button type="button" id="qvac-send" class="btn btn-primary">Send</button>
|
<button type="button" id="qvac-send" class="btn btn-primary">Send</button>
|
||||||
|
<button type="button" id="qvac-stop" class="btn btn-ghost qvac-stop-btn hidden" title="Stop inference (Esc)">Stop</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ export function pickAgents(userText, maxAgents = 3) {
|
|||||||
* tools: { run: (name: string, args?: object) => Promise<any> },
|
* tools: { run: (name: string, args?: object) => Promise<any> },
|
||||||
* query: string,
|
* query: string,
|
||||||
* maxAgents?: number,
|
* maxAgents?: number,
|
||||||
|
* isAborted?: () => boolean,
|
||||||
* onAgent?: (ev: {
|
* onAgent?: (ev: {
|
||||||
* id: string,
|
* id: string,
|
||||||
* label: string,
|
* label: string,
|
||||||
@@ -239,15 +240,36 @@ export function pickAgents(userText, maxAgents = 3) {
|
|||||||
export async function runSubAgents(opts) {
|
export async function runSubAgents(opts) {
|
||||||
const specs = pickAgents(opts.query, opts.maxAgents ?? 3)
|
const specs = pickAgents(opts.query, opts.maxAgents ?? 3)
|
||||||
const tools = opts.tools
|
const tools = opts.tools
|
||||||
|
const aborted = () => Boolean(opts.isAborted?.())
|
||||||
|
|
||||||
/** @type {Array<{ id: string, label: string, status: string, summary: string, result?: any, error?: string }>} */
|
/** @type {Array<{ id: string, label: string, status: string, summary: string, result?: any, error?: string }>} */
|
||||||
const agents = []
|
const agents = []
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
specs.map(async (spec) => {
|
specs.map(async (spec) => {
|
||||||
|
if (aborted()) {
|
||||||
|
agents.push({
|
||||||
|
id: spec.id,
|
||||||
|
label: spec.label,
|
||||||
|
status: 'error',
|
||||||
|
summary: 'stopped',
|
||||||
|
error: 'stopped',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
opts.onAgent?.({ id: spec.id, label: spec.label, status: 'start' })
|
opts.onAgent?.({ id: spec.id, label: spec.label, status: 'start' })
|
||||||
try {
|
try {
|
||||||
const result = await spec.run(tools)
|
const result = await spec.run(tools)
|
||||||
|
if (aborted()) {
|
||||||
|
agents.push({
|
||||||
|
id: spec.id,
|
||||||
|
label: spec.label,
|
||||||
|
status: 'error',
|
||||||
|
summary: 'stopped',
|
||||||
|
error: 'stopped',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
const summary = spec.summarize(result)
|
const summary = spec.summarize(result)
|
||||||
const row = {
|
const row = {
|
||||||
id: spec.id,
|
id: spec.id,
|
||||||
|
|||||||
+208
-56
@@ -95,6 +95,47 @@ function getElectronIpc() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createQvacEngine(deps) {
|
export function createQvacEngine(deps) {
|
||||||
|
/** Soft cancel for current complete() / multi-round tool loop */
|
||||||
|
let abortGeneration = false
|
||||||
|
/** @type {string|null} */
|
||||||
|
let activeRequestId = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop in-flight inference (and pending tool rounds).
|
||||||
|
* Safe to call when idle.
|
||||||
|
*/
|
||||||
|
async function stop() {
|
||||||
|
abortGeneration = true
|
||||||
|
const ipc = getElectronIpc()
|
||||||
|
try {
|
||||||
|
if (ipc && sdkMode === 'main') {
|
||||||
|
await ipc.invoke('peardata:qvac-cancel')
|
||||||
|
} else if (sdk?.cancel) {
|
||||||
|
if (activeRequestId) {
|
||||||
|
try {
|
||||||
|
await sdk.cancel({ requestId: activeRequestId })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (modelId) {
|
||||||
|
try {
|
||||||
|
await sdk.cancel({ modelId, kind: 'completion' })
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
deps.log?.(`QVAC stop: ${err?.message || err}`)
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAborted() {
|
||||||
|
return abortGeneration
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {any} */
|
/** @type {any} */
|
||||||
let sdk = null
|
let sdk = null
|
||||||
/** @type {'direct'|'main'|null} */
|
/** @type {'direct'|'main'|null} */
|
||||||
@@ -442,6 +483,7 @@ export function createQvacEngine(deps) {
|
|||||||
*/
|
*/
|
||||||
async function complete(history, opts = {}) {
|
async function complete(history, opts = {}) {
|
||||||
touchActivity()
|
touchActivity()
|
||||||
|
abortGeneration = false
|
||||||
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')
|
||||||
@@ -471,6 +513,10 @@ export function createQvacEngine(deps) {
|
|||||||
...history.filter((m) => m.role !== 'system'),
|
...history.filter((m) => m.role !== 'system'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (abortGeneration) {
|
||||||
|
return { contentText: '_(Stopped.)_', toolCalls: [], mode: 'stopped', stopped: true }
|
||||||
|
}
|
||||||
|
|
||||||
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
|
if (modelId && (sdkMode === 'main' || (sdk && sdk.completion))) {
|
||||||
return completeWithSdk(fullHistory, profile, opts, prefs)
|
return completeWithSdk(fullHistory, profile, opts, prefs)
|
||||||
}
|
}
|
||||||
@@ -478,18 +524,40 @@ export function createQvacEngine(deps) {
|
|||||||
status = status === 'ready' ? status : 'fallback'
|
status = status === 'ready' ? status : 'fallback'
|
||||||
// If multi-agent pack already ran, prefer its synthesis over naive fallback
|
// If multi-agent pack already ran, prefer its synthesis over naive fallback
|
||||||
if (opts.agentFallbackText) {
|
if (opts.agentFallbackText) {
|
||||||
|
if (abortGeneration) {
|
||||||
|
return { contentText: '_(Stopped.)_', toolCalls: [], mode: 'stopped', stopped: true }
|
||||||
|
}
|
||||||
const text = String(opts.agentFallbackText)
|
const text = String(opts.agentFallbackText)
|
||||||
if (opts.onToken) {
|
if (opts.onToken) {
|
||||||
const chunk = 24
|
const chunk = 24
|
||||||
for (let i = 0; i < text.length; i += chunk) opts.onToken(text.slice(i, i + chunk))
|
for (let i = 0; i < text.length; i += chunk) {
|
||||||
|
if (abortGeneration) break
|
||||||
|
opts.onToken(text.slice(i, i + chunk))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' })
|
opts.onEvent?.({ type: 'completionDone', stopReason: abortGeneration ? 'cancelled' : 'eos' })
|
||||||
return { contentText: text, toolCalls: opts.agentToolCalls || [], mode: 'fallback' }
|
return {
|
||||||
|
contentText: abortGeneration ? `${text}\n\n_(Stopped.)_` : text,
|
||||||
|
toolCalls: opts.agentToolCalls || [],
|
||||||
|
mode: abortGeneration ? 'stopped' : 'fallback',
|
||||||
|
stopped: abortGeneration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (abortGeneration) {
|
||||||
|
return { contentText: '_(Stopped.)_', toolCalls: [], mode: 'stopped', stopped: true }
|
||||||
}
|
}
|
||||||
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,
|
||||||
})
|
})
|
||||||
|
if (abortGeneration) {
|
||||||
|
return {
|
||||||
|
contentText: (result.contentText || '') + '\n\n_(Stopped.)_',
|
||||||
|
toolCalls: result.toolCalls || [],
|
||||||
|
mode: 'stopped',
|
||||||
|
stopped: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const c of result.toolCalls || []) {
|
for (const c of result.toolCalls || []) {
|
||||||
opts.onTool?.(c.name, c.args, c.result)
|
opts.onTool?.(c.name, c.args, c.result)
|
||||||
}
|
}
|
||||||
@@ -497,11 +565,16 @@ export function createQvacEngine(deps) {
|
|||||||
const text = result.contentText || ''
|
const text = result.contentText || ''
|
||||||
const chunk = 24
|
const chunk = 24
|
||||||
for (let i = 0; i < text.length; i += chunk) {
|
for (let i = 0; i < text.length; i += chunk) {
|
||||||
|
if (abortGeneration) break
|
||||||
opts.onToken(text.slice(i, i + chunk))
|
opts.onToken(text.slice(i, i + chunk))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
opts.onEvent?.({ type: 'completionDone', stopReason: 'eos' })
|
opts.onEvent?.({ type: 'completionDone', stopReason: abortGeneration ? 'cancelled' : 'eos' })
|
||||||
return result
|
return {
|
||||||
|
...result,
|
||||||
|
stopped: abortGeneration,
|
||||||
|
mode: abortGeneration ? 'stopped' : result.mode,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeWithSdk(history, profile, opts, prefs = {}) {
|
async function completeWithSdk(history, profile, opts, prefs = {}) {
|
||||||
@@ -523,6 +596,14 @@ export function createQvacEngine(deps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (let round = 0; round < 4; round++) {
|
for (let round = 0; round < 4; round++) {
|
||||||
|
if (abortGeneration) {
|
||||||
|
return {
|
||||||
|
contentText: '_(Stopped.)_',
|
||||||
|
toolCalls: [],
|
||||||
|
mode: 'stopped',
|
||||||
|
stopped: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
touchActivity()
|
touchActivity()
|
||||||
// Re-compact each tool round (payloads grow fast)
|
// Re-compact each tool round (payloads grow fast)
|
||||||
messages = compactMessages(messages, {
|
messages = compactMessages(messages, {
|
||||||
@@ -539,14 +620,32 @@ export function createQvacEngine(deps) {
|
|||||||
/** @type {(( _e: any, t: string) => void)|null} */
|
/** @type {(( _e: any, t: string) => void)|null} */
|
||||||
let onThink = null
|
let onThink = null
|
||||||
try {
|
try {
|
||||||
onTok = (_e, t) => opts.onToken?.(t)
|
onTok = (_e, t) => {
|
||||||
onThink = (_e, t) => opts.onThinking?.(t)
|
if (!abortGeneration) opts.onToken?.(t)
|
||||||
|
}
|
||||||
|
onThink = (_e, t) => {
|
||||||
|
if (!abortGeneration) opts.onThinking?.(t)
|
||||||
|
}
|
||||||
ipc.on('peardata:qvac-token', onTok)
|
ipc.on('peardata:qvac-token', onTok)
|
||||||
ipc.on('peardata:qvac-thinking', onThink)
|
ipc.on('peardata:qvac-thinking', onThink)
|
||||||
const final = await ipc.invoke('peardata:qvac-complete', {
|
const final = await ipc.invoke('peardata:qvac-complete', {
|
||||||
history: messages,
|
history: messages,
|
||||||
tools: toolDefs,
|
tools: toolDefs,
|
||||||
})
|
})
|
||||||
|
if (final?.stopped || abortGeneration) {
|
||||||
|
let text = final?.contentText || ''
|
||||||
|
if (final?.thinkingText && !/<think/i.test(text)) {
|
||||||
|
text = `<think>\n${final.thinkingText}\n</think>\n${text}`
|
||||||
|
}
|
||||||
|
if (!text.trim()) text = '_(Stopped.)_'
|
||||||
|
else if (!/stopped/i.test(text)) text += '\n\n_(Stopped.)_'
|
||||||
|
return {
|
||||||
|
contentText: text,
|
||||||
|
toolCalls: [],
|
||||||
|
mode: 'stopped',
|
||||||
|
stopped: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
if (final?.error && !final.contentText) {
|
if (final?.error && !final.contentText) {
|
||||||
if (isContextOverflowError(final.error) && overflowRetries < 3) {
|
if (isContextOverflowError(final.error) && overflowRetries < 3) {
|
||||||
overflowRetries++
|
overflowRetries++
|
||||||
@@ -602,6 +701,14 @@ export function createQvacEngine(deps) {
|
|||||||
}
|
}
|
||||||
messages = [...messages, { role: 'assistant', content: text }]
|
messages = [...messages, { role: 'assistant', content: text }]
|
||||||
for (const tc of calls) {
|
for (const tc of calls) {
|
||||||
|
if (abortGeneration) {
|
||||||
|
return {
|
||||||
|
contentText: (text || '') + '\n\n_(Stopped.)_',
|
||||||
|
toolCalls: [],
|
||||||
|
mode: 'stopped',
|
||||||
|
stopped: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
const name = tc.name || tc.function?.name
|
const name = tc.name || tc.function?.name
|
||||||
let args = tc.arguments || tc.function?.arguments || {}
|
let args = tc.arguments || tc.function?.arguments || {}
|
||||||
if (typeof args === 'string') {
|
if (typeof args === 'string') {
|
||||||
@@ -647,65 +754,108 @@ export function createQvacEngine(deps) {
|
|||||||
tools: toolDefs,
|
tools: toolDefs,
|
||||||
captureThinking: true,
|
captureThinking: true,
|
||||||
})
|
})
|
||||||
|
activeRequestId = run.requestId || run.id || null
|
||||||
|
|
||||||
let content = ''
|
let content = ''
|
||||||
/** @type {Array<{ name: string, arguments?: any, id?: string }>} */
|
/** @type {Array<{ name: string, arguments?: any, id?: string }>} */
|
||||||
const toolCalls = []
|
const toolCalls = []
|
||||||
|
let streamStopped = false
|
||||||
|
|
||||||
if (run.events) {
|
try {
|
||||||
for await (const ev of run.events) {
|
if (run.events) {
|
||||||
opts.onEvent?.(ev)
|
for await (const ev of run.events) {
|
||||||
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
|
if (abortGeneration) {
|
||||||
opts.onThinking?.(ev.text || ev.delta)
|
streamStopped = true
|
||||||
} else if (ev.type === 'contentDelta' && ev.text) {
|
break
|
||||||
content += ev.text
|
}
|
||||||
opts.onToken?.(ev.text)
|
opts.onEvent?.(ev)
|
||||||
} else if (ev.type === 'toolCall') {
|
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
|
||||||
const call = ev.call || ev.toolCall || ev
|
opts.onThinking?.(ev.text || ev.delta)
|
||||||
toolCalls.push({
|
} else if (ev.type === 'contentDelta' && ev.text) {
|
||||||
name: call.name || ev.name,
|
content += ev.text
|
||||||
arguments: call.arguments || ev.arguments || {},
|
opts.onToken?.(ev.text)
|
||||||
id: call.id || ev.id,
|
} else if (ev.type === 'toolCall') {
|
||||||
})
|
const call = ev.call || ev.toolCall || ev
|
||||||
|
toolCalls.push({
|
||||||
|
name: call.name || ev.name,
|
||||||
|
arguments: call.arguments || ev.arguments || {},
|
||||||
|
id: call.id || ev.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (run.tokenStream) {
|
||||||
|
for await (const token of run.tokenStream) {
|
||||||
|
if (abortGeneration) {
|
||||||
|
streamStopped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
content += token
|
||||||
|
opts.onToken?.(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (run.tokenStream) {
|
|
||||||
for await (const token of run.tokenStream) {
|
|
||||||
content += token
|
|
||||||
opts.onToken?.(token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const final = run.final ? await run.final : { contentText: content, toolCalls }
|
if (streamStopped || abortGeneration) {
|
||||||
|
return {
|
||||||
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
|
contentText: (content || '') + (content ? '\n\n' : '') + '_(Stopped.)_',
|
||||||
if (!calls.length) {
|
toolCalls: [],
|
||||||
return {
|
mode: 'stopped',
|
||||||
contentText: final.contentText || content,
|
stopped: true,
|
||||||
toolCalls: [],
|
|
||||||
mode: 'qvac',
|
|
||||||
stats: final.stats,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = [...messages, { role: 'assistant', content: final.contentText || content || '' }]
|
|
||||||
for (const tc of calls) {
|
|
||||||
const name = tc.name || tc.function?.name
|
|
||||||
let args = tc.arguments || tc.function?.arguments || {}
|
|
||||||
if (typeof args === 'string') {
|
|
||||||
try {
|
|
||||||
args = JSON.parse(args)
|
|
||||||
} catch {
|
|
||||||
args = {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = await deps.tools.run(name, args)
|
|
||||||
opts.onTool?.(name, args, result)
|
const final = run.final ? await run.final : { contentText: content, toolCalls }
|
||||||
messages.push({
|
|
||||||
role: 'tool',
|
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
|
||||||
content: compactToolResult(name, result),
|
if (!calls.length) {
|
||||||
name,
|
return {
|
||||||
})
|
contentText: final.contentText || content,
|
||||||
|
toolCalls: [],
|
||||||
|
mode: 'qvac',
|
||||||
|
stats: final.stats,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
...messages,
|
||||||
|
{ role: 'assistant', content: final.contentText || content || '' },
|
||||||
|
]
|
||||||
|
for (const tc of calls) {
|
||||||
|
if (abortGeneration) {
|
||||||
|
return {
|
||||||
|
contentText: (final.contentText || content || '') + '\n\n_(Stopped.)_',
|
||||||
|
toolCalls: [],
|
||||||
|
mode: 'stopped',
|
||||||
|
stopped: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const name = tc.name || tc.function?.name
|
||||||
|
let args = tc.arguments || tc.function?.arguments || {}
|
||||||
|
if (typeof args === 'string') {
|
||||||
|
try {
|
||||||
|
args = JSON.parse(args)
|
||||||
|
} catch {
|
||||||
|
args = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await deps.tools.run(name, args)
|
||||||
|
opts.onTool?.(name, args, result)
|
||||||
|
messages.push({
|
||||||
|
role: 'tool',
|
||||||
|
content: compactToolResult(name, result),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
activeRequestId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortGeneration) {
|
||||||
|
return {
|
||||||
|
contentText: '_(Stopped.)_',
|
||||||
|
toolCalls: [],
|
||||||
|
mode: 'stopped',
|
||||||
|
stopped: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,6 +871,8 @@ export function createQvacEngine(deps) {
|
|||||||
loadProfile,
|
loadProfile,
|
||||||
unload,
|
unload,
|
||||||
complete,
|
complete,
|
||||||
|
stop,
|
||||||
|
isAborted,
|
||||||
getStatus,
|
getStatus,
|
||||||
tryLoadSdk,
|
tryLoadSdk,
|
||||||
touchActivity,
|
touchActivity,
|
||||||
|
|||||||
+99
-28
@@ -22,6 +22,7 @@ import { chartIdsFromToolLog, mountChartEmbeds } from './chart-embed.js'
|
|||||||
* messages: HTMLElement|null,
|
* messages: HTMLElement|null,
|
||||||
* input: HTMLTextAreaElement|null,
|
* input: HTMLTextAreaElement|null,
|
||||||
* sendBtn: HTMLElement|null,
|
* sendBtn: HTMLElement|null,
|
||||||
|
* stopBtn?: HTMLElement|null,
|
||||||
* status: HTMLElement|null,
|
* status: HTMLElement|null,
|
||||||
* modelChip: HTMLElement|null,
|
* modelChip: HTMLElement|null,
|
||||||
* samples: HTMLElement|null,
|
* samples: HTMLElement|null,
|
||||||
@@ -792,13 +793,36 @@ export function createQvacView(opts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setBusyUi(on) {
|
||||||
|
busy = on
|
||||||
|
const send = opts.els.sendBtn
|
||||||
|
const stop = opts.els.stopBtn
|
||||||
|
if (send) {
|
||||||
|
send.disabled = on
|
||||||
|
send.classList.toggle('hidden', on)
|
||||||
|
}
|
||||||
|
if (stop) {
|
||||||
|
stop.classList.toggle('hidden', !on)
|
||||||
|
stop.disabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopInference() {
|
||||||
|
if (!busy) return
|
||||||
|
setStatus('Stopping…', 'busy')
|
||||||
|
try {
|
||||||
|
await engine.stop?.()
|
||||||
|
} catch (err) {
|
||||||
|
opts.log?.(`QVAC stop: ${err?.message || err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
const input = opts.els.input
|
const input = opts.els.input
|
||||||
const text = (input?.value || '').trim()
|
const text = (input?.value || '').trim()
|
||||||
if (!text || busy) return
|
if (!text || busy) return
|
||||||
if (input) input.value = ''
|
if (input) input.value = ''
|
||||||
busy = true
|
setBusyUi(true)
|
||||||
opts.els.sendBtn && (opts.els.sendBtn.disabled = true)
|
|
||||||
appendMsg('user', text)
|
appendMsg('user', text)
|
||||||
const assistantUi = appendMsg('assistant', '…', { streaming: true })
|
const assistantUi = appendMsg('assistant', '…', { streaming: true })
|
||||||
const streamBody = assistantUi?.body
|
const streamBody = assistantUi?.body
|
||||||
@@ -826,7 +850,11 @@ export function createQvacView(opts) {
|
|||||||
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
|
maxSubAgents: Number(settings().qvacMaxSubAgents) || 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldUseSubAgents(text, prefs) && opts.isConnected?.()) {
|
if (
|
||||||
|
shouldUseSubAgents(text, prefs) &&
|
||||||
|
opts.isConnected?.() &&
|
||||||
|
!engine.isAborted?.()
|
||||||
|
) {
|
||||||
setStatus('Spinning up agents…', 'busy')
|
setStatus('Spinning up agents…', 'busy')
|
||||||
if (streamBody) {
|
if (streamBody) {
|
||||||
streamBody.innerHTML = formatMdLite('_Dispatching multi-agent investigation…_')
|
streamBody.innerHTML = formatMdLite('_Dispatching multi-agent investigation…_')
|
||||||
@@ -835,11 +863,19 @@ export function createQvacView(opts) {
|
|||||||
tools,
|
tools,
|
||||||
query: text,
|
query: text,
|
||||||
maxAgents: prefs.maxSubAgents,
|
maxAgents: prefs.maxSubAgents,
|
||||||
|
isAborted: () => Boolean(engine.isAborted?.()),
|
||||||
onAgent: (ev) => {
|
onAgent: (ev) => {
|
||||||
|
if (engine.isAborted?.()) return
|
||||||
paintAgentEvent(ev)
|
paintAgentEvent(ev)
|
||||||
setStatus(`Agent: ${ev.label}…`, 'busy')
|
setStatus(`Agent: ${ev.label}…`, 'busy')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (engine.isAborted?.()) {
|
||||||
|
acc = '_(Stopped during multi-agent investigation.)_'
|
||||||
|
paintAssistant(streamBody, acc, false)
|
||||||
|
setStatus('Stopped', 'warn')
|
||||||
|
return
|
||||||
|
}
|
||||||
completeOpts.agentBriefing = pack.contextText
|
completeOpts.agentBriefing = pack.contextText
|
||||||
completeOpts.agentFallbackText = synthesizeFromAgents(text, pack)
|
completeOpts.agentFallbackText = synthesizeFromAgents(text, pack)
|
||||||
completeOpts.agentToolCalls = pack.agents.map((a) => ({
|
completeOpts.agentToolCalls = pack.agents.map((a) => ({
|
||||||
@@ -862,6 +898,13 @@ export function createQvacView(opts) {
|
|||||||
setStatus('Synthesizing…', 'busy')
|
setStatus('Synthesizing…', 'busy')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (engine.isAborted?.()) {
|
||||||
|
acc = '_(Stopped.)_'
|
||||||
|
paintAssistant(streamBody, acc, false)
|
||||||
|
setStatus('Stopped', 'warn')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const result = await engine.complete(hist, {
|
const result = await engine.complete(hist, {
|
||||||
...completeOpts,
|
...completeOpts,
|
||||||
onToken: (t) => {
|
onToken: (t) => {
|
||||||
@@ -876,6 +919,7 @@ export function createQvacView(opts) {
|
|||||||
paintAssistant(streamBody, raw, true)
|
paintAssistant(streamBody, raw, true)
|
||||||
},
|
},
|
||||||
onTool: (name, args, res) => {
|
onTool: (name, args, res) => {
|
||||||
|
if (engine.isAborted?.()) return
|
||||||
toolLog.push({ name, args, result: res })
|
toolLog.push({ name, args, result: res })
|
||||||
setStatus(`Tool: ${name}…`, 'busy')
|
setStatus(`Tool: ${name}…`, 'busy')
|
||||||
if (assistantUi?.div) {
|
if (assistantUi?.div) {
|
||||||
@@ -905,35 +949,49 @@ export function createQvacView(opts) {
|
|||||||
assistantUi.div.appendChild(renderToolChips(toolLog))
|
assistantUi.div.appendChild(renderToolChips(toolLog))
|
||||||
}
|
}
|
||||||
// Live sparklines under the answer for chart-related tools
|
// Live sparklines under the answer for chart-related tools
|
||||||
const embedIds = chartIdsFromToolLog(toolLog)
|
if (!result.stopped) {
|
||||||
if (embedIds.length && assistantUi?.div && opts.manager?.request) {
|
const embedIds = chartIdsFromToolLog(toolLog)
|
||||||
mountChartEmbeds(assistantUi.div, embedIds, {
|
if (embedIds.length && assistantUi?.div && opts.manager?.request) {
|
||||||
request: (m, a) => opts.manager.request(m, a || {}),
|
mountChartEmbeds(assistantUi.div, embedIds, {
|
||||||
catalog: opts.getCatalog?.() || {},
|
request: (m, a) => opts.manager.request(m, a || {}),
|
||||||
onOpen: (id) => {
|
catalog: opts.getCatalog?.() || {},
|
||||||
opts.onOpenView?.('charts')
|
onOpen: (id) => {
|
||||||
if (opts.charts?.showCharts) {
|
opts.onOpenView?.('charts')
|
||||||
opts.charts.showCharts({
|
if (opts.charts?.showCharts) {
|
||||||
charts: [id],
|
opts.charts.showCharts({
|
||||||
pin: true,
|
charts: [id],
|
||||||
boardOnly: true,
|
pin: true,
|
||||||
focus: id,
|
boardOnly: true,
|
||||||
openFocus: true,
|
focus: id,
|
||||||
})
|
openFocus: true,
|
||||||
} else {
|
})
|
||||||
opts.onOpenChart?.(id)
|
} else {
|
||||||
}
|
opts.onOpenChart?.(id)
|
||||||
},
|
}
|
||||||
}).catch(() => {})
|
},
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.stopped || result.mode === 'stopped') {
|
||||||
|
setStatus('Stopped', 'warn')
|
||||||
|
} else {
|
||||||
|
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
|
||||||
}
|
}
|
||||||
setStatus(result.mode === 'fallback' ? 'Ready (tools-only)' : 'Ready', 'ok')
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err?.message || String(err)
|
const msg = err?.message || String(err)
|
||||||
if (streamBody) streamBody.innerHTML = formatMdLite(`Error: ${msg}`)
|
if (/cancel|abort|stopped/i.test(msg)) {
|
||||||
setStatus(msg, 'error')
|
if (streamBody && !acc) {
|
||||||
|
paintAssistant(streamBody, '_(Stopped.)_', false)
|
||||||
|
} else if (streamBody && acc) {
|
||||||
|
paintAssistant(streamBody, acc + '\n\n_(Stopped.)_', false)
|
||||||
|
}
|
||||||
|
setStatus('Stopped', 'warn')
|
||||||
|
} else {
|
||||||
|
if (streamBody) streamBody.innerHTML = formatMdLite(`Error: ${msg}`)
|
||||||
|
setStatus(msg, 'error')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = false
|
setBusyUi(false)
|
||||||
if (opts.els.sendBtn) opts.els.sendBtn.disabled = false
|
|
||||||
syncModelChip()
|
syncModelChip()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1003,12 +1061,25 @@ export function createQvacView(opts) {
|
|||||||
|
|
||||||
function bind() {
|
function bind() {
|
||||||
opts.els.sendBtn?.addEventListener('click', () => send())
|
opts.els.sendBtn?.addEventListener('click', () => send())
|
||||||
|
opts.els.stopBtn?.addEventListener('click', () => stopInference())
|
||||||
opts.els.input?.addEventListener('keydown', (ev) => {
|
opts.els.input?.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Escape' && busy) {
|
||||||
|
ev.preventDefault()
|
||||||
|
stopInference()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
send()
|
send()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Global Esc while QVAC tab is active
|
||||||
|
opts.els.root?.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Escape' && busy) {
|
||||||
|
ev.preventDefault()
|
||||||
|
stopInference()
|
||||||
|
}
|
||||||
|
})
|
||||||
opts.els.resetBtn?.addEventListener('click', () => {
|
opts.els.resetBtn?.addEventListener('click', () => {
|
||||||
closeSettings()
|
closeSettings()
|
||||||
resetOnboarding()
|
resetOnboarding()
|
||||||
|
|||||||
+16
-1
@@ -4329,12 +4329,27 @@ html[data-theme='light'] .proc-detail-cmd {
|
|||||||
|
|
||||||
.qvac-composer {
|
.qvac-composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qvac-stop-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-color: color-mix(in srgb, #f87171 50%, var(--border-color)) !important;
|
||||||
|
color: #fca5a5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-stop-btn:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, #f87171 18%, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qvac-stop-btn.hidden,
|
||||||
|
#qvac-send.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.qvac-composer textarea {
|
.qvac-composer textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
|||||||
Reference in New Issue
Block a user