throwing shit at the wall for /: autocomplete

This commit is contained in:
LenAnderson
2024-04-12 21:43:27 -04:00
parent 18f7b29536
commit 2e1a7b5811
8 changed files with 152 additions and 44 deletions

View File

@ -2393,7 +2393,7 @@ async function processCommands(message) {
} }
const previousText = String($('#send_textarea').val()); const previousText = String($('#send_textarea').val());
const result = await executeSlashCommands(message); const result = await executeSlashCommands(message, true, null, true);
if (!result || typeof result !== 'object') { if (!result || typeof result !== 'object') {
return false; return false;

View File

@ -1757,7 +1757,7 @@ function modelCallback(_, model) {
* @param {SlashCommandScope} scope The scope to be used when executing the commands. * @param {SlashCommandScope} scope The scope to be used when executing the commands.
* @returns {Promise<SlashCommandClosureResult>} * @returns {Promise<SlashCommandClosureResult>}
*/ */
async function executeSlashCommands(text, handleParserErrors = true, scope = null) { async function executeSlashCommands(text, handleParserErrors = true, scope = null, handleExecutionErrors = false) {
if (!text) { if (!text) {
return null; return null;
} }
@ -1780,13 +1780,26 @@ async function executeSlashCommands(text, handleParserErrors = true, scope = nul
'SlashCommandParserError', 'SlashCommandParserError',
{ escapeHtml:false, timeOut: 10000, onclick:()=>callPopup(toast, 'text') }, { escapeHtml:false, timeOut: 10000, onclick:()=>callPopup(toast, 'text') },
); );
return; const result = new SlashCommandClosureResult();
result.interrupt = true;
return result;
} else { } else {
throw e; throw e;
} }
} }
try {
return await closure.execute(); return await closure.execute();
} catch (e) {
if (handleExecutionErrors) {
toastr.error(e.message);
const result = new SlashCommandClosureResult();
result.interrupt = true;
return result;
} else {
throw e;
}
}
} }
/** /**
@ -1794,7 +1807,7 @@ async function executeSlashCommands(text, handleParserErrors = true, scope = nul
* @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete * @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete
* @param {Boolean} isFloating Whether to show the auto complete as a floating window (e.g., large QR editor) * @param {Boolean} isFloating Whether to show the auto complete as a floating window (e.g., large QR editor)
*/ */
export function setSlashCommandAutoComplete(textarea, isFloating = false) { export async function setSlashCommandAutoComplete(textarea, isFloating = false) {
const dom = document.createElement('ul'); { const dom = document.createElement('ul'); {
dom.classList.add('slashCommandAutoComplete'); dom.classList.add('slashCommandAutoComplete');
} }
@ -1803,13 +1816,16 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
let selectedItem = null; let selectedItem = null;
let isActive = false; let isActive = false;
let text; let text;
/**@type {SlashCommandExecutor}*/
let executor; let executor;
let clone; let clone;
let startQuote;
let endQuote;
const hide = () => { const hide = () => {
dom?.remove(); dom?.remove();
isActive = false; isActive = false;
}; };
const show = (isInput = false, isForced = false) => { const show = async(isInput = false, isForced = false) => {
//TODO check if isInput and isForced are both required //TODO check if isInput and isForced are both required
text = textarea.value; text = textarea.value;
// only show with textarea in focus // only show with textarea in focus
@ -1819,16 +1835,47 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
// request parser to get command executor (potentially "incomplete", i.e. not an actual existing command) for // request parser to get command executor (potentially "incomplete", i.e. not an actual existing command) for
// cursor position // cursor position
executor = parser.getCommandAt(text, textarea.selectionStart); const parserResult = parser.getCommandAt(text, textarea.selectionStart);
let slashCommand = executor?.name?.toLowerCase() ?? ''; let run;
let options;
if (Array.isArray(parserResult)) {
executor = null;
run = parserResult[0];
options = parserResult[1];
startQuote = text[run.start - 2] == '"';
endQuote = startQuote && text[run.start - 2 + run.value.length + 1] == '"';
try {
const qrApi = (await import('./extensions/quick-reply/index.js')).quickReplyApi;
options.push(...qrApi.listSets().map(set=>qrApi.listQuickReplies(set).map(qr=>`${set}.${qr}`)).flat());
} catch { /* empty */ }
} else {
executor = parserResult;
}
let slashCommand = run ? run.value?.toLowerCase() : executor?.name?.toLowerCase() ?? '';
// do autocomplete if triggered by a user input and we either don't have an executor or the cursor is at the end // do autocomplete if triggered by a user input and we either don't have an executor or the cursor is at the end
// of the name part of the command // of the name part of the command
isReplacable = isInput && (!executor ? true : textarea.selectionStart == executor.start - 2 + executor.name.length + 1); if (options) {
isReplacable = isInput && (!run.value ? true : textarea.selectionStart == run.start - 2 + run.value.length + (startQuote ? 1 : 0));
} else {
isReplacable = isInput && (!executor ? true : textarea.selectionStart == executor.start - 2 + executor.name.length);
}
// if forced (ctrl+space) or user input and cursor is in the middle of the name part (not at the end) // if forced (ctrl+space) or user input and cursor is in the middle of the name part (not at the end)
if ((isForced || isInput) && executor && textarea.selectionStart > executor.start - 2 && textarea.selectionStart <= executor.start - 2 + executor.name.length + 1) { if ((isForced || isInput)
slashCommand = slashCommand.slice(0, textarea.selectionStart - (executor.start - 2) - 1); && (
((executor) && textarea.selectionStart >= executor.start - 2 && textarea.selectionStart <= executor.start - 2 + executor.name.length)
||
((run) && textarea.selectionStart >= run.start - 2 && textarea.selectionStart <= run.start - 2 + run.value.length + (startQuote ? 1 : 0))
)
){
if (run) {
slashCommand = slashCommand.slice(0, textarea.selectionStart - (run.start - 2) - (startQuote ? 1 : 0));
run.value = slashCommand;
run.end = run.start + slashCommand.length;
} else {
slashCommand = slashCommand.slice(0, textarea.selectionStart - (executor.start - 2));
executor.name = slashCommand; executor.name = slashCommand;
executor.end = executor.start + slashCommand.length; executor.end = executor.start + slashCommand.length;
}
isReplacable = true; isReplacable = true;
} }
@ -1874,14 +1921,14 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
if (a.score.longestConsecutive < b.score.longestConsecutive) return 1; if (a.score.longestConsecutive < b.score.longestConsecutive) return 1;
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}; };
const buildHelpStringName = (name) => { const buildHelpStringName = (name, noSlash=false) => {
switch (matchType) { switch (matchType) {
case 'strict': { case 'strict': {
return `<span class="monospace">/<span class="matched">${name.slice(0, slashCommand.length)}</span>${name.slice(slashCommand.length)}</span> `; return `<span class="monospace">${noSlash?'':'/'}<span class="matched">${name.slice(0, slashCommand.length)}</span>${name.slice(slashCommand.length)}</span> `;
} }
case 'includes': { case 'includes': {
const start = name.toLowerCase().search(slashCommand); const start = name.toLowerCase().search(slashCommand);
return `<span class="monospace">/${name.slice(0, start)}<span class="matched">${name.slice(start, start + slashCommand.length)}</span>${name.slice(start + slashCommand.length)}</span> `; return `<span class="monospace">${noSlash?'':'/'}${name.slice(0, start)}<span class="matched">${name.slice(start, start + slashCommand.length)}</span>${name.slice(start + slashCommand.length)}</span> `;
} }
case 'fuzzy': { case 'fuzzy': {
const matched = name.replace(fuzzyRegex, (_, ...parts)=>{ const matched = name.replace(fuzzyRegex, (_, ...parts)=>{
@ -1897,21 +1944,29 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
return it; return it;
}).join(''); }).join('');
}); });
return `<span class="monospace">/${matched}</span> `; return `<span class="monospace">${noSlash?'':'/'}${matched}</span> `;
} }
} }
}; };
// don't show if no executor found, i.e. cursor's area is not a command // don't show if no executor found, i.e. cursor's area is not a command
if (!executor) return hide(); if (!executor && !options) return hide();
else { else {
const helpStrings = Object const helpStrings = (options ?? Object.keys(parser.commands)) // Get all slash commands
.keys(parser.commands) // Get all slash commands .filter(it => run?.value == '' || executor?.name == '' || isReplacable ? matchers[matchType](it) : it.toLowerCase() == slashCommand) // Filter by the input
.filter(it => executor.name == '' || isReplacable ? matchers[matchType](it) : it.toLowerCase() == slashCommand) // Filter by the input
; ;
result = helpStrings result = helpStrings
.filter((it,idx)=>[idx, -1].includes(helpStrings.indexOf(parser.commands[it].name.toLowerCase()))) // remove duplicates .filter((it,idx)=>options || [idx, -1].includes(helpStrings.indexOf(parser.commands[it].name.toLowerCase()))) // remove duplicates
.map(name => { .map(name => {
if (options) {
return {
name: name,
label: `<span class="type monospace">${name.includes('.')?'QR':'𝑥'}</span> ${buildHelpStringName(name, true)}`,
value: name.includes(' ') || startQuote || endQuote ? `"${name}"` : `${name}`,
score: matchType == 'fuzzy' ? fuzzyScore(name) : null,
li: null,
};
}
const cmd = parser.commands[name]; const cmd = parser.commands[name];
let aliases = ''; let aliases = '';
if (cmd.aliases?.length > 0) { if (cmd.aliases?.length > 0) {
@ -1926,7 +1981,7 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
return { return {
name: name, name: name,
label: `${buildHelpStringName(name)}${cmd.helpString}${aliases}`, label: `${buildHelpStringName(name)}${cmd.helpString}${aliases}`,
value: `/${name}`, value: `${name}`,
score: matchType == 'fuzzy' ? fuzzyScore(name) : null, score: matchType == 'fuzzy' ? fuzzyScore(name) : null,
li: null, li: null,
}; };
@ -1941,16 +1996,30 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
return hide(); return hide();
} }
// otherwise add "no match" notice // otherwise add "no match" notice
if (options) {
result.push({ result.push({
name: '', name: '',
label: `No matching commands for "/${slashCommand}"`, label: slashCommand.length ?
value:'', `No matching variables in scope for "${slashCommand}"`
: 'No variables in scope.',
value: null,
score: null, score: null,
li: null, li: null,
}); });
} else if (result.length == 1 && result[0].value == `/${executor.name}`) { } else {
result.push({
name: '',
label: `No matching commands for "/${slashCommand}"`,
value: null,
score: null,
li: null,
});
}
} else if (result.length == 1 && ((executor && result[0].value == `/${executor.name}`) || (options && result[0].value == run.value))) {
// only one result that is exactly the current value? just show hint, no autocomplete // only one result that is exactly the current value? just show hint, no autocomplete
isReplacable = false; isReplacable = false;
} else if (!isReplacable && result.length > 1) {
return hide();
} }
// render autocomplete list // render autocomplete list
@ -2001,6 +2070,9 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
updatePosition(); updatePosition();
document.body.append(dom); document.body.append(dom);
isActive = true; isActive = true;
if (options) {
executor = {start:run.start, name:`${slashCommand}`};
}
}; };
const updatePosition = () => { const updatePosition = () => {
if (isFloating) { if (isFloating) {
@ -2074,8 +2146,8 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
}; };
let pointerup = Promise.resolve(); let pointerup = Promise.resolve();
const select = async() => { const select = async() => {
if (isReplacable) { if (isReplacable && selectedItem.value !== null) {
textarea.value = `${text.slice(0, executor.start - 2)}${selectedItem.value}${text.slice(executor.start - 2 + executor.name.length + 1)}`; textarea.value = `${text.slice(0, executor.start - 2)}${selectedItem.value}${text.slice(executor.start - 2 + executor.name.length + (startQuote ? 1 : 0) + (endQuote ? 1 : 0))}`;
await pointerup; await pointerup;
textarea.focus(); textarea.focus();
textarea.selectionStart = executor.start - 2 + selectedItem.value.length; textarea.selectionStart = executor.start - 2 + selectedItem.value.length;

View File

@ -8,9 +8,9 @@ export class SlashCommandClosure {
/**@type {SlashCommandScope}*/ scope; /**@type {SlashCommandScope}*/ scope;
/**@type {Boolean}*/ executeNow = false; /**@type {Boolean}*/ executeNow = false;
// @ts-ignore // @ts-ignore
/**@type {Map<string,string|SlashCommandClosure>}*/ arguments = {}; /**@type {Object.<string,string|SlashCommandClosure>}*/ arguments = {};
// @ts-ignore // @ts-ignore
/**@type {Map<string,string|SlashCommandClosure>}*/ providedArguments = {}; /**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
/**@type {SlashCommandExecutor[]}*/ executorList = []; /**@type {SlashCommandExecutor[]}*/ executorList = [];
/**@type {String}*/ keptText; /**@type {String}*/ keptText;

View File

@ -1,5 +1,5 @@
export class SlashCommandClosureExecutor { export class SlashCommandClosureExecutor {
/**@type {String}*/ name = ''; /**@type {String}*/ name = '';
// @ts-ignore // @ts-ignore
/**@type {Map<string,string|SlashCommandClosure>}*/ providedArguments = {}; /**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
} }

View File

@ -10,7 +10,7 @@ export class SlashCommandExecutor {
/**@type {String}*/ name = ''; /**@type {String}*/ name = '';
/**@type {SlashCommand}*/ command; /**@type {SlashCommand}*/ command;
// @ts-ignore // @ts-ignore
/**@type {Map<String,String|SlashCommandClosure>}*/ args = {}; /**@type {Object.<string,String|SlashCommandClosure>}*/ args = {};
/**@type {String|SlashCommandClosure|(String|SlashCommandClosure)[]}*/ value; /**@type {String|SlashCommandClosure|(String|SlashCommandClosure)[]}*/ value;
constructor(start) { constructor(start) {

View File

@ -18,6 +18,7 @@ export class SlashCommandParser {
/**@type {SlashCommandScope}*/ scope; /**@type {SlashCommandScope}*/ scope;
/**@type {SlashCommandExecutor[]}*/ commandIndex; /**@type {SlashCommandExecutor[]}*/ commandIndex;
/**@type {SlashCommandScope[]}*/ scopeIndex;
get ahead() { get ahead() {
return this.text.slice(this.index + 1); return this.text.slice(this.index + 1);
@ -167,6 +168,12 @@ export class SlashCommandParser {
<li>This will remove the first message in chat, send a system message that starts with 'Hello,', and then ask the AI to continue the message.</li></ul>`; <li>This will remove the first message in chat, send a system message that starts with 'Hello,', and then ask the AI to continue the message.</li></ul>`;
} }
/**
*
* @param {*} text
* @param {*} index
* @returns {SlashCommandExecutor|String[]}
*/
getCommandAt(text, index) { getCommandAt(text, index) {
try { try {
this.parse(text, false); this.parse(text, false);
@ -180,7 +187,8 @@ export class SlashCommandParser {
.slice(-1)[0] .slice(-1)[0]
?? null ?? null
; ;
if (executor && executor.name == ':') return null; const scope = this.scopeIndex[this.commandIndex.indexOf(executor)];
if (executor && executor.name == ':') return [executor, scope?.allVariableNames];
return executor; return executor;
} }
@ -205,6 +213,7 @@ export class SlashCommandParser {
this.index = 0; this.index = 0;
this.scope = null; this.scope = null;
this.commandIndex = []; this.commandIndex = [];
this.scopeIndex = [];
const closure = this.parseClosure(); const closure = this.parseClosure();
closure.keptText = this.keptText; closure.keptText = this.keptText;
return closure; return closure;
@ -226,6 +235,7 @@ export class SlashCommandParser {
while (this.testNamedArgument()) { while (this.testNamedArgument()) {
const arg = this.parseNamedArgument(); const arg = this.parseNamedArgument();
closure.arguments[arg.key] = arg.value; closure.arguments[arg.key] = arg.value;
this.scope.variableNames.push(arg.key);
this.discardWhitespace(); this.discardWhitespace();
} }
while (!this.testClosureEnd()) { while (!this.testClosureEnd()) {
@ -269,12 +279,13 @@ export class SlashCommandParser {
return this.testCommandEnd(); return this.testCommandEnd();
} }
parseRunShorthand() { parseRunShorthand() {
const start = this.index; const start = this.index + 2;
const cmd = new SlashCommandExecutor(start); const cmd = new SlashCommandExecutor(start);
cmd.name = ':'; cmd.name = ':';
cmd.value = ''; cmd.value = '';
cmd.command = this.commands[cmd.name]; cmd.command = this.commands['run'];
this.commandIndex.push(cmd); this.commandIndex.push(cmd);
this.scopeIndex.push(this.scope.getCopy());
this.take(2); //discard "/:" this.take(2); //discard "/:"
if (this.testQuotedValue()) cmd.value = this.parseQuotedValue(); if (this.testQuotedValue()) cmd.value = this.parseQuotedValue();
else cmd.value = this.parseValue(); else cmd.value = this.parseValue();
@ -303,9 +314,10 @@ export class SlashCommandParser {
return this.testClosureEnd() || this.endOfText || (this.char == '|' && this.behind.slice(-1) != '\\'); return this.testClosureEnd() || this.endOfText || (this.char == '|' && this.behind.slice(-1) != '\\');
} }
parseCommand() { parseCommand() {
const start = this.index; const start = this.index + 1;
const cmd = new SlashCommandExecutor(start); const cmd = new SlashCommandExecutor(start);
this.commandIndex.push(cmd); this.commandIndex.push(cmd);
this.scopeIndex.push(this.scope.getCopy());
this.take(); // discard "/" this.take(); // discard "/"
while (!/\s/.test(this.char) && !this.testCommandEnd()) cmd.name += this.take(); // take chars until whitespace or end while (!/\s/.test(this.char) && !this.testCommandEnd()) cmd.name += this.take(); // take chars until whitespace or end
this.discardWhitespace(); this.discardWhitespace();
@ -319,6 +331,15 @@ export class SlashCommandParser {
this.discardWhitespace(); this.discardWhitespace();
if (this.testUnnamedArgument()) { if (this.testUnnamedArgument()) {
cmd.value = this.parseUnnamedArgument(); cmd.value = this.parseUnnamedArgument();
if (cmd.name == 'let') {
if (Array.isArray(cmd.value)) {
if (typeof cmd.value[0] == 'string') {
this.scope.variableNames.push(cmd.value[0]);
}
} else if (typeof cmd.value == 'string') {
this.scope.variableNames.push(cmd.value.split(/\s+/)[0]);
}
}
} }
if (this.testCommandEnd()) { if (this.testCommandEnd()) {
cmd.end = this.index; cmd.end = this.index;
@ -380,7 +401,7 @@ export class SlashCommandParser {
if (listValues.length == 1) return listValues[0]; if (listValues.length == 1) return listValues[0];
return listValues; return listValues;
} }
return value.trim(); return value.trim().replace(/\\([\s{:])/g, '$1');
} }
testQuotedValue() { testQuotedValue() {

View File

@ -1,8 +1,13 @@
export class SlashCommandScope { export class SlashCommandScope {
/**@type {String[]}*/ variableNames = [];
get allVariableNames() {
const names = [...this.variableNames, ...(this.parent?.allVariableNames ?? [])];
return names.filter((it,idx)=>idx == names.indexOf(it));
}
// @ts-ignore // @ts-ignore
/**@type {Map<String, Object>}*/ variables = {}; /**@type {Object.<string, Object>}*/ variables = {};
// @ts-ignore // @ts-ignore
/**@type {Map<String, Object>}*/ macros = {}; /**@type {Object.<string, Object>}*/ macros = {};
get macroList() { get macroList() {
return [...Object.keys(this.macros).map(key=>({ key, value:this.macros[key] })), ...(this.parent?.macroList ?? [])]; return [...Object.keys(this.macros).map(key=>({ key, value:this.macros[key] })), ...(this.parent?.macroList ?? [])];
} }
@ -22,6 +27,7 @@ export class SlashCommandScope {
getCopy() { getCopy() {
const scope = new SlashCommandScope(this.parent); const scope = new SlashCommandScope(this.parent);
scope.variableNames = [...this.variableNames];
scope.variables = Object.assign({}, this.variables); scope.variables = Object.assign({}, this.variables);
scope.macros = this.macros; scope.macros = this.macros;
scope.#pipe = this.#pipe; scope.#pipe = this.#pipe;

View File

@ -1129,6 +1129,15 @@ select {
background-color: var(--ac-color-selected-background); background-color: var(--ac-color-selected-background);
color: var(--ac-color-selected-text); color: var(--ac-color-selected-text);
} }
> .type {
display: inline-block;
width: 2.75em;
font-size: 0.8em;
text-align: center;
opacity: 0.6;
&:before { content: "["; }
&:after { content: "]"; }
}
.matched { .matched {
background-color: var(--ac-color-matched-background); background-color: var(--ac-color-matched-background);
color: var(--ac-color-matched-text); color: var(--ac-color-matched-text);