Skip to content

Instantly share code, notes, and snippets.

@catboxanon
Last active April 12, 2024 21:31
Show Gist options
  • Save catboxanon/9c3003f19bfb3b306d3e47bdd6b68ca7 to your computer and use it in GitHub Desktop.
Save catboxanon/9c3003f19bfb3b306d3e47bdd6b68ca7 to your computer and use it in GitHub Desktop.
Make NAI Prompting Great Again

NAI prompt userscript

This userscript modifies NovelAI's imagegen prompt editor to add A1111-style keyboard shortcuts for attention emphasis. Let's see how long it takes for them to steal this. :^)

As a bonus, this userscript will be maintained to add additional (optional) features. These can be accessed via the keyboard shortcut CTRL+Alt+X.

Prerequisites

You will need a userscript extension.

Installation

Open this URL: https://gist.github.com/catboxanon/9c3003f19bfb3b306d3e47bdd6b68ca7/raw/hdg-nai-a1111-prompt.user.js

If you have an open tab on NovelAI, refresh it.

If you do not recieve an install dialog, you do not have a userscript extension, or it is not functioning properly.

Usage

In the prompt area, use Ctrl+Up Arrow or Ctrl+Down Arrow to increase or decrease attention respectively. This uses NovelAI's own syntax, documentation found here: https://docs.novelai.net/image/strengthening-weakening.html

Extra features can be found via the settings menu, accessible via CTRL+Alt+X.

Changelog

(2024-04-12) 1.2.5

  • Force @violentmonkey/ui to always use latest version

(2024-03-28) 1.2.4

  • Bump @violentmonkey/ui (0.7 -> 0.7.8)

(2023-11-25) 1.2.3

  • Fix save button detection by only checking element nodes

(2023-11-24) 1.2.2

  • Delay auto-save to handle edge case with "Enhance" functionality

(2023-11-24) 1.2.1

  • Fix save button detection on Webkit-based browsers

(2023-11-24) 1.2.0

  • Add wildcard support (CTRL+\)
    • Disabled by default
    • Syntax is <w:term one$term two$term three$> (fill in as many terms as you want)
    • Wildcards should be compatible with the "generate forever" feature
    • Wildcards will NOT work when manually clicking the generate button or using NAI's own shortcut (CTRL+Enter). This is to prevent any issues cropping up by overriding default behavior.
  • Fix auto-saving for batches

(2023-11-24) 1.1.1

  • Add "generate forever" feature, defaults to false
  • Use simpler names in options menu
  • Fix issue where save button is incorrectly detected

(2023-11-24) 1.1.0

  • Add auto-save feature, defaults to false
  • Add settings menu (CTRL+Alt+X)

(2023-11-20) 1.0.1 - Ignore tag suggestion dialog box when using attention edit shortcuts

(2023-11-20) 1.0.0 - Initial version

// ==UserScript==
// @name NAI A1111-style attention editing
// @namespace hdg-nai-a1111-prompt
// @match https://novelai.net/*
// @grant none
// @version 1.2.5
// @author Anonymous
// @description Make NAI Prompting Great Again
// @require https://cdn.jsdelivr.net/combine/npm/@violentmonkey/dom@2,npm/@violentmonkey/ui@latest
// @updateURL https://gist.github.com/catboxanon/9c3003f19bfb3b306d3e47bdd6b68ca7/raw/hdg-nai-a1111-prompt.user.js
// @downloadURL https://gist.github.com/catboxanon/9c3003f19bfb3b306d3e47bdd6b68ca7/raw/hdg-nai-a1111-prompt.user.js
// ==/UserScript==
(async function() {
let opts = {
"keyedit_delimiters": ",\\/!?%^*;{}=`~[]$:<>\r\n\t",
"keyedit_delimiters_whitespace": [
"Tab",
"Carriage Return",
"Line Feed"
],
"keyedit_target": 'textarea[autocomplete="off"]',
"observer_auto_save": false,
"observer_auto_generate": false,
"wildcards": false,
}
const optsNames = {
"observer_auto_save": "Auto save",
"observer_auto_generate": "Generate forever",
"wildcards": "Enable wildcard support (CTRL + \\)"
}
const reExtra = /<(?:[^:^>]+:([^:]+))>/gm;
let savedOpts = localStorage.getItem('hdg-nai-a1111-prompt');
if (savedOpts) {
try {
const parsedOpts = JSON.parse(savedOpts);
for (const key in parsedOpts) {
if (Object.keys(opts).includes(key)) {
opts[key] = parsedOpts[key];
}
}
} catch(err) {
console.error(err);
}
} else {
localStorage.setItem('hdg-nai-a1111-prompt', JSON.stringify(opts));
}
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function initSettingsPanel() {
let form = '';
for (const key in opts) {
let value = opts[key];
let valueLabel = optsNames[key];
const excludedKeys = [
'keyedit_delimiters',
'keyedit_target',
];
if (excludedKeys.includes(key)) {
continue;
}
if (Array.isArray(value)) {
continue;
} else if (typeof value === 'boolean') {
form += `
<label for="${key}">${valueLabel}</label><br>
<input type="checkbox" id="${key}" name="${key}" ${value ? 'checked' : ''}><br>
`;
} else {
form += `
<label for="${key}">${key}</label><br>
<input type="text" id="${key}" name="${key}" value="${value}"><br>
`;
}
}
form += `<button type="submit" id="save" value="Save">Save</button>`;
form += `<button type="button" id="close">Close</button>`;
form = `<form id="hdgnaia1111prompt">` + form;
form += `</form>`;
const settingsPanel = VM.getPanel({
content: form,
theme: 'dark',
});
settingsPanel.wrapper.style.position = 'fixed';
settingsPanel.wrapper.style.left = '50%';
settingsPanel.wrapper.style.top = '50%';
settingsPanel.wrapper.style.transform = 'translate(-50%, -50%)';
settingsPanel.body.innerHTML = form;
const formEl = settingsPanel.body.querySelector('form');
formEl.addEventListener('submit', (evt) => {
evt.preventDefault();
const optsArray = Array.from(evt.target.querySelectorAll('input')).map((x) => (
[`${x.id}`, x.type === "checkbox" ? x.checked : x.value]
));
for (const opt of optsArray) {
let optKey = opt[0];
let optVal = opt[1];
if (typeof optVal === 'string' || optVal instanceof String) {
optVal = optVal === "on" ? true : false;
}
opts[optKey] = optVal;
};
localStorage.setItem('hdg-nai-a1111-prompt', JSON.stringify(opts));
settingsPanel.hide();
});
const closeButton = settingsPanel.body.querySelector('form #close')
closeButton.addEventListener('click', (evt) => {
evt.preventDefault();
settingsPanel.hide();
})
document.addEventListener('keydown', (evt) => {
if (evt.key == "x" && evt.ctrlKey && evt.altKey) {
if (document.querySelector(`#${settingsPanel.id}`)) {
settingsPanel.hide();
} else {
settingsPanel.show();
}
}
});
return settingsPanel;
}
const settingsPanel = initSettingsPanel();
function setNativeValue(element, value) {
const { set: valueSetter } = Object.getOwnPropertyDescriptor(element, 'value') || {}
const prototype = Object.getPrototypeOf(element)
const { set: prototypeValueSetter } = Object.getOwnPropertyDescriptor(prototype, 'value') || {}
if (prototypeValueSetter && valueSetter !== prototypeValueSetter) {
prototypeValueSetter.call(element, value)
} else if (valueSetter) {
valueSetter.call(element, value)
} else {
throw new Error('The given element does not have a value setter')
}
}
function keyupEditAttention(event) {
let target = event.originalTarget || event.composedPath()[0];
if (!target.matches(opts.keyedit_target)) return;
if (!(event.metaKey || event.ctrlKey)) return;
let isPlus = event.key == "ArrowUp";
let isMinus = event.key == "ArrowDown";
if (!isPlus && !isMinus) return;
let selectionStart = target.selectionStart;
let selectionEnd = target.selectionEnd;
let text = target.value;
if (!(text.length > 0)) return;
function selectCurrentWord() {
if (selectionStart !== selectionEnd) return false;
const whitespace_delimiters = {
"Tab": "\t",
"Carriage Return": "\r",
"Line Feed": "\n"
};
let delimiters = opts.keyedit_delimiters;
for (let i of opts.keyedit_delimiters_whitespace) {
delimiters += whitespace_delimiters[i];
}
// seek backward to find beginning
while (!delimiters.includes(text[selectionStart - 1]) && selectionStart > 0) {
selectionStart--;
}
// seek forward to find end
while (!delimiters.includes(text[selectionEnd]) && selectionEnd < text.length) {
selectionEnd++;
}
// deselect surrounding whitespace
while (target.textContent.slice(selectionStart, selectionStart + 1) == " " && selectionStart < selectionEnd) {
selectionStart++;
}
while (target.textContent.slice(selectionEnd - 1, selectionEnd) == " " && selectionEnd > selectionStart) {
selectionEnd--;
}
target.setSelectionRange(selectionStart, selectionEnd);
return true;
}
selectCurrentWord();
event.preventDefault();
const start = selectionStart > 0 ? text[selectionStart - 1] : "";
const end = text[selectionEnd];
const deltaCurrent = !["{", "["].includes(start) ? 0 : (start == "{" ? 1 : -1);
const deltaUser = isPlus ? 1 : -1;
let selectionStartDelta = 0;
let selectionEndDelta = 0;
function addBrackets(str, isPlus) {
if (isPlus) {
str = `{${str}}`;
} else {
str = `[${str}]`;
}
return str;
}
/* modify text */
let modifiedText = text.slice(selectionStart, selectionEnd);
if (deltaCurrent == 0 || deltaCurrent == deltaUser) {
modifiedText = addBrackets(modifiedText, isPlus);
selectionStartDelta += 1;
selectionEndDelta += 1;
} else {
selectionStart--;
selectionEnd++;
selectionEndDelta -= 2;
}
text = text.slice(0, selectionStart)
+ modifiedText
+ text.slice(selectionEnd);
target.focus();
setNativeValue(target, text);
target.selectionStart = selectionStart + selectionStartDelta;
target.selectionEnd = selectionEnd + selectionEndDelta;
target.dispatchEvent((new Event('input', { bubbles: true })));
}
function getGenerateButton(el) {
return el.querySelector('div button > span + div')?.parentElement;
}
async function generateWithWildcards() {
const prompt = document.querySelector(opts.keyedit_target);
if (!prompt) {
return;
}
let originalPrompt = prompt.value;
let newPrompt = '';
const parts = prompt.value.split(reExtra);
for (const part of parts) {
if (part.length <= 0) {
continue;
}
const choices = part.split('$');
const choice = choices[~~(Math.random() * choices.length)];
newPrompt += choice;
}
console.debug(`Wildcard prompt: ${newPrompt}`);
setNativeValue(prompt, newPrompt);
prompt.dispatchEvent((new Event('input', { bubbles: true })));
const gen = getGenerateButton(document);
if (!gen) {
return
}
// lol
setTimeout(() => {
gen.click();
}, 250);
setTimeout(() => {
setNativeValue(prompt, originalPrompt);
prompt.dispatchEvent((new Event('input', { bubbles: true })));
setTimeout(() => {
prompt.focus();
setTimeout(() => {
prompt.blur();
setTimeout(() => {
prompt.focus();
}, 250);
}, 250);
}, 250);
}, 500);
}
window.addEventListener('keydown', (event) => {
keyupEditAttention(event);
});
document.addEventListener('keydown', (evt) => {
if (evt.key == "\\" && evt.ctrlKey && !evt.shiftKey && !evt.altKey) {
evt.preventDefault();
evt.stopPropagation();
if (opts['wildcards']) {
generateWithWildcards();
}
}
});
window.addEventListener('keyup', (event) => {
let target = event.originalTarget || event.composedPath()[0];
const tagSuggestionsExist = document.querySelector('div[style*="opacity: 1"][style*="transform: none"] span');
if (tagSuggestionsExist && target.matches(opts.keyedit_target) && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
event.stopPropagation();
keyupEditAttention(event);
}
});
async function observers() {
(new MutationObserver((mutations) => {
for (const mutation of mutations) {
// Ignore all mutations when settings page is open
if (document.querySelector(`#${settingsPanel.id}`)) {
break;
}
// Save image on each generation finish
try {
if (
mutation?.target
&& mutation?.target?.firstChild
&& !mutation?.target?.id
&& mutation?.target?.firstChild?.getAttribute('role') == 'button'
&& mutation?.target?.firstChild?.getAttribute('aria-label') != null
) {
setTimeout(() => {
const saveButtons = Array.from(
document.querySelectorAll('div[data-projection-id] button'))
.filter(button => {
if (!button.firstChild || button.firstChild.nodeType != 1) {
return false;
}
const computedStyles = getComputedStyle(button.firstChild);
const maskImage = Array.from(computedStyles).filter((css) => (css.includes('maskImage') || css.includes('mask-image')))?.[0];
if (maskImage && computedStyles[maskImage].includes('/save.')) {
return true;
}
return false;
});
if (saveButtons.length > 0 && opts['observer_auto_save']) {
for (const saveButton of saveButtons) {
saveButton.click();
}
}
}, 500);
}
} catch { ; }
}
})).observe(document.body, {childList: true, subtree: true});
(new MutationObserver((mutations) => {
for (const mutation of mutations) {
// Generate forever
const generateButton = getGenerateButton(mutation?.target?.parentElement);
if (
generateButton
&& mutation?.target
&& mutation?.target.childElementCount <= 2
&& !generateButton.offsetParent
&& generateButton.getAttribute('disabled') === null
) {
setTimeout(() => {
if (!document.querySelector('#historyContainer div[role="button"][aria-label]')) {
return;
}
if (opts['observer_auto_generate'] && opts['wildcards']) {
generateWithWildcards();
}
if (opts['observer_auto_generate']) {
generateButton.click();
}
}, 500);
}
}
})).observe(document.body, {attributes: true, subtree: true, attributeFilter: ['disabled']});
}
await observers();
})();
@catboxanon
Copy link
Author

Updated now.

@CharlesGoodenMcGradyWiley

The require needs bumped to https://cdn.jsdelivr.net/combine/npm/@violentmonkey/dom@2,npm/@violentmonkey/ui@0.7.9 for it to work

Browser: OperaGX
Userscript: Manager Violentmonkey

@catboxanon
Copy link
Author

catboxanon commented Apr 12, 2024

I've updated it to always use the latest version now, so no version bumping or pinning should be needed hopefully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment