Keep conversations, so a useful answer outlives the tab it was asked in
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user