diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index a451ae2..9d7949d 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -792,6 +792,69 @@ vv_ai_profiles_script(); } function clearDraft() { try { localStorage.removeItem(DRAFT_KEY); } catch (_) {} } + // ── Slash commands ─────────────────────────────────────────────────── + // Typed rather than clicked, for the panels that live on a screen with no pointer near them. + // Everything here is also reachable by mouse or Alt-key; this is a third route to the same + // controls, never the only route to any of them. + function runCommand(raw) { + const parts = raw.slice(1).trim().split(/\s+/); + const cmd = (parts.shift() || '').toLowerCase(); + const arg = parts.join(' '); + const clear = () => { $('input').value = ''; clearDraft(); }; + + // Toggles a checkbox the page owns, if it gave us one. Reports the resulting state rather + // than the requested one, so a command against a control this profile does not have says so. + const flip = (elId, label) => { + const c = elId ? document.getElementById(elId) : null; + if (!c) { noteLine(label + ' is not available on this profile.'); return; } + c.checked = !c.checked; + noteLine(label + (c.checked ? ' on' : ' off') + ' for the next question.'); + }; + + switch (cmd) { + case 'new': clear(); newChat(); $('input').focus(); return; + case 'saved': { clear(); const hb = $('hist-b'); if (hb) hb.click(); return; } + case 'web': clear(); flip(o.webEl, 'Web search'); return; + case 'think': clear(); flip(o.thinkEl, 'Reasoning'); return; + case 'retry': clear(); lastAction('retry'); return; + case 'edit': clear(); lastAction('edit'); return; + + case 'profile': { + clear(); + // Read from the rendered menu rather than a second list in JS — one source of truth for + // which profiles exist, and it cannot drift from what the picker offers. + const opts = Array.from(document.querySelectorAll(`#${P}-menu [data-prof]`)) + .map(b => b.dataset.prof); + if (!arg) { noteLine('Profiles: ' + opts.join(', ')); return; } + const want = opts.find(x => x.toLowerCase() === arg.toLowerCase()) + || opts.find(x => x.toLowerCase().startsWith(arg.toLowerCase())); + if (!want) { noteLine('No profile matching "' + arg + '". Try: ' + opts.join(', ')); return; } + setProfile(want); + noteLine('Profile is now ' + want + '.'); + return; + } + + case 'scope': { + clear(); + const s = (typeof o.scope === 'function' ? o.scope() : (o.scope || '')); + noteLine(s ? ('Scoped to ' + s) : 'Not scoped to anything in particular.'); + return; + } + + case 'help': case '?': { + clear(); + noteLine('/new /saved /retry /edit /profile [name] /scope /web /think /help'); + return; + } + } + + // Unknown commands are not sent to the model. A typo'd command is a typo, and answering it + // as a question wastes 40 seconds to say it does not understand. + noteLine('No such command: /' + cmd + ' — try /help'); + $('input').value = ''; + clearDraft(); + } + // ── Ask / poll ─────────────────────────────────────────────────────── function send() { // Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll @@ -806,6 +869,15 @@ vv_ai_profiles_script(); const q = $('input').value.trim(); if (!q) return; + // Slash commands are handled before anything else, so one is never sent to the model as a + // question. Checked ahead of beforeSend too: a page claiming typed text wants questions, + // not "/new". + // + // The pattern is deliberately strict — a bare word of letters, then whitespace or the end. + // "/mnt/user/appdata is filling up" is a perfectly ordinary question on this machine, and a + // looser "starts with a slash" test swallowed it as a command. + if (/^\/[a-z?]+(\s|$)/i.test(q)) { runCommand(q); return; } + // A page may claim what was typed instead of asking it. The Scheduler does this once, after // an offer is accepted, when the next thing typed is the fix being recorded rather than a // question. Checked here rather than in a wrapper the page calls, because Ask and Ctrl+Enter @@ -1400,6 +1472,9 @@ vv_ai_profiles_script(); e.preventDefault(); inp.value = messages[i].content; inp.setSelectionRange(inp.value.length, inp.value.length); + // Setting .value in script fires no input event, so the draft would still hold + // whatever was there before the recall. + saveDraft(); break; } } @@ -1411,7 +1486,11 @@ vv_ai_profiles_script(); || (histWrap && histWrap.classList.contains('open')); closeMenus(); // Only clears once there is no menu left to close, so one Escape never does two things. - if (!wasOpen && e.target === inp && inp.value !== '') { e.preventDefault(); inp.value = ''; } + // clearDraft too, or Escape would empty the box and the text would reappear on the next + // tab swap — an Escape that does not actually discard anything. + if (!wasOpen && e.target === inp && inp.value !== '') { + e.preventDefault(); inp.value = ''; clearDraft(); + } return; } @@ -1599,6 +1678,7 @@ function vv_ai_chat_keys(): array { return [ ['keys' => ['Ctrl', 'Enter'], 'what' => 'Ask', 'id' => 'send'], ['keys' => ['↑'], 'what' => 'Your last question, to edit', 'id' => 'recall'], + ['keys' => ['/'], 'what' => 'Command — /help lists them', 'id' => 'slash'], ['keys' => ['Esc'], 'what' => 'Close a menu, or clear the box', 'id' => 'esc'], ['keys' => ['Alt', 'N'], 'what' => 'New conversation', 'id' => 'new'], ['keys' => ['Alt', 'E'], 'what' => 'Expand or collapse', 'id' => 'grow'],