Add the ability to stop inference
Release rolling / release (push) Canceled after 3m53s

This commit is contained in:
Raven Scott
2026-07-30 17:29:12 -04:00
parent 6581b826cb
commit 6abc5999f2
7 changed files with 471 additions and 128 deletions
+1
View File
@@ -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'),
+82 -1
View File
@@ -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,14 +175,21 @@ 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
try {
if (run.events) { if (run.events) {
for await (const ev of run.events) { for await (const ev of run.events) {
if (cancelRequested) {
stopped = true
break
}
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) { if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
const t = ev.text || ev.delta || '' const t = ev.text || ev.delta || ''
thinking += t thinking += t
@@ -201,11 +213,26 @@ async function complete(opts, onToken, onThinking) {
} }
} else if (run.tokenStream) { } else if (run.tokenStream) {
for await (const token of run.tokenStream) { for await (const token of run.tokenStream) {
if (cancelRequested) {
stopped = true
break
}
content += token content += token
onToken?.(token) onToken?.(token)
} }
} }
if (stopped || cancelRequested) {
return {
contentText: content,
thinkingText: thinking,
toolCalls: [],
mode: 'qvac',
stopped: true,
error: null,
}
}
const final = run.final ? await run.final : { contentText: content, toolCalls } const final = run.final ? await run.final : { contentText: content, toolCalls }
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean) const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
// Prefer full text that may still include think tags if captureThinking missed // Prefer full text that may still include think tags if captureThinking missed
@@ -221,7 +248,42 @@ async function complete(opts, onToken, onThinking) {
toolCalls: calls, toolCalls: calls,
stats: final.stats, stats: final.stats,
mode: 'qvac', 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() {
@@ -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 { return {
contentText: '', contentText: '',
thinkingText: '', thinkingText: '',
toolCalls: [], toolCalls: [],
error: err?.message || String(err), stopped: true,
mode: 'qvac',
}
}
return {
contentText: '',
thinkingText: '',
toolCalls: [],
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 = {
+1
View File
@@ -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>
+22
View File
@@ -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,
+160 -8
View File
@@ -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' }) }
return { contentText: text, toolCalls: opts.agentToolCalls || [], mode: 'fallback' } opts.onEvent?.({ type: 'completionDone', stopReason: abortGeneration ? 'cancelled' : 'eos' })
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,13 +754,20 @@ 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
try {
if (run.events) { if (run.events) {
for await (const ev of run.events) { for await (const ev of run.events) {
if (abortGeneration) {
streamStopped = true
break
}
opts.onEvent?.(ev) opts.onEvent?.(ev)
if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) { if (ev.type === 'thinkingDelta' && (ev.text || ev.delta)) {
opts.onThinking?.(ev.text || ev.delta) opts.onThinking?.(ev.text || ev.delta)
@@ -671,11 +785,24 @@ export function createQvacEngine(deps) {
} }
} else if (run.tokenStream) { } else if (run.tokenStream) {
for await (const token of run.tokenStream) { for await (const token of run.tokenStream) {
if (abortGeneration) {
streamStopped = true
break
}
content += token content += token
opts.onToken?.(token) opts.onToken?.(token)
} }
} }
if (streamStopped || abortGeneration) {
return {
contentText: (content || '') + (content ? '\n\n' : '') + '_(Stopped.)_',
toolCalls: [],
mode: 'stopped',
stopped: true,
}
}
const final = run.final ? await run.final : { contentText: content, toolCalls } const final = run.final ? await run.final : { contentText: content, toolCalls }
const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean) const calls = (final.toolCalls?.length ? final.toolCalls : toolCalls).filter(Boolean)
@@ -688,8 +815,19 @@ export function createQvacEngine(deps) {
} }
} }
messages = [...messages, { role: 'assistant', content: final.contentText || content || '' }] messages = [
...messages,
{ role: 'assistant', content: final.contentText || content || '' },
]
for (const tc of calls) { 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 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') {
@@ -707,6 +845,18 @@ export function createQvacEngine(deps) {
name, name,
}) })
} }
} finally {
activeRequestId = null
}
}
if (abortGeneration) {
return {
contentText: '_(Stopped.)_',
toolCalls: [],
mode: 'stopped',
stopped: true,
}
} }
return { return {
@@ -721,6 +871,8 @@ export function createQvacEngine(deps) {
loadProfile, loadProfile,
unload, unload,
complete, complete,
stop,
isAborted,
getStatus, getStatus,
tryLoadSdk, tryLoadSdk,
touchActivity, touchActivity,
+76 -5
View File
@@ -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,6 +949,7 @@ 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
if (!result.stopped) {
const embedIds = chartIdsFromToolLog(toolLog) const embedIds = chartIdsFromToolLog(toolLog)
if (embedIds.length && assistantUi?.div && opts.manager?.request) { if (embedIds.length && assistantUi?.div && opts.manager?.request) {
mountChartEmbeds(assistantUi.div, embedIds, { mountChartEmbeds(assistantUi.div, embedIds, {
@@ -926,14 +971,27 @@ export function createQvacView(opts) {
}, },
}).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 (/cancel|abort|stopped/i.test(msg)) {
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}`) if (streamBody) streamBody.innerHTML = formatMdLite(`Error: ${msg}`)
setStatus(msg, 'error') 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
View File
@@ -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;