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
+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() {