Stream the answer as it is written, and let the operator stop it

This commit is contained in:
Gmer4Lfe
2026-08-10 20:22:57 -04:00
parent 9c744b34f8
commit eb24e60b8c
3 changed files with 245 additions and 23 deletions
+66 -12
View File
@@ -126,6 +126,11 @@ if ($explain) {
if ($jobFile === '' || $question === '') exit(1);
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);
// Claim the job immediately. Retrieval can take a second or two and a profile that skips it
// goes straight to the model, so without this the first write could be a streaming flush —
// leaving a Stop pressed early with no pid to signal and nothing to show for the press.
jw($jobFile, ['status' => 'starting']);
}
// What got attached and why, recorded as it happens rather than reconstructed afterwards.
@@ -174,9 +179,21 @@ $can = function (string $cap) use (&$profile): bool { return vv_ai_profile_can($
// Explain mode has no job file and no tab waiting on one, so the state writes are dropped rather
// than special-cased at each of their call sites.
// Atomic by way of rename(2), which is what makes streaming safe. While generating, this runs
// several times a second; a plain file_put_contents would eventually be caught mid-write by a
// poll, and vv_ai_job_read() json_decodes a truncated file to null, which the endpoint reports as
// "pending" — the page would read a job halfway through generating as one that had not started.
// The temp name carries the pid so two writers can never collide on it.
function jw(string $f, array $d): void {
if ($f === '') return;
file_put_contents($f, json_encode($d));
// The pid rides on every write rather than just the first, so Stop always has something to
// signal no matter which state the job is caught in — and so it cannot go missing when a
// later write forgets to carry it forward, which is exactly how the notify stamp was lost
// in the findings store.
if (!isset($d['pid'])) $d['pid'] = getmypid();
$tmp = $f . '.' . getmypid() . '.tmp';
if (@file_put_contents($tmp, json_encode($d)) === false) return;
if (!@rename($tmp, $f)) @unlink($tmp);
}
// Same log the endpoint writes to, tagged so the two are tellable apart. Defined here because
@@ -827,10 +844,15 @@ if ($explain) {
exit(0);
}
// Streamed rather than awaited. At ~61 t/s a long answer is several seconds of a spinner, and the
// wait is the same either way — but seeing the first line lands lets the operator tell in about a
// second whether the question was understood, instead of finding out at the end. It also makes
// "generating" a state that genuinely exists: before this the worker never wrote it, so the page
// showed "starting…" for the entire run.
$payload = json_encode([
'model' => $cfg['model'],
'messages' => $messages,
'stream' => false,
'stream' => true,
'think' => $think === '1',
'options' => ['num_ctx' => 16384],
]);
@@ -844,23 +866,55 @@ $ctx = stream_context_create(['http' => [
'ignore_errors' => true,
]]);
$raw = @file_get_contents($cfg['url'] . '/api/chat', false, $ctx);
if ($raw === false) {
$fh = @fopen($cfg['url'] . '/api/chat', 'r', false, $ctx);
if ($fh === false) {
jw($jobFile, ['status' => 'error',
'error' => 'Ollama did not respond within ' . max(30, $cfg['timeout']) . 's at ' . $cfg['url'],
'sources' => $sources]);
exit(1);
}
$d = json_decode($raw, true);
if (!is_array($d) || !isset($d['message'])) {
jw($jobFile, ['status' => 'error', 'error' => 'Unparseable response from Ollama',
'sources' => $sources]);
exit(1);
}
// Ollama streams NDJSON — one JSON object per line, each carrying a delta. The final object has
// done=true and is the only one holding the eval counters, so it is kept as $d for the ledger
// below; losing it would silently stop token accounting.
$answer = '';
$thinking = '';
$d = [];
$answer = trim((string)($d['message']['content'] ?? ''));
$thinking = trim((string)($d['message']['thinking'] ?? ''));
// Throttle. The job file is on tmpfs so writes are cheap, but the page polls on its own interval
// and rewriting faster than it reads is pure waste.
$FLUSH_SEC = 0.12;
$lastFlush = 0.0;
while (($line = fgets($fh)) !== false) {
$line = trim($line);
if ($line === '') continue;
$o = json_decode($line, true);
// A non-JSON line means Ollama answered with an error body rather than a stream. Nothing to
// accumulate; the empty-answer check below reports it.
if (!is_array($o)) continue;
if (isset($o['message']['content'])) $answer .= (string)$o['message']['content'];
if (isset($o['message']['thinking'])) $thinking .= (string)$o['message']['thinking'];
if (!empty($o['done'])) { $d = $o; break; }
$now = microtime(true);
if ($now - $lastFlush >= $FLUSH_SEC) {
$lastFlush = $now;
// thinking_chars rather than the reasoning itself: with think on, nothing lands in
// content for ~15s, and a page that only watched partial would look hung.
jw($jobFile, ['status' => 'generating',
'partial' => $answer,
'thinking_chars' => strlen($thinking),
'sources' => $sources]);
}
}
fclose($fh);
$answer = trim($answer);
$thinking = trim($thinking);
if ($answer === '') {
jw($jobFile, ['status' => 'error',