diff --git a/public/script.js b/public/script.js index a20a04ccb..31156c99c 100644 --- a/public/script.js +++ b/public/script.js @@ -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; diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 3934cfde7..f0503c35d 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -1757,7 +1757,7 @@ function modelCallback(_, model) { * @param {SlashCommandScope} scope The scope to be used when executing the commands. * @returns {Promise} */ -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; } } - return await closure.execute(); + 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); - executor.name = slashCommand; - executor.end = executor.start + slashCommand.length; + 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 `/${name.slice(0, slashCommand.length)}${name.slice(slashCommand.length)} `; + return `${noSlash?'':'/'}${name.slice(0, slashCommand.length)}${name.slice(slashCommand.length)} `; } case 'includes': { const start = name.toLowerCase().search(slashCommand); - return `/${name.slice(0, start)}${name.slice(start, start + slashCommand.length)}${name.slice(start + slashCommand.length)} `; + return `${noSlash?'':'/'}${name.slice(0, start)}${name.slice(start, start + slashCommand.length)}${name.slice(start + slashCommand.length)} `; } case 'fuzzy': { const matched = name.replace(fuzzyRegex, (_, ...parts)=>{ @@ -1897,21 +1944,29 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) { return it; }).join(''); }); - return `/${matched} `; + return `${noSlash?'':'/'}${matched} `; } } }; // 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: `${name.includes('.')?'QR':'𝑥'} ${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 - result.push({ - name: '', - label: `No matching commands for "/${slashCommand}"`, - value:'', - score: null, - li: null, - }); - } else if (result.length == 1 && result[0].value == `/${executor.name}`) { + if (options) { + result.push({ + name: '', + label: slashCommand.length ? + `No matching variables in scope for "${slashCommand}"` + : 'No variables in scope.', + value: null, + score: null, + li: null, + }); + } 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; diff --git a/public/scripts/slash-commands/SlashCommandClosure.js b/public/scripts/slash-commands/SlashCommandClosure.js index 30d81516d..6880dcc60 100644 --- a/public/scripts/slash-commands/SlashCommandClosure.js +++ b/public/scripts/slash-commands/SlashCommandClosure.js @@ -8,9 +8,9 @@ export class SlashCommandClosure { /**@type {SlashCommandScope}*/ scope; /**@type {Boolean}*/ executeNow = false; // @ts-ignore - /**@type {Map}*/ arguments = {}; + /**@type {Object.}*/ arguments = {}; // @ts-ignore - /**@type {Map}*/ providedArguments = {}; + /**@type {Object.}*/ providedArguments = {}; /**@type {SlashCommandExecutor[]}*/ executorList = []; /**@type {String}*/ keptText; diff --git a/public/scripts/slash-commands/SlashCommandClosureExecutor.js b/public/scripts/slash-commands/SlashCommandClosureExecutor.js index 0f932b174..70ac43bb3 100644 --- a/public/scripts/slash-commands/SlashCommandClosureExecutor.js +++ b/public/scripts/slash-commands/SlashCommandClosureExecutor.js @@ -1,5 +1,5 @@ export class SlashCommandClosureExecutor { /**@type {String}*/ name = ''; // @ts-ignore - /**@type {Map}*/ providedArguments = {}; + /**@type {Object.}*/ providedArguments = {}; } diff --git a/public/scripts/slash-commands/SlashCommandExecutor.js b/public/scripts/slash-commands/SlashCommandExecutor.js index e44ba31b3..aa78aebfb 100644 --- a/public/scripts/slash-commands/SlashCommandExecutor.js +++ b/public/scripts/slash-commands/SlashCommandExecutor.js @@ -10,7 +10,7 @@ export class SlashCommandExecutor { /**@type {String}*/ name = ''; /**@type {SlashCommand}*/ command; // @ts-ignore - /**@type {Map}*/ args = {}; + /**@type {Object.}*/ args = {}; /**@type {String|SlashCommandClosure|(String|SlashCommandClosure)[]}*/ value; constructor(start) { diff --git a/public/scripts/slash-commands/SlashCommandParser.js b/public/scripts/slash-commands/SlashCommandParser.js index eb0132a32..ec54b6874 100644 --- a/public/scripts/slash-commands/SlashCommandParser.js +++ b/public/scripts/slash-commands/SlashCommandParser.js @@ -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 {
  • 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.
  • `; } + /** + * + * @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() { diff --git a/public/scripts/slash-commands/SlashCommandScope.js b/public/scripts/slash-commands/SlashCommandScope.js index b2e068c23..c555c51e5 100644 --- a/public/scripts/slash-commands/SlashCommandScope.js +++ b/public/scripts/slash-commands/SlashCommandScope.js @@ -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}*/ variables = {}; + /**@type {Object.}*/ variables = {}; // @ts-ignore - /**@type {Map}*/ macros = {}; + /**@type {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; diff --git a/public/style.css b/public/style.css index f63a2734c..6c3bb2b08 100644 --- a/public/style.css +++ b/public/style.css @@ -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);