diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 9fce12c..dd81d6b 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -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 diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index 04866ad..edeb7ca 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -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= 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= one stored conversation with its transcript // POST action=ask question=… [history=] [kind=…] [think=0|1] // POST action=memory_set memory=… replace the memory file // POST action=clear token= discard a finished job +// POST action=chat_save [id=] profile=… messages= +// POST action=chat_delete id= // // RESPONSE // stats {"ok":true,"stats":{…}} // ask {"ok":true,"token":""} // 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":"","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. diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index b7965db..f75de51 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -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;