/**
* The AI moderator helper's Suggest button and panel (staff with ai_suggest, when switched on in Settings).
*
* Pages add a button with AiSuggest.button(kind, id); clicking it asks the server for a drafted suggestion (a stored
* one when the case hasn't changed) and shows it with the evidence it cites. "Use this" only fills in the page's own
* dialog (the reject/act dialog, the flag review, or the account page's note, mute, ban or strike form): staff still
* read it and submit it themselves. Nothing is ever applied from here, and nothing reaches players unless staff send it.
*/
(function () {
'use strict';
var ACTIONS = {
dismiss: 'Dismiss', note: 'Add a note', warn: 'Warn', strike: 'Give a strike', mute: 'Mute', ban: 'Ban',
approve: 'Approve', reject_name: 'Reject the name'
};
var CONFIDENCE = { low: 'secondary', medium: 'warning', high: 'success' };
// Actions done from the account page
var ACCOUNT_ACTIONS = ['note', 'warn', 'strike', 'mute', 'ban'];
var PREFILL_KEY = 'aiPrefill';
function can() { return window.DASH && DASH.can && DASH.can('ai_suggest'); }
function actionText(s) {
var text = ACTIONS[s.action] || s.action;
if (s.days) text += ' for ' + s.days + ' day' + (s.days === 1 ? '' : 's');
if (s.strike && s.action !== 'strike') text += ', with a strike';
return text;
}
function clip(text, n) { text = String(text || ''); return text.length > n ? text.slice(0, n - 1) + '…' : text; }
// ---- Status: hide the buttons while the helper is off ----
var statusPromise = null;
function status() {
if (!statusPromise) statusPromise = api.get('/api/ai/status').then(function (d) { return d.success ? d : null; }).catch(function () { return null; });
return statusPromise;
}
function budgetText(b) {
if (!b) return '';
return b.remaining_today + ' of ' + b.per_day + ' suggestions left today (' + b.per_minute + ' a minute)';
}
// ---- The panel ----
var el = null, modal = null, current = null;
function build() {
el = document.createElement('div');
el.className = 'modal fade';
el.tabIndex = -1;
el.setAttribute('aria-labelledby', 'aiSuggestTitle');
el.innerHTML =
'
';
document.body.appendChild(el);
modal = new bootstrap.Modal(el);
el.querySelector('#aiSuggestAgain').addEventListener('click', function () { if (current) ask(current.kind, current.id, current.button, true); });
el.querySelector('#aiSuggestUse').addEventListener('click', function () {
if (!current || !current.data) return;
var c = current;
modal.hide();
use(c);
});
el.querySelector('#aiSuggestAccount').addEventListener('click', function () {
if (current && current.data) fillOnAccountPage(current.data);
});
}
function setButtons(data) {
var s = data && data.suggestion;
el.querySelector('#aiSuggestAgain').classList.toggle('d-none', !data);
el.querySelector('#aiSuggestUse').classList.toggle('d-none', !s || !usable(current.kind, s));
el.querySelector('#aiSuggestAccount').classList.toggle('d-none', !s || !data.account_id || ACCOUNT_ACTIONS.indexOf(s.action) === -1 || !DASH.can('accounts_view'));
}
function showMessage(html, type) {
el.querySelector('#aiSuggestBody').innerHTML = '' + html + '
';
}
// One line of the case file for a ref the answer cites
function evidenceLine(data, ref) {
var c = data['case'] || {};
if (ref === 'ITEM') return 'The ' + (c.kind || 'item').replace(/_/g, ' ') + ' itself';
var lists = { M: c.chat || [], S: (c.account_history || {}).strikes || [], H: (c.account_history || {}).earlier_moderation || [] };
var found = (lists[ref.charAt(0)] || []).filter(function (x) { return x.ref === ref; })[0];
if (!found) return '';
if (ref.charAt(0) === 'M') return found.when + ' · ' + found.channel + ' · ' + found.from + (found.to ? ' → ' + found.to : '') + ': ' + found.text + (found.stopped_by_filter ? ' (stopped by the filter)' : '');
if (ref.charAt(0) === 'S') return found.when + ' · strike for ' + found['for'] + (found.about ? ' (' + found.about + ')' : '') + (found.reason ? ': ' + found.reason : '') + (found.still_counts ? '' : ' (no longer counts)');
return found.when + ' · ' + found.kind + ': ' + found.text;
}
function render(data) {
current.data = data;
var s = data.suggestion;
var meta = (data.cached ? 'Stored suggestion from ' + fmt.unix(data.created_at) + (data.requested_by ? ', asked by ' + data.requested_by : '') + ' (the case hasn\'t changed since; free)'
: 'Just asked') + ' · ' + (data.model || '') + ' · ' + (data.input_tokens + data.output_tokens) + ' tokens';
var evidence = (s.evidence || []).map(function (ref) {
return '' + esc(ref) + '' + esc(evidenceLine(data, ref)) + '';
}).join('');
el.querySelector('#aiSuggestBody').innerHTML =
'Written by an AI model from the case below. It can be wrong or miss context: check the evidence. ' +
'Nothing happens until you do it yourself.
' +
'' + esc(actionText(s)) + '' +
fmt.badge(s.confidence + ' confidence', CONFIDENCE[s.confidence] || 'secondary') + '
' +
(s.player_reason ? 'Reason for the player
' +
'' + esc(s.player_reason) + '
' : '') +
'Why
' + esc(s.staff_explanation) + '
' +
(evidence ? 'Evidence it cites
' : '') +
'' + esc(meta) + '
';
el.querySelector('#aiSuggestBudget').textContent = budgetText(data.budget);
setButtons(data);
}
function ask(kind, id, button, refresh) {
if (!el) build();
current = { kind: kind, id: id, button: button, data: null };
setButtons(null);
el.querySelector('#aiSuggestBudget').textContent = '';
showMessage('Asking the AI helper… this can take a little while.');
modal.show();
var mine = current;
api.post('/api/ai/suggest', { kind: kind, id: String(id), refresh: !!refresh }).then(function (d) {
if (mine !== current) return null;
if (!d.success) { showMessage(esc(d.error || 'Request failed'), 'warning'); el.querySelector('#aiSuggestAgain').classList.add('d-none'); return null; }
if (d.budget) el.querySelector('#aiSuggestBudget').textContent = budgetText(d.budget);
if (d.suggestion) return d;
return Live.waitForResult(d.requestId).then(function (r) {
if (!r.success) {
showMessage(esc(r.message || 'The helper failed'), 'warning');
el.querySelector('#aiSuggestAgain').classList.remove('d-none');
return null;
}
return r.data;
});
}).then(function (data) {
if (data && mine === current) render(data);
}).catch(function () { if (mine === current) showMessage('Could not reach the dashboard.', 'danger'); });
}
// ---- "Use this": fill in the page's own dialog; staff submit it ----
function row(c) { return c.button ? c.button.closest('tr') || document : document; }
function highlight(button) {
if (!button) return;
button.classList.add('ai-suggested');
button.scrollIntoView({ block: 'center' });
button.focus();
setTimeout(function () { button.classList.remove('ai-suggested'); }, 4000);
}
function usable(kind, s) {
if (kind === 'name' || kind === 'pet_name' || kind === 'player_report') return true;
if (kind === 'economy_flag') return !!document.querySelector('[data-review]') && DASH.can('reports_review_flags');
return false; // chat: done on the account page
}
function reportReason(s) {
return clip(actionText(s) + (s.player_reason ? ': ' + s.player_reason : ''), 300);
}
function use(c) {
var s = c.data.suggestion, scope = row(c), target;
if (c.kind === 'name' || c.kind === 'pet_name') {
if (s.action === 'approve') {
target = scope.querySelector('[data-action="approve"]');
highlight(target);
toast('Click Approve to apply it', 'info');
return;
}
target = scope.querySelector('[data-action="reject"]');
if (!target) return toast('The name is no longer waiting', 'warning');
window.Decide.next = { reason: s.player_reason, strike: s.strike };
target.click();
} else if (c.kind === 'player_report') {
target = s.action === 'dismiss' ? scope.querySelector('[data-dismiss-report]') : scope.querySelector('[data-act]');
if (!target) return toast('The report is no longer open', 'warning');
window.Decide.next = s.action === 'dismiss' ? { reason: clip(s.staff_explanation, 300) } : { reason: reportReason(s), strike: s.strike || s.action === 'strike' };
target.click();
if (s.action !== 'dismiss' && s.action !== 'note' && c.data.account_id) {
toast('Closing the report doesn\'t ' + actionText(s).toLowerCase() + ': use "Fill in on the account page" for that', 'info');
}
} else if (c.kind === 'economy_flag') {
target = scope.querySelector('[data-review]');
if (!target) return;
target.click();
var note = document.getElementById('flagNote');
if (note) note.value = clip(actionText(s) + ': ' + s.staff_explanation, 1000);
highlight(document.querySelector('[data-flag-status="' + (s.action === 'dismiss' ? 'dismissed' : 'actioned') + '"]'));
}
}
function fillOnAccountPage(data) {
var s = data.suggestion;
var prefill = { account: String(data.account_id), action: s.action, days: s.days, strike: s.strike,
reason: s.action === 'note' ? clip(s.staff_explanation, 300) : s.player_reason, at: Date.now() };
try { sessionStorage.setItem(PREFILL_KEY, JSON.stringify(prefill)); } catch (e) { return toast('Could not pass the suggestion on (browser storage is off)', 'warning'); }
window.location.href = '/accounts/' + encodeURIComponent(data.account_id);
}
// On an account page: fill in the form a suggestion was passed to. Nothing is submitted.
function applyAccountPrefill() {
var match = location.pathname.match(/^\/accounts\/(\d+)/);
if (!match) return;
var prefill = null;
try {
prefill = JSON.parse(sessionStorage.getItem(PREFILL_KEY) || 'null');
sessionStorage.removeItem(PREFILL_KEY);
} catch (e) { return; }
if (!prefill || prefill.account !== match[1] || Date.now() - prefill.at > 5 * 60 * 1000) return;
function pick(select, days) {
if (!select) return;
var value = String(days);
if (!Array.prototype.some.call(select.options, function (o) { return o.value === value; })) {
var option = document.createElement('option');
option.value = value;
option.textContent = days + ' days (suggested)';
select.appendChild(option);
}
select.value = value;
}
function showModal(id) {
var m = document.getElementById(id);
if (m) bootstrap.Modal.getOrCreateInstance(m).show();
return !!m;
}
var done = false;
if (prefill.action === 'note' || prefill.action === 'warn') {
var kind = document.getElementById('noteKind'), text = document.getElementById('noteText');
if (kind && text) {
kind.value = prefill.action === 'warn' ? 'warning' : 'note';
kind.dispatchEvent(new Event('change', { bubbles: true }));
text.value = prefill.reason || '';
text.scrollIntoView({ block: 'center' });
text.focus();
done = true;
}
} else if (prefill.action === 'mute') {
pick(document.getElementById('muteDuration'), prefill.days);
var muteReason = document.getElementById('muteReason');
if (muteReason) muteReason.value = prefill.reason || '';
done = showModal('muteModal');
} else if (prefill.action === 'ban') {
pick(document.getElementById('banDays'), prefill.days);
var banReason = document.getElementById('banReason');
if (banReason) banReason.value = prefill.reason || '';
done = showModal('banModal');
} else if (prefill.action === 'strike') {
var give = document.getElementById('giveStrike');
if (give) { window.Decide.next = { reason: prefill.reason }; give.click(); done = true; }
}
if (!done) return toast('This account page has no form for that, or you can\'t use it', 'warning');
toast('Filled in from the AI suggestion: check it, then apply it yourself' +
(prefill.strike && prefill.action !== 'strike' ? '. It also suggests a strike (Strikes card)' : ''), 'info');
}
// ---- Chat log: a button for the character typed in the filter ----
function addPlayerChatButton() {
var input = document.getElementById('chatCharacter');
if (!input || document.getElementById('aiPlayerChat')) return;
var button = document.createElement('button');
button.type = 'button';
button.id = 'aiPlayerChat';
// Beside the label rather than under the input, so the filter row's inputs stay lined up
button.className = 'btn btn-link btn-sm p-0 float-end lh-sm align-baseline ai-suggest-btn';
button.textContent = 'Suggest for their recent chat';
button.addEventListener('click', function () {
if (!input.value.trim()) { input.focus(); return toast('Type a character first', 'info'); }
ask('player_chat', input.value.trim(), null, false);
});
input.parentNode.insertBefore(button, input);
}
// ---- Wiring ----
var style = document.createElement('style');
style.textContent = 'body.ai-helper-off .ai-suggest-btn { display: none !important; } .ai-suggested { box-shadow: 0 0 0 .25rem rgba(var(--bs-info-rgb), .6) !important; }';
document.head.appendChild(style);
// Suggest buttons stay hidden until we know the helper is on, so pages don't show buttons that can't work
if (can()) document.body.classList.add('ai-helper-off');
window.AiSuggest = {
// HTML for a Suggest button (empty without ai_suggest)
button: function (kind, id, extraClass) {
if (!can() || !id || id === '0') return '';
return '';
},
ask: ask
};
document.addEventListener('click', function (e) {
var button = e.target.closest('[data-ai-suggest]');
if (!button) return;
e.preventDefault();
ask(button.getAttribute('data-ai-suggest'), button.getAttribute('data-ai-id'), button, false);
});
function start() {
applyAccountPrefill();
if (!can()) return;
addPlayerChatButton();
status().then(function (d) {
if (d && d.enabled) document.body.classList.remove('ai-helper-off');
var budget = document.getElementById('aiBudget');
if (budget && d && d.enabled) {
budget.textContent = d.problem ? 'AI helper: ' + d.problem : 'AI helper: ' + budgetText(d.budget);
budget.classList.remove('d-none');
}
});
}
// After the page's own scripts have set up their dialogs
if (document.readyState === 'complete') setTimeout(start, 0);
else window.addEventListener('load', function () { setTimeout(start, 0); });
})();