Deep Research: NDJSON streaming so the connection survives long runs
Previously the endpoint returned a single JSON object at the end. Apache+ PHP-FPM buffers the entire body until PHP exits, so a 160s azure_full run caused the browser to drop the fetch as "Failed to fetch" while the server was still synthesising — the response then arrived to a dead socket. Switch to application/x-ndjson with one event per line. The endpoint emits 'progress', 'start', 'step' (running/complete/warning/error), 'subq', and a final 'final' event carrying the full result payload. Output buffering is explicitly disabled so each line flushes through Apache as soon as the agent emits it. DbnDeepResearchAgent::run() now accepts an optional ?callable $emit and fires step:running before each step + step:complete after, plus a subq event per sub-question retrieval round. JS reads response.body as a stream, splits on newlines, updates the trace panel live, and renders the final result when the final event arrives. Status pill shows live progress detail (e.g. "Synthesising with Azure gpt-4o — this is the slowest step…"). Engine row in the form now shows expected duration per engine (~15-45s mini, ~60-180s full, ~30-90s GPU) so users know what they're in for before clicking Run.
This commit is contained in:
+121
-22
@@ -232,27 +232,38 @@
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('Running deep research…', 'busy');
|
||||
els.runButton.disabled = true;
|
||||
els.results.innerHTML = `<div class="empty-state"><h3>Working…</h3><p>The agent is expanding the question, retrieving from the corpus, and synthesising the brief. This usually takes 6–15 seconds.</p></div>`;
|
||||
const engine = getEngine();
|
||||
const expectedDuration = engine === 'azure_full'
|
||||
? '60–180 seconds with Azure gpt-4o'
|
||||
: (engine === 'gpu' ? '30–90 seconds on GPU' : '15–45 seconds with Azure gpt-4o-mini');
|
||||
|
||||
// Render placeholder trace with first step running
|
||||
const placeholder = STEP_LABELS.map((label, i) => ({
|
||||
label,
|
||||
detail: i === 0 ? 'Running…' : 'Queued',
|
||||
status: i === 0 ? 'running' : 'idle',
|
||||
}));
|
||||
renderTrace(placeholder);
|
||||
setStatus(`Running deep research… (${expectedDuration})`, 'busy');
|
||||
els.runButton.disabled = true;
|
||||
els.results.innerHTML = `<div class="empty-state"><h3>Working…</h3><p>The agent is expanding your question and researching the corpus. Live progress in the right-hand panel. Expect ${expectedDuration}.</p></div>`;
|
||||
|
||||
// Initialise the trace with all 7 steps as 'idle'
|
||||
const stepState = STEP_LABELS.map((label) => ({ label, detail: 'Queued', status: 'idle' }));
|
||||
renderTrace(stepState);
|
||||
|
||||
const payload = {
|
||||
query,
|
||||
paste_text: '',
|
||||
slices,
|
||||
engine: getEngine(),
|
||||
engine,
|
||||
language: lang,
|
||||
controls: getControls(),
|
||||
};
|
||||
|
||||
const stepKeyToIndex = {
|
||||
interpretation: 0,
|
||||
expansion: 1,
|
||||
slice_resolution: 2,
|
||||
upload_indexing: 3,
|
||||
retrieval: 4,
|
||||
synthesis: 5,
|
||||
confidence: 6,
|
||||
};
|
||||
|
||||
let response;
|
||||
try {
|
||||
if (uploadFiles.length > 0) {
|
||||
@@ -271,25 +282,113 @@
|
||||
} catch (err) {
|
||||
setStatus(`Network error: ${err.message || err}`, 'error');
|
||||
els.runButton.disabled = false;
|
||||
stepState[0] = { ...stepState[0], status: 'error', detail: String(err) };
|
||||
renderTrace(stepState);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = null;
|
||||
try { data = await response.json(); } catch (_) {}
|
||||
|
||||
if (!response.ok || !data || data.ok === false) {
|
||||
const msg = (data && data.error && data.error.message) || `Request failed (${response.status}).`;
|
||||
setStatus(msg, 'error');
|
||||
if (!response.ok || !response.body) {
|
||||
setStatus(`Request failed (${response.status}).`, 'error');
|
||||
els.runButton.disabled = false;
|
||||
renderTrace(placeholder.map((s, i) => i === 0 ? { ...s, status: 'error', detail: msg } : s));
|
||||
return;
|
||||
}
|
||||
|
||||
lastResult = data;
|
||||
setStatus(`Done in ${data.latency_ms || 0} ms · ${data.trace_metadata?.source_count || 0} sources · confidence ${data.trace_metadata?.citation_confidence || '?'}`, 'ok');
|
||||
// Read the NDJSON stream
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalResult = null;
|
||||
let errorEvent = null;
|
||||
let progressDetail = '';
|
||||
|
||||
while (true) {
|
||||
let chunk;
|
||||
try {
|
||||
chunk = await reader.read();
|
||||
} catch (err) {
|
||||
setStatus(`Stream error: ${err.message || err}`, 'error');
|
||||
els.runButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
const { done, value } = chunk;
|
||||
if (value) {
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let evt;
|
||||
try { evt = JSON.parse(trimmed); } catch (_) { continue; }
|
||||
handleStreamEvent(evt);
|
||||
}
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
|
||||
if (errorEvent) {
|
||||
setStatus(`${errorEvent.code}: ${errorEvent.message}`, 'error');
|
||||
els.runButton.disabled = false;
|
||||
// Mark the currently-running step as error
|
||||
const runningIdx = stepState.findIndex((s) => s.status === 'running');
|
||||
if (runningIdx >= 0) {
|
||||
stepState[runningIdx] = { ...stepState[runningIdx], status: 'error', detail: errorEvent.message };
|
||||
renderTrace(stepState);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!finalResult) {
|
||||
setStatus('Stream ended without a final result.', 'error');
|
||||
els.runButton.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
lastResult = finalResult;
|
||||
const meta = finalResult.trace_metadata || {};
|
||||
setStatus(
|
||||
`Done in ${Math.round((finalResult.latency_ms || 0) / 1000)} s · ${meta.source_count || 0} sources · confidence ${meta.citation_confidence || '?'}`,
|
||||
'ok'
|
||||
);
|
||||
els.runButton.disabled = false;
|
||||
renderTrace(data.trace || []);
|
||||
renderResults(data);
|
||||
renderTrace(finalResult.trace || []);
|
||||
renderResults(finalResult);
|
||||
|
||||
function handleStreamEvent(evt) {
|
||||
if (!evt || !evt.event) return;
|
||||
if (evt.event === 'progress') {
|
||||
progressDetail = evt.detail || '';
|
||||
if (progressDetail) setStatus(progressDetail, 'busy');
|
||||
return;
|
||||
}
|
||||
if (evt.event === 'start') {
|
||||
setStatus(`Running… engine=${evt.engine}, uploads=${evt.upload_count || 0}`, 'busy');
|
||||
return;
|
||||
}
|
||||
if (evt.event === 'step') {
|
||||
const idx = stepKeyToIndex[evt.step];
|
||||
if (idx === undefined) return;
|
||||
stepState[idx] = {
|
||||
label: evt.label || stepState[idx].label,
|
||||
detail: evt.detail || stepState[idx].detail,
|
||||
status: evt.status || stepState[idx].status,
|
||||
};
|
||||
renderTrace(stepState);
|
||||
return;
|
||||
}
|
||||
if (evt.event === 'subq') {
|
||||
setStatus(`Retrieving sub-question ${evt.index}/${evt.total}: ${evt.question.slice(0, 80)}${evt.question.length > 80 ? '…' : ''}`, 'busy');
|
||||
return;
|
||||
}
|
||||
if (evt.event === 'final') {
|
||||
finalResult = evt.result;
|
||||
return;
|
||||
}
|
||||
if (evt.event === 'error') {
|
||||
errorEvent = evt;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(message, kind) {
|
||||
|
||||
Reference in New Issue
Block a user