Stream the answer as it is written, and let the operator stop it
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user