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
+67 -1
View File
@@ -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.