Let each control actually govern the thing it names
Auto Scroll, the learning gate, the fold and the flash label each described a behaviour they did not enforce.
This commit is contained in:
@@ -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:
|
// 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.
|
// mentioned mid-answer while explaining this feature is not mistaken for one being filed.
|
||||||
if (vv_ai_mem_learn_enabled() && $answer !== '') {
|
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]));
|
$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 !== '') {
|
if ($candidate !== '') {
|
||||||
$r = vv_ai_mem_propose($candidate, [
|
$r = vv_ai_mem_propose($candidate, [
|
||||||
|
|||||||
@@ -650,6 +650,15 @@ function vv_ai_memory_learned_max(): int {
|
|||||||
function vv_ai_memory_applies(string $kind, string $profile): bool
|
function vv_ai_memory_applies(string $kind, string $profile): bool
|
||||||
{
|
{
|
||||||
$vars = vv_conf_vars();
|
$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';
|
$key = $kind === 'learned' ? 'AI_MEMORY_LEARNED_PROFILES' : 'AI_MEMORY_ASSISTED_PROFILES';
|
||||||
$spec = trim($vars[$key] ?? '*');
|
$spec = trim($vars[$key] ?? '*');
|
||||||
if ($spec === '' || $spec === '*') return true;
|
if ($spec === '' || $spec === '*') return true;
|
||||||
|
|||||||
@@ -474,6 +474,20 @@ vv_ai_profiles_script();
|
|||||||
return Math.floor(d/86400)+'d';
|
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 ─────────────────────────────────────────
|
// ── Source viewer, one per page ─────────────────────────────────────────
|
||||||
const $g = id => document.getElementById(id);
|
const $g = id => document.getElementById(id);
|
||||||
window.vvAiOpen = function (path) {
|
window.vvAiOpen = function (path) {
|
||||||
@@ -528,7 +542,27 @@ vv_ai_profiles_script();
|
|||||||
let pendingTimer = null;
|
let pendingTimer = null;
|
||||||
|
|
||||||
const chatEl = () => $('chat');
|
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 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(); }
|
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; };
|
const following = () => { const f = $('follow'); return !f || f.checked; };
|
||||||
|
|
||||||
function syncFollow() {
|
function syncFollow() {
|
||||||
const f = $('follow');
|
// Only a scroll the operator actually performed may answer for them. Ours are ignored, or
|
||||||
if (f) f.checked = nearBottom();
|
// 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();
|
syncJump();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,8 +856,10 @@ vv_ai_profiles_script();
|
|||||||
const s = $('stream');
|
const s = $('stream');
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
if (!text) { s.hidden = true; return; }
|
if (!text) { s.hidden = true; return; }
|
||||||
s.hidden = false;
|
// Wrapped because replacing the bubble's content changes the transcript's scroll height,
|
||||||
s.innerHTML = fmt(text);
|
// 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();
|
scrollIfFollowing();
|
||||||
syncJump();
|
syncJump();
|
||||||
}
|
}
|
||||||
@@ -834,10 +874,14 @@ vv_ai_profiles_script();
|
|||||||
const on = seeThink() && text;
|
const on = seeThink() && text;
|
||||||
t.hidden = !on;
|
t.hidden = !on;
|
||||||
if (!on) return;
|
if (!on) return;
|
||||||
t.textContent = text;
|
// Wrapped for the same reason streamInto's rewrite is: growing this block changes the
|
||||||
// Pinned to its own newest line, so watching reasoning does not require scrolling the
|
// transcript's scroll height, and the resulting event is ours rather than the operator's.
|
||||||
// transcript while the answer is still arriving underneath it.
|
selfMove(() => {
|
||||||
t.scrollTop = t.scrollHeight;
|
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();
|
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
|
// 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.
|
// before appending would either never fire or fire on everything.
|
||||||
foldIfLong(node);
|
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
|
// 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();
|
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) {
|
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.textContent = msg;
|
||||||
btn.classList.add('ok');
|
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 ───────────────────────────────────────────
|
// ── 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
|
// 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.
|
// room for a fourth button that is dead 95% of the time.
|
||||||
setSendMode('stop');
|
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);
|
addUser(q);
|
||||||
$('input').value = '';
|
$('input').value = '';
|
||||||
clearDraft();
|
clearDraft();
|
||||||
@@ -1427,9 +1488,14 @@ vv_ai_profiles_script();
|
|||||||
c.innerHTML = '';
|
c.innerHTML = '';
|
||||||
messages.forEach((m, i) => {
|
messages.forEach((m, i) => {
|
||||||
if (m.role === 'user') { addUser(m.content); return; }
|
if (m.role === 'user') { addUser(m.content); return; }
|
||||||
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
const node = el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||||||
+ `<div class="vv-ai-body">${fmt(m.content)}</div>`
|
+ `<div class="vv-ai-body">${fmt(m.content)}</div>`
|
||||||
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`));
|
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`);
|
||||||
|
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();
|
scroll();
|
||||||
syncLast();
|
syncLast();
|
||||||
@@ -1616,7 +1682,7 @@ vv_ai_profiles_script();
|
|||||||
const prof = (c.profile && c.profile !== 'chat' && PR[c.profile]) ? PR[c.profile].short : '';
|
const prof = (c.profile && c.profile !== 'chat' && PR[c.profile]) ? PR[c.profile].short : '';
|
||||||
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
|
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
|
||||||
return `<button class="vv-ai-opt${c.id === chatId ? ' active' : ''}" type="button"`
|
return `<button class="vv-ai-opt${c.id === chatId ? ' active' : ''}" type="button"`
|
||||||
+ ` role="option" data-chat="${esc(c.id)}">`
|
+ ` role="option" data-chat="${esc(c.id)}" title="${esc(chatAges(c))}">`
|
||||||
+ `<span class="vv-ai-opt-l">${esc(c.title)}</span>`
|
+ `<span class="vv-ai-opt-l">${esc(c.title)}</span>`
|
||||||
+ `<span class="vv-ai-opt-h">${esc([tag, ago(c.ts)].filter(Boolean).join(' · '))}</span>`
|
+ `<span class="vv-ai-opt-h">${esc([tag, ago(c.ts)].filter(Boolean).join(' · '))}</span>`
|
||||||
+ `</button>`;
|
+ `</button>`;
|
||||||
@@ -1696,6 +1762,13 @@ vv_ai_profiles_script();
|
|||||||
// passive: this only reads scroll position, so it must never delay the scroll itself.
|
// passive: this only reads scroll position, so it must never delay the scroll itself.
|
||||||
chatEl().addEventListener('scroll', syncFollow, { passive: true });
|
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');
|
const seeEl = $('see-think');
|
||||||
if (seeEl) {
|
if (seeEl) {
|
||||||
// A display preference, so it is remembered per instance — the Monitor card and the AI tab
|
// 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 prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : '';
|
||||||
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
|
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
|
||||||
return `<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
|
return `<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
|
||||||
+ `<span class="vv-ai-crow-t" title="${esc(tag ? tag + ' — ' + c.title : c.title)}">`
|
+ `<span class="vv-ai-crow-t" title="${esc((tag ? tag + ' — ' : '') + c.title
|
||||||
|
+ ' · ' + chatAges(c))}">`
|
||||||
+ (tag ? `<span class="vv-ai-crow-s">${esc(tag)}</span> ` : '')
|
+ (tag ? `<span class="vv-ai-crow-s">${esc(tag)}</span> ` : '')
|
||||||
+ `${esc(c.title)}</span>`
|
+ `${esc(c.title)}</span>`
|
||||||
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`
|
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`
|
||||||
|
|||||||
@@ -139,7 +139,9 @@ function vv_ai_mem_propose(string $text, array $meta = []): array {
|
|||||||
if (!$r['ok']) $row['error'] = $r['error'];
|
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']];
|
return ['ok' => true, 'id' => $id, 'state' => $row['state']];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,30 +180,63 @@ function vv_ai_mem_list(string $state = ''): array {
|
|||||||
return $out;
|
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 {
|
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";
|
$f = vv_ai_mem_dir() . "/$id.json";
|
||||||
if (!file_exists($f)) return ['ok' => false, 'error' => 'no such proposal'];
|
if (!file_exists($f)) return ['ok' => false, 'error' => 'no such proposal'];
|
||||||
|
|
||||||
$d = json_decode((string)@file_get_contents($f), true);
|
$lock = @fopen(vv_ai_mem_dir() . '/.lock', 'c');
|
||||||
if (!is_array($d)) return ['ok' => false, 'error' => 'unreadable proposal'];
|
if ($lock === false || !flock($lock, LOCK_EX)) {
|
||||||
if (($d['state'] ?? '') !== 'open') {
|
if ($lock !== false) fclose($lock);
|
||||||
// A tab left open overnight must not act on a choice the store has already moved past.
|
return ['ok' => false, 'error' => 'could not lock the proposal store'];
|
||||||
return ['ok' => false, 'error' => 'already ' . ($d['state'] ?? 'closed')];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($act === 'accept') {
|
try {
|
||||||
$r = vv_ai_mem_append((string)$d['text']);
|
// Re-read under the lock. Whatever the card was showing when it was clicked is not
|
||||||
if (!$r['ok']) return $r;
|
// evidence of anything — the decision may already have been made in another tab.
|
||||||
$d['state'] = 'accepted';
|
$d = json_decode((string)@file_get_contents($f), true);
|
||||||
$d['accepted'] = time();
|
if (!is_array($d)) return ['ok' => false, 'error' => 'unreadable proposal'];
|
||||||
} elseif ($act === 'dismiss') {
|
if (($d['state'] ?? '') !== 'open') {
|
||||||
$d['state'] = 'dismissed';
|
// A tab left open overnight must not act on a choice the store has already moved past.
|
||||||
$d['closed'] = time();
|
return ['ok' => false, 'error' => 'already ' . ($d['state'] ?? 'closed')];
|
||||||
} else {
|
}
|
||||||
return ['ok' => false, 'error' => 'unknown action'];
|
|
||||||
}
|
|
||||||
|
|
||||||
@file_put_contents($f, json_encode($d, JSON_PRETTY_PRINT));
|
if ($act === 'accept') {
|
||||||
return ['ok' => true, 'state' => $d['state']];
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user