mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
throwing shit at the wall for /: autocomplete
This commit is contained in:
@ -2393,7 +2393,7 @@ async function processCommands(message) {
|
||||
}
|
||||
|
||||
const previousText = String($('#send_textarea').val());
|
||||
const result = await executeSlashCommands(message);
|
||||
const result = await executeSlashCommands(message, true, null, true);
|
||||
|
||||
if (!result || typeof result !== 'object') {
|
||||
return false;
|
||||
|
@ -1757,7 +1757,7 @@ function modelCallback(_, model) {
|
||||
* @param {SlashCommandScope} scope The scope to be used when executing the commands.
|
||||
* @returns {Promise<SlashCommandClosureResult>}
|
||||
*/
|
||||
async function executeSlashCommands(text, handleParserErrors = true, scope = null) {
|
||||
async function executeSlashCommands(text, handleParserErrors = true, scope = null, handleExecutionErrors = false) {
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
@ -1780,13 +1780,26 @@ async function executeSlashCommands(text, handleParserErrors = true, scope = nul
|
||||
'SlashCommandParserError',
|
||||
{ escapeHtml:false, timeOut: 10000, onclick:()=>callPopup(toast, 'text') },
|
||||
);
|
||||
return;
|
||||
const result = new SlashCommandClosureResult();
|
||||
result.interrupt = true;
|
||||
return result;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
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 {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'); {
|
||||
dom.classList.add('slashCommandAutoComplete');
|
||||
}
|
||||
@ -1803,13 +1816,16 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
||||
let selectedItem = null;
|
||||
let isActive = false;
|
||||
let text;
|
||||
/**@type {SlashCommandExecutor}*/
|
||||
let executor;
|
||||
let clone;
|
||||
let startQuote;
|
||||
let endQuote;
|
||||
const hide = () => {
|
||||
dom?.remove();
|
||||
isActive = false;
|
||||
};
|
||||
const show = (isInput = false, isForced = false) => {
|
||||
const show = async(isInput = false, isForced = false) => {
|
||||
//TODO check if isInput and isForced are both required
|
||||
text = textarea.value;
|
||||
// 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
|
||||
// cursor position
|
||||
executor = parser.getCommandAt(text, textarea.selectionStart);
|
||||
let slashCommand = executor?.name?.toLowerCase() ?? '';
|
||||
const parserResult = parser.getCommandAt(text, textarea.selectionStart);
|
||||
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
|
||||
// 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 ((isForced || isInput) && executor && textarea.selectionStart > executor.start - 2 && textarea.selectionStart <= executor.start - 2 + executor.name.length + 1) {
|
||||
slashCommand = slashCommand.slice(0, textarea.selectionStart - (executor.start - 2) - 1);
|
||||
if ((isForced || isInput)
|
||||
&& (
|
||||
((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.end = executor.start + slashCommand.length;
|
||||
}
|
||||
isReplacable = true;
|
||||
}
|
||||
|
||||
@ -1874,14 +1921,14 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
||||
if (a.score.longestConsecutive < b.score.longestConsecutive) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
};
|
||||
const buildHelpStringName = (name) => {
|
||||
const buildHelpStringName = (name, noSlash=false) => {
|
||||
switch (matchType) {
|
||||
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': {
|
||||
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': {
|
||||
const matched = name.replace(fuzzyRegex, (_, ...parts)=>{
|
||||
@ -1897,21 +1944,29 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
||||
return it;
|
||||
}).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
|
||||
if (!executor) return hide();
|
||||
if (!executor && !options) return hide();
|
||||
else {
|
||||
const helpStrings = Object
|
||||
.keys(parser.commands) // Get all slash commands
|
||||
.filter(it => executor.name == '' || isReplacable ? matchers[matchType](it) : it.toLowerCase() == slashCommand) // Filter by the input
|
||||
const helpStrings = (options ?? Object.keys(parser.commands)) // Get all slash commands
|
||||
.filter(it => run?.value == '' || executor?.name == '' || isReplacable ? matchers[matchType](it) : it.toLowerCase() == slashCommand) // Filter by the input
|
||||
;
|
||||
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 => {
|
||||
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];
|
||||
let aliases = '';
|
||||
if (cmd.aliases?.length > 0) {
|
||||
@ -1926,7 +1981,7 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
||||
return {
|
||||
name: name,
|
||||
label: `${buildHelpStringName(name)}${cmd.helpString}${aliases}`,
|
||||
value: `/${name}`,
|
||||
value: `${name}`,
|
||||
score: matchType == 'fuzzy' ? fuzzyScore(name) : null,
|
||||
li: null,
|
||||
};
|
||||
@ -1941,16 +1996,30 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
||||
return hide();
|
||||
}
|
||||
// otherwise add "no match" notice
|
||||
if (options) {
|
||||
result.push({
|
||||
name: '',
|
||||
label: `No matching commands for "/${slashCommand}"`,
|
||||
value:'',
|
||||
label: slashCommand.length ?
|
||||
`No matching variables in scope for "${slashCommand}"`
|
||||
: 'No variables in scope.',
|
||||
value: null,
|
||||
score: 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
|
||||
isReplacable = false;
|
||||
} else if (!isReplacable && result.length > 1) {
|
||||
return hide();
|
||||
}
|
||||
|
||||
// render autocomplete list
|
||||
@ -2001,6 +2070,9 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
||||
updatePosition();
|
||||
document.body.append(dom);
|
||||
isActive = true;
|
||||
if (options) {
|
||||
executor = {start:run.start, name:`${slashCommand}`};
|
||||
}
|
||||
};
|
||||
const updatePosition = () => {
|
||||
if (isFloating) {
|
||||
@ -2074,8 +2146,8 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
|
||||
};
|
||||
let pointerup = Promise.resolve();
|
||||
const select = async() => {
|
||||
if (isReplacable) {
|
||||
textarea.value = `${text.slice(0, executor.start - 2)}${selectedItem.value}${text.slice(executor.start - 2 + executor.name.length + 1)}`;
|
||||
if (isReplacable && selectedItem.value !== null) {
|
||||
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;
|
||||
textarea.focus();
|
||||
textarea.selectionStart = executor.start - 2 + selectedItem.value.length;
|
||||
|
@ -8,9 +8,9 @@ export class SlashCommandClosure {
|
||||
/**@type {SlashCommandScope}*/ scope;
|
||||
/**@type {Boolean}*/ executeNow = false;
|
||||
// @ts-ignore
|
||||
/**@type {Map<string,string|SlashCommandClosure>}*/ arguments = {};
|
||||
/**@type {Object.<string,string|SlashCommandClosure>}*/ arguments = {};
|
||||
// @ts-ignore
|
||||
/**@type {Map<string,string|SlashCommandClosure>}*/ providedArguments = {};
|
||||
/**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
|
||||
/**@type {SlashCommandExecutor[]}*/ executorList = [];
|
||||
/**@type {String}*/ keptText;
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
export class SlashCommandClosureExecutor {
|
||||
/**@type {String}*/ name = '';
|
||||
// @ts-ignore
|
||||
/**@type {Map<string,string|SlashCommandClosure>}*/ providedArguments = {};
|
||||
/**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ export class SlashCommandExecutor {
|
||||
/**@type {String}*/ name = '';
|
||||
/**@type {SlashCommand}*/ command;
|
||||
// @ts-ignore
|
||||
/**@type {Map<String,String|SlashCommandClosure>}*/ args = {};
|
||||
/**@type {Object.<string,String|SlashCommandClosure>}*/ args = {};
|
||||
/**@type {String|SlashCommandClosure|(String|SlashCommandClosure)[]}*/ value;
|
||||
|
||||
constructor(start) {
|
||||
|
@ -18,6 +18,7 @@ export class SlashCommandParser {
|
||||
/**@type {SlashCommandScope}*/ scope;
|
||||
|
||||
/**@type {SlashCommandExecutor[]}*/ commandIndex;
|
||||
/**@type {SlashCommandScope[]}*/ scopeIndex;
|
||||
|
||||
get ahead() {
|
||||
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>`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} text
|
||||
* @param {*} index
|
||||
* @returns {SlashCommandExecutor|String[]}
|
||||
*/
|
||||
getCommandAt(text, index) {
|
||||
try {
|
||||
this.parse(text, false);
|
||||
@ -180,7 +187,8 @@ export class SlashCommandParser {
|
||||
.slice(-1)[0]
|
||||
?? 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;
|
||||
}
|
||||
|
||||
@ -205,6 +213,7 @@ export class SlashCommandParser {
|
||||
this.index = 0;
|
||||
this.scope = null;
|
||||
this.commandIndex = [];
|
||||
this.scopeIndex = [];
|
||||
const closure = this.parseClosure();
|
||||
closure.keptText = this.keptText;
|
||||
return closure;
|
||||
@ -226,6 +235,7 @@ export class SlashCommandParser {
|
||||
while (this.testNamedArgument()) {
|
||||
const arg = this.parseNamedArgument();
|
||||
closure.arguments[arg.key] = arg.value;
|
||||
this.scope.variableNames.push(arg.key);
|
||||
this.discardWhitespace();
|
||||
}
|
||||
while (!this.testClosureEnd()) {
|
||||
@ -269,12 +279,13 @@ export class SlashCommandParser {
|
||||
return this.testCommandEnd();
|
||||
}
|
||||
parseRunShorthand() {
|
||||
const start = this.index;
|
||||
const start = this.index + 2;
|
||||
const cmd = new SlashCommandExecutor(start);
|
||||
cmd.name = ':';
|
||||
cmd.value = '';
|
||||
cmd.command = this.commands[cmd.name];
|
||||
cmd.command = this.commands['run'];
|
||||
this.commandIndex.push(cmd);
|
||||
this.scopeIndex.push(this.scope.getCopy());
|
||||
this.take(2); //discard "/:"
|
||||
if (this.testQuotedValue()) cmd.value = this.parseQuotedValue();
|
||||
else cmd.value = this.parseValue();
|
||||
@ -303,9 +314,10 @@ export class SlashCommandParser {
|
||||
return this.testClosureEnd() || this.endOfText || (this.char == '|' && this.behind.slice(-1) != '\\');
|
||||
}
|
||||
parseCommand() {
|
||||
const start = this.index;
|
||||
const start = this.index + 1;
|
||||
const cmd = new SlashCommandExecutor(start);
|
||||
this.commandIndex.push(cmd);
|
||||
this.scopeIndex.push(this.scope.getCopy());
|
||||
this.take(); // discard "/"
|
||||
while (!/\s/.test(this.char) && !this.testCommandEnd()) cmd.name += this.take(); // take chars until whitespace or end
|
||||
this.discardWhitespace();
|
||||
@ -319,6 +331,15 @@ export class SlashCommandParser {
|
||||
this.discardWhitespace();
|
||||
if (this.testUnnamedArgument()) {
|
||||
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()) {
|
||||
cmd.end = this.index;
|
||||
@ -380,7 +401,7 @@ export class SlashCommandParser {
|
||||
if (listValues.length == 1) return listValues[0];
|
||||
return listValues;
|
||||
}
|
||||
return value.trim();
|
||||
return value.trim().replace(/\\([\s{:])/g, '$1');
|
||||
}
|
||||
|
||||
testQuotedValue() {
|
||||
|
@ -1,8 +1,13 @@
|
||||
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
|
||||
/**@type {Map<String, Object>}*/ variables = {};
|
||||
/**@type {Object.<string, Object>}*/ variables = {};
|
||||
// @ts-ignore
|
||||
/**@type {Map<String, Object>}*/ macros = {};
|
||||
/**@type {Object.<string, Object>}*/ macros = {};
|
||||
get macroList() {
|
||||
return [...Object.keys(this.macros).map(key=>({ key, value:this.macros[key] })), ...(this.parent?.macroList ?? [])];
|
||||
}
|
||||
@ -22,6 +27,7 @@ export class SlashCommandScope {
|
||||
|
||||
getCopy() {
|
||||
const scope = new SlashCommandScope(this.parent);
|
||||
scope.variableNames = [...this.variableNames];
|
||||
scope.variables = Object.assign({}, this.variables);
|
||||
scope.macros = this.macros;
|
||||
scope.#pipe = this.#pipe;
|
||||
|
@ -1129,6 +1129,15 @@ select {
|
||||
background-color: var(--ac-color-selected-background);
|
||||
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 {
|
||||
background-color: var(--ac-color-matched-background);
|
||||
color: var(--ac-color-matched-text);
|
||||
|
Reference in New Issue
Block a user