mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-09 03:08:56 +02:00
733 lines
34 KiB
JavaScript
733 lines
34 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
const state = { config: null, saved: null, busy: false, testing: false, revision: 0, openRuleId: null, addDraft: null };
|
||
const ruleViews = new Map();
|
||
const el = (id) => document.getElementById('tool-guard-' + id);
|
||
const canRead = () => typeof hasPermission !== 'function' || hasPermission('config:read');
|
||
const canWrite = () => typeof hasPermission !== 'function' || hasPermission('config:write');
|
||
const copy = (value) => JSON.parse(JSON.stringify(value));
|
||
|
||
function tr(key, params) {
|
||
const fullKey = 'toolGuard.' + key;
|
||
const value = typeof window.t === 'function' ? window.t(fullKey, params) : fullKey;
|
||
return String(value).replace(/\{\{(\w+)\}\}/g, (match, name) => params && params[name] !== undefined ? String(params[name]) : match);
|
||
}
|
||
|
||
function dirty() {
|
||
return state.config && JSON.stringify(state.config) !== JSON.stringify(state.saved);
|
||
}
|
||
|
||
function feedback(message, error) {
|
||
const target = el('feedback');
|
||
if (!target) return;
|
||
target.textContent = message || '';
|
||
target.hidden = !message;
|
||
target.classList.toggle('is-error', !!error);
|
||
}
|
||
|
||
function updateControls() {
|
||
const writable = canWrite() && !!state.config && !state.busy;
|
||
const readable = canRead() && !!state.config && !state.busy;
|
||
const isDirty = !!dirty();
|
||
if (el('enabled')) el('enabled').disabled = !writable;
|
||
if (el('add')) el('add').disabled = !writable || state.config.rules.length >= 100;
|
||
if (el('save')) el('save').disabled = !writable || !isDirty;
|
||
if (el('reset')) el('reset').disabled = !writable || !isDirty;
|
||
if (el('test')) el('test').disabled = !canRead() || !state.config || state.busy || state.testing;
|
||
if (el('open-test')) el('open-test').disabled = !readable;
|
||
if (el('save-state')) {
|
||
el('save-state').textContent = state.busy ? tr('loading') : state.config ? tr(isDirty ? 'unsaved' : 'savedState') : '';
|
||
el('save-state').classList.toggle('is-dirty', isDirty);
|
||
}
|
||
if (el('protection-status') && state.config) {
|
||
el('protection-status').textContent = tr(state.config.enabled ? 'protectionOn' : 'protectionOff');
|
||
el('protection-status').classList.toggle('is-off', !state.config.enabled);
|
||
}
|
||
if (el('rule-count') && state.config) el('rule-count').textContent = tr('ruleCount', {
|
||
enabled: state.config.rules.filter((rule) => rule.enabled).length,
|
||
total: state.config.rules.length
|
||
});
|
||
ruleViews.forEach((view) => {
|
||
[view.checkbox, view.remove, ...Object.values(view.fields)].forEach((input) => { input.disabled = !writable; });
|
||
// Reading and collapsing an editor never requires write permission.
|
||
view.summary.disabled = false;
|
||
view.close.disabled = false;
|
||
view.validate.disabled = !readable;
|
||
updateLocalTestControls(view.tester);
|
||
updateRuleView(view);
|
||
});
|
||
if (state.addDraft) {
|
||
const draft = state.addDraft;
|
||
Object.values(draft.fields).forEach((input) => { input.disabled = !writable; });
|
||
el('add-confirm').disabled = !writable || !canRead() || draft.adding;
|
||
el('add-confirm').textContent = tr(draft.adding ? 'addingRule' : 'addToList');
|
||
updateLocalTestControls(draft.tester);
|
||
}
|
||
}
|
||
|
||
function invalidateTest() {
|
||
state.revision += 1;
|
||
if (el('test-result')) {
|
||
el('test-result').hidden = true;
|
||
el('test-result').replaceChildren();
|
||
}
|
||
}
|
||
|
||
function openTest(ruleId) {
|
||
if (!canRead() || !state.config || state.busy) return;
|
||
if (ruleId !== undefined && ruleId !== null) {
|
||
const view = ruleViews.get(ruleId);
|
||
if (!view) return;
|
||
openRule(ruleId);
|
||
view.tester.root.hidden = false;
|
||
view.validate.setAttribute('aria-expanded', 'true');
|
||
updateRuleView(view);
|
||
view.tester.args.focus();
|
||
return;
|
||
}
|
||
const panel = el('test-panel');
|
||
if (panel) {
|
||
panel.open = true;
|
||
panel.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||
}
|
||
if (el('test-arguments')) el('test-arguments').focus({ preventScroll: true });
|
||
}
|
||
|
||
function changed(ruleId) {
|
||
invalidateTest();
|
||
const view = ruleViews.get(ruleId);
|
||
if (view) invalidateLocalTest(view.tester);
|
||
feedback('');
|
||
ruleViews.forEach((view) => Object.values(view.fields).forEach((input) => input.removeAttribute('aria-invalid')));
|
||
updateControls();
|
||
}
|
||
|
||
function textElement(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
node.textContent = text == null ? '' : String(text);
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
function ruleField(rule, index, key, label, multiline, maxLength, fields, onChange) {
|
||
const field = document.createElement('div');
|
||
field.className = 'tool-guard-field tool-guard-field--' + key;
|
||
const input = document.createElement(multiline ? 'textarea' : 'input');
|
||
input.id = 'tool-guard-rule-' + index + '-' + key;
|
||
input.value = rule[key] || '';
|
||
input.maxLength = maxLength;
|
||
input.spellcheck = false;
|
||
if (multiline) input.rows = 3;
|
||
else input.type = 'text';
|
||
if (key === 'pattern') input.className = 'tool-guard-pattern';
|
||
const labelNode = textElement('label', tr(label));
|
||
labelNode.htmlFor = input.id;
|
||
input.addEventListener('input', () => {
|
||
if (!canWrite() || state.busy) return;
|
||
rule[key] = input.value;
|
||
if (onChange) onChange();
|
||
else changed(rule.id);
|
||
});
|
||
fields[key] = input;
|
||
field.append(labelNode, input);
|
||
if (key === 'message') {
|
||
const hint = textElement('p', tr('messageHint'), 'tool-guard-hint tool-guard-field-hint');
|
||
hint.id = input.id + '-hint';
|
||
input.setAttribute('aria-describedby', hint.id);
|
||
field.append(hint);
|
||
}
|
||
return field;
|
||
}
|
||
|
||
function updateRuleView(view) {
|
||
const { rule, card, summary, editor, title, preview, badge, status, checkbox } = view;
|
||
const expanded = state.openRuleId === rule.id;
|
||
const name = rule.name.trim() || tr('unnamedRule');
|
||
title.textContent = name;
|
||
preview.textContent = rule.message.trim() || tr('defaultPreview');
|
||
summary.setAttribute('aria-expanded', String(expanded));
|
||
summary.setAttribute('aria-label', tr(expanded ? 'collapseRule' : 'expandRule') + ': ' + name);
|
||
editor.hidden = !expanded;
|
||
card.classList.toggle('is-expanded', expanded);
|
||
card.classList.toggle('is-disabled', !rule.enabled);
|
||
checkbox.checked = !!rule.enabled;
|
||
checkbox.setAttribute('aria-label', tr('ruleEnabled') + ': ' + name);
|
||
status.textContent = tr(rule.enabled ? 'ruleOn' : 'ruleOff');
|
||
const savedRule = state.saved && state.saved.rules.find((saved) => saved.id === rule.id);
|
||
const modified = !!savedRule && JSON.stringify(savedRule) !== JSON.stringify(rule);
|
||
badge.hidden = !!savedRule && !modified;
|
||
badge.textContent = tr(savedRule ? 'ruleModified' : 'ruleNew');
|
||
view.validate.textContent = tr(view.tester.root.hidden ? 'validateRule' : 'closeRuleTest');
|
||
}
|
||
|
||
function openRule(id) {
|
||
state.openRuleId = id;
|
||
ruleViews.forEach(updateRuleView);
|
||
}
|
||
|
||
function renderRules() {
|
||
const target = el('rules');
|
||
if (!target || !state.config) return;
|
||
ruleViews.forEach((view) => disposeLocalTest(view.tester));
|
||
target.replaceChildren();
|
||
ruleViews.clear();
|
||
if (!state.config.rules.some((rule) => rule.id === state.openRuleId)) state.openRuleId = null;
|
||
if (!state.config.rules.length) {
|
||
target.append(textElement('p', tr('emptyRules'), 'tool-guard-empty'));
|
||
}
|
||
state.config.rules.forEach((rule, index) => {
|
||
const card = document.createElement('article');
|
||
card.className = 'tool-guard-rule';
|
||
card.id = 'tool-guard-rule-' + index;
|
||
const header = document.createElement('div');
|
||
header.className = 'tool-guard-rule-header';
|
||
const summary = document.createElement('button');
|
||
summary.type = 'button';
|
||
summary.id = card.id + '-summary';
|
||
summary.className = 'tool-guard-rule-summary';
|
||
summary.setAttribute('aria-controls', card.id + '-editor');
|
||
summary.addEventListener('click', () => openRule(state.openRuleId === rule.id ? null : rule.id));
|
||
const overview = document.createElement('span');
|
||
overview.className = 'tool-guard-rule-overview';
|
||
const title = textElement('span', '', 'tool-guard-rule-name');
|
||
title.id = card.id + '-title';
|
||
const preview = textElement('span', '', 'tool-guard-rule-preview');
|
||
preview.id = card.id + '-preview';
|
||
overview.append(title, preview);
|
||
const badge = textElement('span', '', 'tool-guard-rule-badge');
|
||
badge.id = card.id + '-badge';
|
||
const chevron = textElement('span', '›', 'tool-guard-chevron');
|
||
chevron.setAttribute('aria-hidden', 'true');
|
||
summary.append(textElement('span', String(index + 1).padStart(2, '0'), 'tool-guard-rule-number'), overview, badge, chevron);
|
||
const actions = document.createElement('div');
|
||
actions.className = 'tool-guard-rule-actions';
|
||
const toggle = document.createElement('label');
|
||
toggle.className = 'tool-guard-toggle tool-guard-switch';
|
||
const checkbox = document.createElement('input');
|
||
checkbox.id = card.id + '-enabled';
|
||
checkbox.type = 'checkbox';
|
||
checkbox.className = 'theme-checkbox';
|
||
checkbox.checked = !!rule.enabled;
|
||
checkbox.addEventListener('change', () => {
|
||
if (!canWrite() || state.busy) return;
|
||
rule.enabled = checkbox.checked;
|
||
changed(rule.id);
|
||
});
|
||
const status = textElement('span', '');
|
||
toggle.append(checkbox, status);
|
||
actions.append(toggle);
|
||
const editor = document.createElement('div');
|
||
editor.className = 'tool-guard-rule-editor';
|
||
editor.id = card.id + '-editor';
|
||
editor.setAttribute('aria-labelledby', title.id);
|
||
const fields = {};
|
||
const grid = document.createElement('div');
|
||
grid.className = 'tool-guard-editor-grid';
|
||
grid.append(ruleField(rule, index, 'name', 'ruleName', false, 200, fields),
|
||
ruleField(rule, index, 'pattern', 'pattern', true, 4096, fields),
|
||
ruleField(rule, index, 'message', 'message', true, 4096, fields));
|
||
const footer = document.createElement('div');
|
||
footer.className = 'tool-guard-editor-footer';
|
||
const remove = textElement('button', tr('deleteRule'), 'btn-secondary tool-guard-delete');
|
||
remove.id = card.id + '-delete';
|
||
remove.type = 'button';
|
||
remove.addEventListener('click', () => {
|
||
if (!canWrite() || state.busy) return;
|
||
if (state.openRuleId === rule.id) state.openRuleId = null;
|
||
state.config.rules.splice(index, 1);
|
||
renderRules();
|
||
changed();
|
||
const next = state.config.rules[Math.min(index, state.config.rules.length - 1)];
|
||
const focusTarget = next ? ruleViews.get(next.id).summary : el('add');
|
||
if (focusTarget) focusTarget.focus();
|
||
});
|
||
const close = textElement('button', tr('closeEditor'), 'btn-secondary tool-guard-close');
|
||
close.id = card.id + '-close';
|
||
close.type = 'button';
|
||
close.addEventListener('click', () => { openRule(null); summary.focus(); });
|
||
const validate = textElement('button', tr('validateRule'), 'btn-secondary tool-guard-rule-validate');
|
||
validate.id = card.id + '-validate';
|
||
validate.type = 'button';
|
||
const tester = createLocalTest(rule, 'rule-' + index + '-test', fields);
|
||
tester.root.id = card.id + '-test-panel';
|
||
tester.root.classList.toggle('tool-guard-inline-test', true);
|
||
tester.root.hidden = true;
|
||
validate.setAttribute('aria-controls', tester.root.id);
|
||
validate.setAttribute('aria-expanded', 'false');
|
||
validate.addEventListener('click', () => {
|
||
if (!tester.root.hidden) {
|
||
tester.root.hidden = true;
|
||
validate.setAttribute('aria-expanded', 'false');
|
||
updateRuleView(ruleViews.get(rule.id));
|
||
} else openTest(rule.id);
|
||
});
|
||
const editorActions = document.createElement('div');
|
||
editorActions.className = 'tool-guard-editor-actions';
|
||
editorActions.append(validate, close);
|
||
footer.append(remove, editorActions);
|
||
editor.append(grid, footer, tester.root);
|
||
header.append(summary, actions);
|
||
card.append(header, editor);
|
||
ruleViews.set(rule.id, { rule, card, summary, editor, title, preview, badge, status, checkbox, remove, close, validate, fields, tester });
|
||
target.append(card);
|
||
});
|
||
updateControls();
|
||
}
|
||
|
||
function render() {
|
||
if (state.config && el('enabled')) el('enabled').checked = !!state.config.enabled;
|
||
renderRules();
|
||
updateControls();
|
||
}
|
||
|
||
async function request(url, method, body) {
|
||
const options = { method: method || 'GET' };
|
||
if (body !== undefined) {
|
||
options.headers = { 'Content-Type': 'application/json' };
|
||
options.body = JSON.stringify(body);
|
||
}
|
||
const response = await apiFetch(url, options);
|
||
const result = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(result.error || tr('requestFailed'));
|
||
return result;
|
||
}
|
||
|
||
function normalizeConfig(config) {
|
||
if (!config || typeof config.enabled !== 'boolean' || (config.rules != null && !Array.isArray(config.rules))) {
|
||
throw new Error(tr('invalidResponse'));
|
||
}
|
||
return { enabled: config.enabled, rules: (config.rules || []).map((rule) => ({
|
||
id: String(rule.id || ''), name: String(rule.name || ''), enabled: !!rule.enabled,
|
||
pattern: String(rule.pattern || ''), message: String(rule.message || '')
|
||
})) };
|
||
}
|
||
|
||
async function loadConfig() {
|
||
if (!el('rules') || !canRead() || state.busy) return;
|
||
// Retain the user's draft when navigating away and back.
|
||
if (dirty() || state.addDraft) { updateControls(); return; }
|
||
state.busy = true;
|
||
feedback('');
|
||
updateControls();
|
||
try {
|
||
const config = normalizeConfig(await request('/api/tool-guard'));
|
||
state.saved = copy(config);
|
||
state.config = config;
|
||
invalidateTest();
|
||
render();
|
||
} catch (error) {
|
||
feedback(tr('loadFailed') + ': ' + error.message, true);
|
||
} finally {
|
||
state.busy = false;
|
||
updateControls();
|
||
}
|
||
}
|
||
|
||
function configForRequest(selected = null) {
|
||
if (!state.config) throw new Error(tr('loadFirst'));
|
||
const config = selected ? { enabled: true, rules: [{ ...copy(selected), enabled: true }] } : copy(state.config);
|
||
const utf8 = new TextEncoder();
|
||
for (const [index, rule] of config.rules.entries()) {
|
||
for (const key of ['name', 'pattern', 'message']) {
|
||
let message;
|
||
if (key !== 'message' && !rule[key].trim()) message = tr('requiredFields');
|
||
else if (utf8.encode(rule[key]).length > (key === 'name' ? 200 : 4096)) message = tr('fieldTooLong');
|
||
if (message) {
|
||
const error = new Error(message);
|
||
error.ruleIndex = index;
|
||
error.ruleId = rule.id;
|
||
error.ruleField = key;
|
||
throw error;
|
||
}
|
||
}
|
||
}
|
||
// RE2 validation belongs to the backend; JavaScript RegExp has different semantics.
|
||
return config;
|
||
}
|
||
|
||
function revealValidationError(error, requestRuleIds) {
|
||
let index = error.ruleIndex;
|
||
let field = error.ruleField;
|
||
if (!Number.isInteger(index)) {
|
||
// The server reports the 1-based rule number for RE2 validation errors.
|
||
const match = /tool guard rule (\d+)(?: \([^\n]*\))?: (.*)/.exec(error.message);
|
||
if (!match) return;
|
||
index = Number(match[1]) - 1;
|
||
field = /^name\b/.test(match[2]) ? 'name' : /^message\b/.test(match[2]) ? 'message' : 'pattern';
|
||
}
|
||
const ruleId = error.ruleId || (requestRuleIds && requestRuleIds[index]);
|
||
const rule = state.config && (ruleId ? state.config.rules.find((item) => item.id === ruleId) : state.config.rules[index]);
|
||
const view = rule && ruleViews.get(rule.id);
|
||
if (!view) return;
|
||
openRule(rule.id);
|
||
const input = view.fields[field];
|
||
if (input) {
|
||
input.setAttribute('aria-invalid', 'true');
|
||
if (input.disabled) view.summary.focus();
|
||
else input.focus();
|
||
}
|
||
}
|
||
|
||
async function saveConfig() {
|
||
if (!canWrite() || state.busy || !dirty()) return;
|
||
let failure;
|
||
try {
|
||
const config = configForRequest();
|
||
state.busy = true;
|
||
feedback('');
|
||
updateControls();
|
||
const saved = normalizeConfig(await request('/api/tool-guard', 'PUT', config));
|
||
state.saved = copy(saved);
|
||
state.config = saved;
|
||
invalidateTest();
|
||
render();
|
||
feedback(tr('saveSuccess'));
|
||
} catch (error) {
|
||
failure = error;
|
||
feedback(tr('saveFailed') + ': ' + error.message, true);
|
||
} finally {
|
||
state.busy = false;
|
||
updateControls();
|
||
if (failure) revealValidationError(failure);
|
||
}
|
||
}
|
||
|
||
function addRule() {
|
||
if (!canWrite() || !state.config || state.busy) return;
|
||
const dialog = el('add-dialog');
|
||
if (!dialog || state.addDraft) return;
|
||
if (state.config.rules.length >= 100) { feedback(tr('tooManyRules'), true); return; }
|
||
const id = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||
? crypto.randomUUID() : 'rule-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||
const rule = { id, name: '', enabled: true, pattern: '', message: tr('defaultMessage') };
|
||
const draft = { rule, fields: {}, adding: false, revision: 0, returnFocus: document.activeElement || el('add') };
|
||
state.addDraft = draft;
|
||
const onChange = () => {
|
||
draft.revision += 1;
|
||
invalidateLocalTest(draft.tester);
|
||
showLocalFeedback(el('add-feedback'), '');
|
||
Object.values(draft.fields).forEach((input) => input.removeAttribute('aria-invalid'));
|
||
};
|
||
el('add-fields').replaceChildren(
|
||
ruleField(rule, 'draft', 'name', 'ruleName', false, 200, draft.fields, onChange),
|
||
ruleField(rule, 'draft', 'pattern', 'pattern', true, 4096, draft.fields, onChange),
|
||
ruleField(rule, 'draft', 'message', 'message', true, 4096, draft.fields, onChange)
|
||
);
|
||
draft.tester = createLocalTest(rule, 'draft-test', draft.fields);
|
||
el('add-test').replaceChildren(draft.tester.root);
|
||
showLocalFeedback(el('add-feedback'), '');
|
||
if (!dialog.dataset.guardBound) {
|
||
dialog.dataset.guardBound = 'true';
|
||
dialog.addEventListener('cancel', (event) => { event.preventDefault(); closeRuleDialog(); });
|
||
dialog.addEventListener('close', () => { if (!dialog.open) closeRuleDialog(); });
|
||
dialog.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Tab') return;
|
||
const controls = Array.from(dialog.querySelectorAll('button, input, textarea'))
|
||
.filter((input) => !input.disabled && !input.hidden);
|
||
const first = controls[0];
|
||
const last = controls[controls.length - 1];
|
||
if (first && event.shiftKey && document.activeElement === first) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (last && !event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
});
|
||
}
|
||
updateControls();
|
||
dialog.showModal();
|
||
draft.fields.name.focus();
|
||
}
|
||
|
||
function closeRuleDialog() {
|
||
const draft = state.addDraft;
|
||
if (!draft) return;
|
||
state.addDraft = null;
|
||
disposeLocalTest(draft.tester);
|
||
const dialog = el('add-dialog');
|
||
if (dialog.open) dialog.close();
|
||
el('add-fields').replaceChildren();
|
||
el('add-test').replaceChildren();
|
||
showLocalFeedback(el('add-feedback'), '');
|
||
updateControls();
|
||
if (draft.returnFocus) draft.returnFocus.focus();
|
||
}
|
||
|
||
async function commitRule() {
|
||
const draft = state.addDraft;
|
||
if (!draft || !canWrite() || !canRead() || state.busy || draft.adding) return;
|
||
const revision = draft.revision;
|
||
try {
|
||
if (state.config.rules.length >= 100) throw new Error(tr('tooManyRules'));
|
||
const config = configForRequest(draft.rule);
|
||
draft.adding = true;
|
||
showLocalFeedback(el('add-feedback'), '');
|
||
updateControls();
|
||
// Validate with the same RE2 engine as saving, independently of the optional test inputs.
|
||
const result = await request('/api/tool-guard/test', 'POST', { config, toolName: 'rule_validation', arguments: {} });
|
||
if (state.addDraft !== draft || revision !== draft.revision) return;
|
||
validateTestResult(result);
|
||
state.config.rules.push(copy(draft.rule));
|
||
closeRuleDialog();
|
||
state.openRuleId = null;
|
||
renderRules();
|
||
changed();
|
||
feedback(tr('ruleAdded'));
|
||
const view = ruleViews.get(draft.rule.id);
|
||
if (view) view.summary.focus();
|
||
} catch (error) {
|
||
if (state.addDraft !== draft || revision !== draft.revision) return;
|
||
showLocalFeedback(el('add-feedback'), tr('addFailed') + ': ' + error.message);
|
||
revealLocalError(draft.tester, error);
|
||
} finally {
|
||
draft.adding = false;
|
||
updateControls();
|
||
}
|
||
}
|
||
|
||
function resetConfig() {
|
||
if (!canWrite() || state.busy || !state.saved) return;
|
||
closeRuleDialog();
|
||
state.config = copy(state.saved);
|
||
render();
|
||
changed();
|
||
}
|
||
|
||
function changeEnabled(enabled) {
|
||
if (!canWrite() || !state.config || state.busy) return;
|
||
state.config.enabled = !!enabled;
|
||
changed();
|
||
}
|
||
|
||
function renderTestResult(result, enabled, single, target = el('test-result')) {
|
||
if (!target) return;
|
||
target.replaceChildren();
|
||
target.hidden = false;
|
||
target.classList.toggle('is-blocked', !!result.blocked);
|
||
target.classList.toggle('is-single', !!single);
|
||
target.append(textElement('strong', tr(single ? (result.blocked ? 'singleMatched' : 'singleNotMatched') :
|
||
result.blocked ? 'blocked' : enabled ? 'notBlocked' : 'disabledResult')));
|
||
if (single) target.append(textElement('p', tr('singleResultHint'), 'tool-guard-single-result-hint'));
|
||
if (result.blocked && result.match) {
|
||
const fields = [
|
||
['matchedRule', result.match.ruleName || result.match.ruleId],
|
||
['matchedText', result.match.matchedText],
|
||
['matchedMessage', result.match.message]
|
||
];
|
||
fields.forEach(([key, value]) => {
|
||
target.append(textElement('p', tr(key), 'tool-guard-result-label'));
|
||
target.append(textElement('pre', value, 'tool-guard-result-value'));
|
||
});
|
||
}
|
||
}
|
||
|
||
function showLocalFeedback(target, message) {
|
||
target.textContent = message || '';
|
||
target.hidden = !message;
|
||
target.classList.toggle('is-error', !!message);
|
||
}
|
||
|
||
function reserveDialogTestSpace(tester) {
|
||
if (!state.addDraft || state.addDraft.tester !== tester) return;
|
||
const body = el('add-body');
|
||
const bottomSlack = Math.max(0, body.scrollHeight - body.clientHeight - body.scrollTop);
|
||
// Keep only the space needed to avoid clamping the current scroll position.
|
||
// Do not retain an entire long result or accumulate its height across tests.
|
||
const height = Math.max(180, Math.min(body.clientHeight, tester.output.getBoundingClientRect().height - bottomSlack));
|
||
tester.output.style.minHeight = Math.ceil(height) + 'px';
|
||
}
|
||
|
||
function invalidateLocalTest(tester, statusKey = 'testChanged') {
|
||
if (!tester) return;
|
||
reserveDialogTestSpace(tester);
|
||
tester.revision += 1;
|
||
tester.result.hidden = true;
|
||
tester.result.replaceChildren();
|
||
showLocalFeedback(tester.feedback, '');
|
||
tester.placeholder.textContent = tr(statusKey);
|
||
tester.placeholder.hidden = false;
|
||
}
|
||
|
||
function disposeLocalTest(tester) {
|
||
if (!tester) return;
|
||
tester.disposed = true;
|
||
invalidateLocalTest(tester);
|
||
}
|
||
|
||
function updateLocalTestControls(tester) {
|
||
if (!tester) return;
|
||
tester.run.disabled = !canRead() || state.busy || tester.testing || tester.disposed;
|
||
tester.run.textContent = tr(tester.testing ? 'testing' : 'test');
|
||
tester.output.setAttribute('aria-busy', String(tester.testing));
|
||
tester.tool.disabled = !canRead() || state.busy;
|
||
tester.args.disabled = !canRead() || state.busy;
|
||
}
|
||
|
||
function createLocalTest(rule, prefix, fields) {
|
||
const root = document.createElement('section');
|
||
root.className = 'tool-guard-local-test';
|
||
const title = textElement('h4', tr('singleTestTitle'));
|
||
title.id = 'tool-guard-' + prefix + '-title';
|
||
root.setAttribute('aria-labelledby', title.id);
|
||
root.append(title, textElement('p', tr('singleTestHint'), 'tool-guard-hint'));
|
||
const tester = { rule, fields, root, revision: 0, testing: false, disposed: false };
|
||
for (const [key, suffix, label, value] of [
|
||
['tool', 'tool', 'testTool', 'http_request'],
|
||
['args', 'arguments', 'testArguments', '{"url": "https://example.gov.cn"}']
|
||
]) {
|
||
const field = document.createElement('div');
|
||
field.className = 'tool-guard-field';
|
||
const input = document.createElement(key === 'tool' ? 'input' : 'textarea');
|
||
input.id = 'tool-guard-' + prefix + '-' + suffix;
|
||
input.value = value;
|
||
input.spellcheck = false;
|
||
if (key === 'tool') { input.type = 'text'; input.maxLength = 512; input.autocomplete = 'off'; }
|
||
else { input.rows = 4; input.className = 'tool-guard-test-arguments'; }
|
||
const labelNode = textElement('label', tr(label));
|
||
labelNode.htmlFor = input.id;
|
||
input.addEventListener('input', () => {
|
||
input.removeAttribute('aria-invalid');
|
||
invalidateLocalTest(tester);
|
||
});
|
||
tester[key] = input;
|
||
field.append(labelNode, input);
|
||
root.append(field);
|
||
}
|
||
tester.run = textElement('button', tr('test'), 'btn-secondary tool-guard-test-run');
|
||
tester.run.id = 'tool-guard-' + prefix + '-run';
|
||
tester.run.type = 'button';
|
||
tester.run.addEventListener('click', () => testLocalRule(tester));
|
||
tester.feedback = textElement('div', '', 'tool-guard-feedback');
|
||
tester.feedback.id = 'tool-guard-' + prefix + '-feedback';
|
||
tester.feedback.hidden = true;
|
||
tester.feedback.setAttribute('role', 'status');
|
||
tester.result = textElement('div', '', 'tool-guard-test-result');
|
||
tester.result.id = 'tool-guard-' + prefix + '-result';
|
||
tester.result.hidden = true;
|
||
tester.result.setAttribute('role', 'status');
|
||
tester.output = textElement('div', '', 'tool-guard-local-output');
|
||
tester.output.id = 'tool-guard-' + prefix + '-output';
|
||
if (prefix === 'draft-test') {
|
||
tester.output.setAttribute('role', 'region');
|
||
tester.output.setAttribute('aria-label', tr('testResultLabel'));
|
||
}
|
||
tester.placeholder = textElement('p', tr('testReadyHint'), 'tool-guard-test-placeholder');
|
||
tester.placeholder.id = 'tool-guard-' + prefix + '-status';
|
||
tester.placeholder.setAttribute('role', 'status');
|
||
tester.output.append(tester.placeholder, tester.feedback, tester.result);
|
||
root.append(tester.run, tester.output);
|
||
updateLocalTestControls(tester);
|
||
return tester;
|
||
}
|
||
|
||
function revealLocalError(tester, error) {
|
||
const match = /tool guard rule \d+(?: \([^\n]*\))?: (.*)/.exec(error.message);
|
||
const field = error.ruleField || (match ? /^name\b/.test(match[1]) ? 'name' :
|
||
/^message\b/.test(match[1]) ? 'message' : 'pattern' : null);
|
||
const input = error.input || tester.fields[field];
|
||
if (!input) return;
|
||
input.setAttribute('aria-invalid', 'true');
|
||
// Async feedback stays with its rule instead of reopening another editor.
|
||
if ((state.addDraft && state.addDraft.tester === tester) || state.openRuleId === tester.rule.id) {
|
||
if (!input.disabled) input.focus();
|
||
}
|
||
}
|
||
|
||
function validateTestResult(result) {
|
||
if (!result || typeof result.blocked !== 'boolean' || (result.blocked && (!result.match ||
|
||
typeof result.match.matchedText !== 'string' || typeof result.match.message !== 'string'))) {
|
||
throw new Error(tr('invalidTestResponse'));
|
||
}
|
||
}
|
||
|
||
async function testLocalRule(tester) {
|
||
if (!canRead() || state.busy || tester.testing || tester.disposed || !state.config) return;
|
||
invalidateLocalTest(tester, 'testing');
|
||
const revision = tester.revision;
|
||
try {
|
||
const config = configForRequest(tester.rule);
|
||
const toolName = tester.tool.value.trim();
|
||
if (!toolName) {
|
||
const error = new Error(tr('toolRequired'));
|
||
error.input = tester.tool;
|
||
throw error;
|
||
}
|
||
let args;
|
||
try {
|
||
args = JSON.parse(tester.args.value);
|
||
if (!args || Array.isArray(args) || typeof args !== 'object') throw new Error();
|
||
} catch (_) {
|
||
const error = new Error(tr('invalidArguments'));
|
||
error.input = tester.args;
|
||
throw error;
|
||
}
|
||
tester.testing = true;
|
||
updateLocalTestControls(tester);
|
||
const result = await request('/api/tool-guard/test', 'POST', { config, toolName, arguments: args });
|
||
if (tester.disposed || revision !== tester.revision) return;
|
||
validateTestResult(result);
|
||
tester.placeholder.hidden = true;
|
||
renderTestResult(result, true, true, tester.result);
|
||
} catch (error) {
|
||
if (tester.disposed || revision !== tester.revision) return;
|
||
tester.placeholder.hidden = true;
|
||
showLocalFeedback(tester.feedback, tr('testFailed') + ': ' + error.message);
|
||
revealLocalError(tester, error);
|
||
} finally {
|
||
tester.testing = false;
|
||
updateLocalTestControls(tester);
|
||
}
|
||
}
|
||
|
||
async function testConfig() {
|
||
if (!canRead() || state.busy || state.testing || !state.config) return;
|
||
let revision;
|
||
let requestRuleIds;
|
||
try {
|
||
invalidateTest();
|
||
const config = configForRequest();
|
||
requestRuleIds = config.rules.map((rule) => rule.id);
|
||
const toolName = el('test-tool').value.trim();
|
||
if (!toolName) throw new Error(tr('toolRequired'));
|
||
let args;
|
||
try { args = JSON.parse(el('test-arguments').value); }
|
||
catch (_) { throw new Error(tr('invalidArguments')); }
|
||
if (!args || Array.isArray(args) || typeof args !== 'object') throw new Error(tr('invalidArguments'));
|
||
revision = state.revision;
|
||
state.testing = true;
|
||
feedback('');
|
||
updateControls();
|
||
const result = await request('/api/tool-guard/test', 'POST', { config, toolName, arguments: args });
|
||
if (state.revision !== revision) return;
|
||
validateTestResult(result);
|
||
renderTestResult(result, config.enabled, false);
|
||
} catch (error) {
|
||
if (revision === undefined || revision === state.revision) {
|
||
feedback(tr('testFailed') + ': ' + error.message, true);
|
||
revealValidationError(error, requestRuleIds);
|
||
}
|
||
} finally {
|
||
state.testing = false;
|
||
updateControls();
|
||
}
|
||
}
|
||
|
||
window.loadToolGuardConfig = loadConfig;
|
||
window.saveToolGuardConfig = saveConfig;
|
||
window.addToolGuardRule = addRule;
|
||
window.closeToolGuardRuleDialog = closeRuleDialog;
|
||
window.commitToolGuardRule = commitRule;
|
||
window.resetToolGuardConfig = resetConfig;
|
||
window.changeToolGuardEnabled = changeEnabled;
|
||
window.openToolGuardTest = openTest;
|
||
window.testToolGuardConfig = testConfig;
|
||
window.invalidateToolGuardTest = invalidateTest;
|
||
document.addEventListener('languagechange', () => {
|
||
render();
|
||
invalidateTest();
|
||
feedback('');
|
||
});
|
||
})();
|