diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index 7bc0a17..238bd09 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -956,9 +956,14 @@ $thinking = trim($thinking); // and the token ledger all see the text without it. Matched only at the very end, so a MEMORY: // mentioned mid-answer while explaining this feature is not mistaken for one being filed. if (vv_ai_mem_learn_enabled() && $answer !== '') { - if (preg_match('/\n[ \t]*MEMORY:[ \t]*(.+?)[ \t]*$/s', "\n" . $answer, $mm)) { + // No /s, and the tail is [^\n]+ rather than .+ — the marker must BE the last line, which is + // what the comment above always claimed. With /s the dot crossed newlines, so a lazy group + // anchored at $ matched the FIRST line-initial MEMORY: and captured everything after it to + // the end: asking the assistant to explain this very feature filed the rest of its own answer + // as a proposal and deleted it from the transcript. + if (preg_match('/\n[ \t]*MEMORY:[ \t]*([^\n]+?)[ \t]*$/', "\n" . $answer, $mm)) { $candidate = trim(preg_replace('/\s+/', ' ', $mm[1])); - $answer = trim(preg_replace('/\n[ \t]*MEMORY:[ \t]*.+?[ \t]*$/s', '', "\n" . $answer)); + $answer = trim(preg_replace('/\n[ \t]*MEMORY:[ \t]*[^\n]+?[ \t]*$/', '', "\n" . $answer)); if ($candidate !== '') { $r = vv_ai_mem_propose($candidate, [ diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index bb965ec..a968d0d 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -650,6 +650,15 @@ function vv_ai_memory_learned_max(): int { function vv_ai_memory_applies(string $kind, string $profile): bool { $vars = vv_conf_vars(); + + // The learned slot answers to the same gate that fills it. Without this, switching + // AI_MEMORY_LEARN_ENABLED off stopped new proposals but left every line already kept in every + // future prompt — a gate that closes the tap and not the tank. Gated here rather than at the + // one call site so any future consumer of vv_ai_memory_assemble() inherits it, and deliberately + // NOT in vv_ai_memory_read(), which reports what is on disk for the AI tab to display. + if ($kind === 'learned' + && strtolower(trim($vars['AI_MEMORY_LEARN_ENABLED'] ?? 'false')) !== 'true') return false; + $key = $kind === 'learned' ? 'AI_MEMORY_LEARNED_PROFILES' : 'AI_MEMORY_ASSISTED_PROFILES'; $spec = trim($vars[$key] ?? '*'); if ($spec === '' || $spec === '*') return true; diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index d611298..84d6251 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -474,6 +474,20 @@ vv_ai_profiles_script(); return Math.floor(d/86400)+'d'; } + // Both timestamps, because the visible one cannot answer both questions. The store is ordered + // by creation on purpose — see vv_ai_chats_list(), which keeps the visible order in step with + // the prune order so the row about to be dropped is the one at the bottom — and the column + // therefore has to show creation age or it would run out of order. "When did I last touch + // this", which is what is actually being asked of a list of saved conversations, goes here. + function chatAges(c) { + const made = 'started ' + ago(c.ts); + // A minute of slack: every conversation is written once at creation, so updated is always a + // shade later and saying so on every row would be noise. + return (c.updated && c.updated > (c.ts || 0) + 60) + ? made + ', last active ' + ago(c.updated) + : made; + } + // ── Source viewer, one per page ───────────────────────────────────────── const $g = id => document.getElementById(id); window.vvAiOpen = function (path) { @@ -528,7 +542,27 @@ vv_ai_profiles_script(); let pendingTimer = null; const chatEl = () => $('chat'); - const scroll = () => { const c = chatEl(); c.scrollTop = c.scrollHeight; }; + + // A scroll event says the transcript moved. It does not say who moved it, and the browser + // fires an identical one either way — so syncFollow(), which answers for the operator on + // every scroll event, could answer on the strength of a scroll this component performed. + // Auto Scroll was reported as un-untickable during a stream, and this is the class of cause: + // the control cannot be authoritative while anything else is allowed to write to it. + // + // Held here rather than proven: the exact event that re-ticked it was not reproduced from the + // source, only the requirement that our own movement must never count as the operator's. + // If it turns out something else re-ticks the box, this guard is still correct and the real + // cause is still open — do not read this comment as saying the bug was diagnosed. + // + // A count, not a flag, because these nest: scroll() runs inside wrapped writes. Released on + // the next animation frame, because scroll events are dispatched in the rendering step ahead + // of requestAnimationFrame callbacks — so the guard is still up when ours arrives. + let selfMoves = 0; + function selfMove(fn) { + selfMoves++; + try { fn(); } finally { requestAnimationFrame(() => { if (selfMoves > 0) selfMoves--; }); } + } + const scroll = () => selfMove(() => { const c = chatEl(); c.scrollTop = c.scrollHeight; }); function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; } function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); } @@ -794,8 +828,12 @@ vv_ai_profiles_script(); const following = () => { const f = $('follow'); return !f || f.checked; }; function syncFollow() { - const f = $('follow'); - if (f) f.checked = nearBottom(); + // Only a scroll the operator actually performed may answer for them. Ours are ignored, or + // the control cannot be switched off while the thing it controls is running. + if (selfMoves === 0) { + const f = $('follow'); + if (f) f.checked = nearBottom(); + } syncJump(); } @@ -818,8 +856,10 @@ vv_ai_profiles_script(); const s = $('stream'); if (!s) return; if (!text) { s.hidden = true; return; } - s.hidden = false; - s.innerHTML = fmt(text); + // Wrapped because replacing the bubble's content changes the transcript's scroll height, + // and any scroll event that results is this component's doing rather than the operator's. + // See selfMove(). + selfMove(() => { s.hidden = false; s.innerHTML = fmt(text); }); scrollIfFollowing(); syncJump(); } @@ -834,10 +874,14 @@ vv_ai_profiles_script(); const on = seeThink() && text; t.hidden = !on; if (!on) return; - t.textContent = text; - // Pinned to its own newest line, so watching reasoning does not require scrolling the - // transcript while the answer is still arriving underneath it. - t.scrollTop = t.scrollHeight; + // Wrapped for the same reason streamInto's rewrite is: growing this block changes the + // transcript's scroll height, and the resulting event is ours rather than the operator's. + selfMove(() => { + t.textContent = text; + // Pinned to its own newest line, so watching reasoning does not require scrolling the + // transcript while the answer is still arriving underneath it. + t.scrollTop = t.scrollHeight; + }); scrollIfFollowing(); } @@ -902,7 +946,11 @@ vv_ai_profiles_script(); // Measured after it is in the DOM — scrollHeight is 0 on a detached node, so a fold decided // before appending would either never fire or fire on everything. foldIfLong(node); - scroll(); + // Obeys Follow like every other write. This was the one place that did not, and it was the + // worst place for it: unticking the box to hold your place while an answer arrives, only to + // be dragged to the bottom the instant it lands. + scrollIfFollowing(); + syncJump(); } // A long answer buries the composer on a 15" panel, and the composer is where the next thing @@ -1051,11 +1099,20 @@ vv_ai_profiles_script(); t.remove(); } + // The label is stashed on the element the first time and never re-read. Capturing it per + // press meant a second click inside the 1100ms window recorded "Copied" as the text to + // restore, and the button then read "Copied" for the rest of the page's life. The pending + // timer is cleared for the same reason: two in flight restore in the wrong order. function flashBtn(btn, msg) { - const was = btn.textContent; + if (btn.dataset.label === undefined) btn.dataset.label = btn.textContent; + clearTimeout(+btn.dataset.flash || 0); btn.textContent = msg; btn.classList.add('ok'); - setTimeout(() => { btn.textContent = was; btn.classList.remove('ok'); }, 1100); + btn.dataset.flash = setTimeout(() => { + btn.textContent = btn.dataset.label; + btn.classList.remove('ok'); + delete btn.dataset.flash; + }, 1100); } // ── Last-exchange controls ─────────────────────────────────────────── @@ -1226,6 +1283,10 @@ vv_ai_profiles_script(); // 25-75s is exactly when the operator most wants a control, and the composer row has no // room for a fourth button that is dead 95% of the time. setSendMode('stop'); + // Asking is an act of attention: a new turn returns to the newest line and follows it, + // whatever was left unticked from reading back through the previous one. Without this, a + // question asked after scrolling up would stream in entirely off screen. + const followBox = $('follow'); if (followBox) followBox.checked = true; addUser(q); $('input').value = ''; clearDraft(); @@ -1427,9 +1488,14 @@ vv_ai_profiles_script(); c.innerHTML = ''; messages.forEach((m, i) => { if (m.role === 'user') { addUser(m.content); return; } - c.appendChild(el(`
Varaverk
` - + `
${fmt(m.content)}
` - + (m.offer ? offerHtml(m.offer, i) : '') + `
`)); + const node = el(`
Varaverk
` + + `
${fmt(m.content)}
` + + (m.offer ? offerHtml(m.offer, i) : '') + `
`); + c.appendChild(node); + // Folded here too, not only on arrival. A reopened thread carrying several long answers + // is exactly the case the fold exists for — it is what buries the composer on a 15" + // panel. Measured after the append, because scrollHeight is 0 on a detached node. + foldIfLong(node); }); scroll(); syncLast(); @@ -1616,7 +1682,7 @@ vv_ai_profiles_script(); const prof = (c.profile && c.profile !== 'chat' && PR[c.profile]) ? PR[c.profile].short : ''; const tag = [prof, c.scope || ''].filter(Boolean).join(' · '); return ``; @@ -1696,6 +1762,13 @@ vv_ai_profiles_script(); // passive: this only reads scroll position, so it must never delay the scroll itself. chatEl().addEventListener('scroll', syncFollow, { passive: true }); + const followEl = $('follow'); + if (followEl) { + // Ticking it by hand is a request to be at the newest line now, not merely to be taken + // there by the next delta — which on a finished conversation is never. + followEl.addEventListener('change', () => { if (followEl.checked) scroll(); syncJump(); }); + } + const seeEl = $('see-think'); if (seeEl) { // A display preference, so it is remembered per instance — the Monitor card and the AI tab @@ -2023,7 +2096,8 @@ vv_ai_profiles_script(); const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : ''; const tag = [prof, c.scope || ''].filter(Boolean).join(' · '); return `
` - + `` + + `` + (tag ? `${esc(tag)} ` : '') + `${esc(c.title)}` + `${esc(ago(c.ts))}` diff --git a/Plugin/unraid/include/ai_memory_learn.php b/Plugin/unraid/include/ai_memory_learn.php index 3f1bbf8..c4eb905 100644 --- a/Plugin/unraid/include/ai_memory_learn.php +++ b/Plugin/unraid/include/ai_memory_learn.php @@ -139,7 +139,9 @@ function vv_ai_mem_propose(string $text, array $meta = []): array { if (!$r['ok']) $row['error'] = $r['error']; } - @file_put_contents(vv_ai_mem_dir() . "/$id.json", json_encode($row, JSON_PRETTY_PRINT)); + if (!vv_ai_mem_write_row(vv_ai_mem_dir() . "/$id.json", $row)) { + return ['ok' => false, 'error' => 'could not file the proposal']; + } return ['ok' => true, 'id' => $id, 'state' => $row['state']]; } @@ -178,30 +180,63 @@ function vv_ai_mem_list(string $state = ''): array { return $out; } +// Temp and rename, so a concurrent list() never decodes half a record — a torn read here is +// indistinguishable from a corrupt proposal and the row silently vanishes from the card. +function vv_ai_mem_write_row(string $f, array $row): bool { + $tmp = $f . '.tmp'; + if (@file_put_contents($tmp, json_encode($row, JSON_PRETTY_PRINT)) === false) { + @unlink($tmp); + return false; + } + if (!@rename($tmp, $f)) { @unlink($tmp); return false; } + return true; +} + +// One lock for every decision, not one per proposal. Accept is a read-modify-write across two +// files — this proposal and the learned slot — and two of them interleaving is how the same line +// lands in memory twice, or how one of two accepted lines is lost to a temp-and-rename that began +// before the other finished. The card arms and disables the button, so this only has to hold +// against a second tab, a double submit or an impatient reload; that is exactly when it matters, +// because none of those are visible from the one that is about to lose. function vv_ai_mem_action(string $id, string $act): array { - if (!preg_match('/^[0-9a-f]{12}$/', $id)) return ['ok' => false, 'error' => 'bad id']; + if (!preg_match('/^[0-9a-f]{12}$/', $id)) return ['ok' => false, 'error' => 'bad id']; + if ($act !== 'accept' && $act !== 'dismiss') return ['ok' => false, 'error' => 'unknown action']; + $f = vv_ai_mem_dir() . "/$id.json"; if (!file_exists($f)) return ['ok' => false, 'error' => 'no such proposal']; - $d = json_decode((string)@file_get_contents($f), true); - if (!is_array($d)) return ['ok' => false, 'error' => 'unreadable proposal']; - if (($d['state'] ?? '') !== 'open') { - // A tab left open overnight must not act on a choice the store has already moved past. - return ['ok' => false, 'error' => 'already ' . ($d['state'] ?? 'closed')]; + $lock = @fopen(vv_ai_mem_dir() . '/.lock', 'c'); + if ($lock === false || !flock($lock, LOCK_EX)) { + if ($lock !== false) fclose($lock); + return ['ok' => false, 'error' => 'could not lock the proposal store']; } - if ($act === 'accept') { - $r = vv_ai_mem_append((string)$d['text']); - if (!$r['ok']) return $r; - $d['state'] = 'accepted'; - $d['accepted'] = time(); - } elseif ($act === 'dismiss') { - $d['state'] = 'dismissed'; - $d['closed'] = time(); - } else { - return ['ok' => false, 'error' => 'unknown action']; - } + try { + // Re-read under the lock. Whatever the card was showing when it was clicked is not + // evidence of anything — the decision may already have been made in another tab. + $d = json_decode((string)@file_get_contents($f), true); + if (!is_array($d)) return ['ok' => false, 'error' => 'unreadable proposal']; + if (($d['state'] ?? '') !== 'open') { + // A tab left open overnight must not act on a choice the store has already moved past. + return ['ok' => false, 'error' => 'already ' . ($d['state'] ?? 'closed')]; + } - @file_put_contents($f, json_encode($d, JSON_PRETTY_PRINT)); - return ['ok' => true, 'state' => $d['state']]; + if ($act === 'accept') { + $r = vv_ai_mem_append((string)$d['text']); + if (!$r['ok']) return $r; + $d['state'] = 'accepted'; + $d['accepted'] = time(); + } else { + $d['state'] = 'dismissed'; + $d['closed'] = time(); + } + + if (!vv_ai_mem_write_row($f, $d)) { + return ['ok' => false, 'error' => 'could not write the proposal']; + } + return ['ok' => true, 'state' => $d['state']]; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } }