Keep conversations, so a useful answer outlives the tab it was asked in
This commit is contained in:
@@ -1645,6 +1645,20 @@
|
||||
AI_MEMORY_FILE="$DATA_DIR/ai_memory.md"
|
||||
AI_MEMORY_MAX_CHARS=4000 # ~1000 tokens — truncated with a notice if exceeded
|
||||
|
||||
# ━━━ AI Stored Conversations ━━━
|
||||
# How many past conversations the AI tab and the Monitor tab's AI row keep. One JSON file per
|
||||
# conversation under DATA_DIR/ai_chats, saved automatically when a turn completes; the oldest
|
||||
# drop off once the count is exceeded.
|
||||
#
|
||||
# A cap rather than a retention age. These are read by picking one out of a short list, and a
|
||||
# list you have to scroll is a list you stop using — the useful window is the handful of things
|
||||
# you were recently working on, which is a count, not a date.
|
||||
#
|
||||
# Not indexed and never retrieved into a prompt on their own. A stored chat only re-enters the
|
||||
# model's context when the operator explicitly reopens it, and it is re-validated per message on
|
||||
# the way in, exactly as live history is.
|
||||
AI_CHAT_HISTORY_MAX=10 # conversations kept — clamped to 1-50
|
||||
|
||||
# ━━━ AI Token Accounting ━━━
|
||||
# One row per completed turn, appended by whichever path ran it — the WebGUI worker and the
|
||||
# ai_query.sh CLI both write here, so the totals are not silently the tab's alone. Format is
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
// History is validated per message, not trusted as a blob.
|
||||
// Role must be user or assistant, content must be a non-empty string, and each is
|
||||
// truncated. A crafted history could otherwise inject a system role or push the context
|
||||
// past the offload ceiling.
|
||||
// past the offload ceiling. chat_save applies the same validation, because a stored
|
||||
// conversation is replayed into a later prompt when it is reopened — an unchecked role
|
||||
// written there is an injection that survives a reload rather than one turn.
|
||||
//
|
||||
// The question is length-capped before it reaches a command line.
|
||||
// It is passed to the worker through escapeshellarg, but an unbounded string would still
|
||||
@@ -61,15 +63,21 @@
|
||||
// GET ?action=stats banner payload
|
||||
// GET ?action=poll&token=<hex32> job state
|
||||
// GET ?action=memory_get the operator memory file and its budget
|
||||
// GET ?action=chats stored conversations, newest first, metadata only
|
||||
// GET ?action=chat_get&id=<hex32> one stored conversation with its transcript
|
||||
// POST action=ask question=… [history=<JSON>] [kind=…] [think=0|1]
|
||||
// POST action=memory_set memory=… replace the memory file
|
||||
// POST action=clear token=<hex32> discard a finished job
|
||||
// POST action=chat_save [id=<hex32>] profile=… messages=<JSON>
|
||||
// POST action=chat_delete id=<hex32>
|
||||
//
|
||||
// RESPONSE
|
||||
// stats {"ok":true,"stats":{…}}
|
||||
// ask {"ok":true,"token":"<hex32>"}
|
||||
// poll {"ok":true,"job":{"status":"retrieving|generating|done|error",…}}
|
||||
// clear {"ok":true}
|
||||
// chats {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N}
|
||||
// chat_save {"ok":true,"id":"<hex32>","title":…}
|
||||
// {"ok":false,"error":…}
|
||||
//
|
||||
// DEPENDS ON
|
||||
@@ -201,6 +209,64 @@ if ($action === 'clear') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── chats ─────────────────────────────────────────────────────────────────────
|
||||
// Stored conversations. Listing and reading are GET because they change nothing; saving and
|
||||
// deleting are POST, so they ride Unraid's CSRF prepend like every other mutation here.
|
||||
//
|
||||
// Messages are validated per message on the way in, exactly as ask validates history and for
|
||||
// the same reason: a stored chat is replayed into a later prompt when the operator reopens it,
|
||||
// so a crafted role in the store would be an injection that survives a reload.
|
||||
if ($action === 'chats') {
|
||||
echo json_encode(['ok' => true, 'chats' => vv_ai_chats_list(), 'max' => vv_ai_chats_max()]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'chat_get') {
|
||||
$chat = vv_ai_chat_read(trim($_GET['id'] ?? ''));
|
||||
if ($chat === null) { echo json_encode(['ok' => false, 'error' => 'No such chat']); exit; }
|
||||
echo json_encode(['ok' => true, 'chat' => $chat]);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'chat_save') {
|
||||
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
|
||||
|
||||
$profile = trim($_POST['profile'] ?? 'chat');
|
||||
if (!isset(VV_AI_PROFILES[$profile])) {
|
||||
echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
$msgs = json_decode($_POST['messages'] ?? '[]', true);
|
||||
if (is_array($msgs)) {
|
||||
foreach ($msgs as $m) {
|
||||
$role = $m['role'] ?? '';
|
||||
$text = trim((string)($m['content'] ?? ''));
|
||||
if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue;
|
||||
$clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)];
|
||||
}
|
||||
}
|
||||
// Capped at the deepest profile's window rather than that of the profile in hand. A chat
|
||||
// saved under one profile can be reopened under another, and the reopened turn is trimmed
|
||||
// again on the way back out by ask — so storing a little more than any single profile will
|
||||
// send costs nothing and keeps the transcript readable.
|
||||
$cap = max(VV_AI_PROFILES) * 2;
|
||||
if (count($clean) > $cap) $clean = array_slice($clean, -$cap);
|
||||
|
||||
$r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean);
|
||||
vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12)
|
||||
: 'FAILED: ' . $r['error']));
|
||||
echo json_encode($r);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($action === 'chat_delete') {
|
||||
if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
|
||||
$ok = vv_ai_chat_delete(trim($_POST['id'] ?? ''));
|
||||
echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'No such chat']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── bugs / bug_close ──────────────────────────────────────────────────────────
|
||||
// Reports the troubleshooter filed. Listing is a GET because it changes nothing; dismissing is
|
||||
// a POST, like every other mutation in this plugin.
|
||||
|
||||
@@ -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