Put the Scheduler dock on the same conversation store as everything else

This commit is contained in:
Gmer4Lfe
2026-08-09 00:16:09 -04:00
parent f4fd17be4d
commit 073352b21e
4 changed files with 105 additions and 9 deletions
+7 -1
View File
@@ -256,7 +256,13 @@ if ($action === 'chat_save') {
$cap = vv_ai_profiles_max_turns() * 2;
if (count($clean) > $cap) $clean = array_slice($clean, -$cap);
$r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean);
// Whitelisted exactly as ask's is, and for the same reason: a scope is only ever a name from
// a page's own view state, it is stored and later replayed into a prompt, and anything
// richer than a file name is an instruction-injection surface for no benefit.
$scope = trim($_POST['scope'] ?? '');
if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';
$r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean, $scope);
vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12)
: 'FAILED: ' . $r['error']));
echo json_encode($r);
+9 -1
View File
@@ -1207,6 +1207,7 @@ function vv_ai_chats_list(): array {
'id' => $d['id'],
'ts' => (int)($d['ts'] ?? 0),
'profile' => (string)($d['profile'] ?? 'chat'),
'scope' => (string)($d['scope'] ?? ''),
'title' => (string)($d['title'] ?? 'Untitled conversation'),
'turns' => (int)($d['turns'] ?? count($d['messages'] ?? [])),
];
@@ -1244,7 +1245,7 @@ function vv_ai_chats_prune(?int $max = null): int {
// Writes a whole conversation. An empty id mints one; a known id overwrites in place, which is
// what makes a continued conversation stay one row in the list instead of breeding a new one per
// turn. Written to a temp file and renamed, so a reader never sees half a transcript.
function vv_ai_chat_save(string $id, string $profile, array $messages): array {
function vv_ai_chat_save(string $id, string $profile, array $messages, string $scope = ''): array {
if (!$messages) return ['ok' => false, 'error' => 'Nothing to save'];
if ($id === '') $id = bin2hex(random_bytes(16));
@@ -1257,12 +1258,19 @@ function vv_ai_chat_save(string $id, string $profile, array $messages): array {
$prev = vv_ai_chat_read($id);
$created = (int)($prev['created'] ?? time());
// Scope travels with the conversation. A Scheduler dock thread is bound to what the operator
// had open — a script, a log, a conf key — and its turns carry log excerpts chosen for that
// thing. Storing the scope means reopening the thread anywhere restores the context it was
// reasoned in, rather than silently continuing a troubleshooting conversation against
// whatever happens to be on screen. The isolation the dock enforces in memory becomes a
// property of the record instead of something lost the moment it is saved.
$rec = [
'id' => $id,
'created' => $created,
'ts' => $created,
'updated' => time(),
'profile' => $profile,
'scope' => $scope,
'title' => vv_ai_chat_title($messages),
'turns' => (int)floor(count($messages) / 2),
'messages' => $messages,
+64 -6
View File
@@ -70,10 +70,51 @@ require_once __DIR__ . '/ai_profiles.php';
// Emitted once even if two instances are rendered. A second copy of the script would re-register
// the factories harmlessly but would also install a second Escape handler and a second copy of
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
// The conversation store, on its own, with no styling and no chat widget attached.
//
// Emitted separately because the surface that most needs it is the one that does not want the
// rest: the Scheduler dock draws its own one-line bar, with its own scope chip and fix flow, and
// pulling in the full transcript stylesheet to reach a save function would restyle a component
// that was deliberately built to look different.
//
// Where a conversation lives is not a presentation decision, so it gets one answer for every
// surface. Anything added to the store — a new field, a new cap, a changed prune rule — arrives
// everywhere at once because there is only one writer.
function vv_ai_chat_store_script(): void {
static $done = false;
if ($done) return;
$done = true;
?>
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
// Returns the id so a caller can keep appending to one conversation instead of minting a row
// per turn. Fire and forget on failure: a store that cannot be written is not a reason to lose
// the answer already on screen.
window.VvAiChatSave = function (msgs, profile, scope, id) {
if (!msgs || !msgs.length) return Promise.resolve(null);
return fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({
action: 'chat_save', id: id || '', profile: profile || 'chat',
scope: scope || '', messages: JSON.stringify(msgs),
}),
})
.then(r => r.json())
.then(d => (d && d.ok) ? d.id : null)
.catch(() => null);
};
})();
</script>
<?php
}
function vv_ai_chat_assets(): void {
static $done = false;
if ($done) return;
$done = true;
vv_ai_chat_store_script();
?>
<style>
/* ── Shared tone ─────────────────────────────────────────────────────────── */
@@ -197,6 +238,8 @@ function vv_ai_chat_assets(): void {
white-space:nowrap; flex:1; min-width:0; }
.vv-ai-crow.active .vv-ai-crow-t { color:#9bd; }
.vv-ai-crow-m { font-size:9px; color:#3a3a3a; font-family:monospace; flex-shrink:0; }
.vv-ai-crow-s { font-size:9px; color:#5c7cfa; font-family:monospace; }
.vv-ai-c .vv-ai-crow-s { color:#7a9ae0; }
.vv-ai-crow-x { font-size:11px; color:#333; flex-shrink:0; padding:0 2px; visibility:hidden; }
.vv-ai-crow:hover .vv-ai-crow-x { visibility:visible; }
.vv-ai-crow-x:hover { color:#e57; }
@@ -518,6 +561,7 @@ vv_ai_profiles_script();
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({
action: 'chat_save', id: chatId, profile,
scope: (typeof o.scope === 'function' ? o.scope() : (o.scope || '')),
messages: JSON.stringify(messages),
}) })
.then(r => r.json())
@@ -525,6 +569,7 @@ vv_ai_profiles_script();
.catch(() => {});
}
// A reopened conversation renders as plain turns. Sources, reasoning and timings are not
// stored: they describe one generation, and redrawing them beside a transcript that may be
// continued under a different profile would be citing evidence for an answer that is no
@@ -655,12 +700,25 @@ vv_ai_profiles_script();
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
return;
}
box.innerHTML = '<div class="vv-ai-clist">' + rows.map(c =>
`<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
+ `<span class="vv-ai-crow-t" title="${esc(c.title)}">${esc(c.title)}</span>`
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`
+ `<span class="vv-ai-crow-x" data-del="${esc(c.id)}" title="Delete">×</span>`
+ `</div>`).join('') + '</div>';
// The scope is shown, not just stored. A Scheduler dock thread is about one script or log,
// and a list of titles alone makes "why does this one talk about a log I never opened"
// an unanswerable question.
// Tagged only when the tag says something. A scope always does; a profile does unless it
// is the ordinary one — labelling every General Chat row "Chat" is a column of noise that
// makes the rows that are genuinely different harder to pick out, which is the opposite
// of the point.
const P = window.VvAiProfiles || {};
box.innerHTML = '<div class="vv-ai-clist">' + rows.map(c => {
const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : '';
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
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)}">`
+ (tag ? `<span class="vv-ai-crow-s">${esc(tag)}</span> ` : '')
+ `${esc(c.title)}</span>`
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`
+ `<span class="vv-ai-crow-x" data-del="${esc(c.id)}" title="Delete">×</span>`
+ `</div>`;
}).join('') + '</div>';
}
function load() {
+25 -1
View File
@@ -65,6 +65,9 @@
require_once dirname(__DIR__) . '/include/scheduler.php';
require_once dirname(__DIR__) . '/include/docs.php';
require_once dirname(__DIR__) . '/include/ai_profiles.php';
// For vv_ai_chat_store_script() only — this page renders no chat widget. The store is shared;
// the presentation deliberately is not.
require_once dirname(__DIR__) . '/include/ai_chat.php';
// Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived
// path constants — those are not conf keys, but they are exactly what a reader needs resolved
@@ -1060,7 +1063,13 @@ Still the same two servers, two households, the same media stack running itself.
At rest it is one input row. Answers expand it upward and the views above shrink to
suit, which is why vvFitRight() subtracts its height. It pushes rather than overlays
so the thing you are asking about stays on screen. Collapsing keeps the conversation. -->
<?php vv_ai_profiles_script(); ?>
<?php
// The registry and the conversation store — not the chat widget. This dock draws its own
// bar and must keep looking like itself; what it must not have is its own answer to where
// a conversation lives.
vv_ai_profiles_script();
vv_ai_chat_store_script();
?>
<div id="vv-ai-dock">
<div id="vv-ai-dock-body" style="display:none"></div>
<div id="vv-ai-dock-bar">
@@ -2350,6 +2359,7 @@ function vvClickCog(el) {
let vvAiProfile = 'varaverk';
let vvAiScope = 'Scheduler';
let vvAiHist = []; // what the model is told; reset when the scope changes
let vvAiChatId = ""; // the stored conversation this thread is appending to
let vvAiBusy = false;
let vvAiPoll = null;
@@ -2381,6 +2391,10 @@ function vvAiDockScope(profile, label, target) {
// question about a conf key, but hiding that the earlier exchange happened is worse.
if (had) {
vvAiHist = [];
// A new stored conversation too, not a continuation of the last one. The scope is part of
// the record, so appending turns about a different thing to the same row would produce a
// conversation whose stored scope describes only its first half.
vvAiChatId = '';
vvAiDockAppend('<div class="vv-ai-dock-sep">now looking at ' + vvEscHtml(label) + '</div>');
}
// The scope moved under text already typed. Not blocked — just never silent.
@@ -2564,6 +2578,16 @@ function vvAiDockPoll(token, started) {
if (j.status === 'done') {
vvAiHist.push({ role: 'assistant', content: j.answer || '' });
vvAiDockDone(j.answer || '(empty answer)', false, j.sources || []);
// Same store as the AI tab and the Monitor row, through the same writer. This dock
// kept its conversation in a JavaScript array and nothing else, so a thread died on
// reload — including the troubleshooting ones, which are the most expensive to have
// had and the most annoying to lose. The scope goes with it so reopening the thread
// restores what it was reasoned about rather than continuing it against whatever
// happens to be on screen.
if (window.VvAiChatSave) {
window.VvAiChatSave(vvAiHist, vvAiProfile, vvAiScope, vvAiChatId)
.then(id => { if (id) vvAiChatId = id; });
}
fetch('/plugins/varaverk/api/ai.php', { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});