Keep conversations, so a useful answer outlives the tab it was asked in

This commit is contained in:
Gmer4Lfe
2026-08-08 22:00:58 -04:00
parent df3c4bb369
commit 1e324636e0
3 changed files with 205 additions and 1 deletions
+124
View File
@@ -1150,6 +1150,130 @@ function vv_ai_incidents_for(string $scope, int $max = 4): array {
return array_slice(array_reverse($hits), 0, max(1, $max));
}
// ── Stored conversations ──────────────────────────────────────────────────────
// Chats live in DATA_DIR, not /tmp, because the point of storing them is that they outlive a
// reboot — the job files above are deliberately the opposite. One file per conversation, same
// shape as ai_bugs: a directory of small JSON records is trivially prunable and a corrupt one
// costs a single chat rather than the whole history.
//
// The store is capped, and pruning happens on write rather than on a schedule. Nothing else
// runs often enough to be trusted with it, and an unbounded directory here would quietly grow
// for as long as the operator keeps talking to the assistant.
function vv_ai_chats_dir(): string {
$d = DATA_DIR . '/ai_chats';
if (!is_dir($d)) @mkdir($d, 0755, true);
return $d;
}
function vv_ai_chats_max(): int {
$n = (int)(vv_conf_vars()['AI_CHAT_HISTORY_MAX'] ?? 10);
return max(1, min(50, $n));
}
// Hex-only, for exactly the reason vv_ai_job_path() is: the id composes a path. Ids are minted
// server-side and the client only ever echoes one back, so anything else is a caller that has
// no business naming a file here.
function vv_ai_chat_path(string $id): ?string {
if (!preg_match('/^[0-9a-f]{32}$/', $id)) return null;
return vv_ai_chats_dir() . '/' . $id . '.json';
}
// The first thing asked, which is what the operator will recognise the conversation by. Falls
// back rather than returning empty: a blank row in the list is indistinguishable from a broken
// one.
function vv_ai_chat_title(array $messages): string {
foreach ($messages as $m) {
if (($m['role'] ?? '') !== 'user') continue;
$t = trim(preg_replace('/\s+/', ' ', (string)($m['content'] ?? '')));
if ($t !== '') return mb_substr($t, 0, 80);
}
return 'Untitled conversation';
}
// Newest first, metadata only. The list card renders ten rows and none of them need the
// transcript — sending every message of every stored chat to draw a sidebar would be the most
// expensive read on the monitor page.
function vv_ai_chats_list(): array {
$out = [];
foreach (glob(vv_ai_chats_dir() . '/*.json') ?: [] as $f) {
$d = json_decode(@file_get_contents($f) ?: '', true);
if (!is_array($d) || empty($d['id'])) continue;
$out[] = [
'id' => $d['id'],
'ts' => (int)($d['ts'] ?? 0),
'profile' => (string)($d['profile'] ?? 'chat'),
'title' => (string)($d['title'] ?? 'Untitled conversation'),
'turns' => (int)($d['turns'] ?? count($d['messages'] ?? [])),
];
}
usort($out, fn($a, $b) => $b['ts'] <=> $a['ts']);
return $out;
}
function vv_ai_chat_read(string $id): ?array {
$p = vv_ai_chat_path($id);
if ($p === null || !file_exists($p)) return null;
$d = json_decode(@file_get_contents($p) ?: '', true);
return is_array($d) ? $d : null;
}
function vv_ai_chat_delete(string $id): bool {
$p = vv_ai_chat_path($id);
if ($p === null || !file_exists($p)) return false;
return @unlink($p);
}
// Oldest go first, by stored timestamp rather than mtime — a chat that is reopened and continued
// is rewritten, and ordering on mtime would make "the one I have been using all week" look like
// the newest thing in the store while a genuinely older thread got dropped in its place.
function vv_ai_chats_prune(?int $max = null): int {
$max = $max ?? vv_ai_chats_max();
$list = vv_ai_chats_list();
$n = 0;
foreach (array_slice($list, $max) as $old) {
if (vv_ai_chat_delete($old['id'])) $n++;
}
return $n;
}
// 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 {
if (!$messages) return ['ok' => false, 'error' => 'Nothing to save'];
if ($id === '') $id = bin2hex(random_bytes(16));
$p = vv_ai_chat_path($id);
if ($p === null) return ['ok' => false, 'error' => 'Invalid chat id'];
// Preserve the original creation time across rewrites. Ordering the list by last activity
// would be defensible, but it would also mean an old thread jumps the queue the moment it is
// reopened, and the prune above is written against creation order.
$prev = vv_ai_chat_read($id);
$created = (int)($prev['created'] ?? time());
$rec = [
'id' => $id,
'created' => $created,
'ts' => $created,
'updated' => time(),
'profile' => $profile,
'title' => vv_ai_chat_title($messages),
'turns' => (int)floor(count($messages) / 2),
'messages' => $messages,
];
$tmp = $p . '.tmp';
if (@file_put_contents($tmp, json_encode($rec, JSON_PRETTY_PRINT)) === false
|| !@rename($tmp, $p)) {
@unlink($tmp);
return ['ok' => false, 'error' => 'Could not write ' . $p];
}
vv_ai_chats_prune();
return ['ok' => true, 'id' => $id, 'title' => $rec['title']];
}
function vv_ai_job_dir(): string {
if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true);
return VV_AI_JOB_DIR;