| 1 | /** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */ |
| 2 | /** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */ |
| 3 | /** @typedef {import('chevrotain').ILexingError} ILexingError */ |
| 4 | /** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */ |
| 5 | |
| 6 | import { t } from '/scripts/i18n.js'; |
| 7 | import { Popup, POPUP_RESULT } from '/scripts/popup.js'; |
| 8 | import { power_user } from '/scripts/power-user.js'; |
| 9 | import { accountStorage } from '/scripts/util/AccountStorage.js'; |
| 10 | import { SimpleMutex } from '/scripts/util/SimpleMutex.js'; |
| 11 | |
| 12 | /** |
| 13 | * @typedef {Object} MacroErrorContext |
| 14 | * @property {string} [macroName] |
| 15 | * @property {MacroCall} [call] |
| 16 | * @property {MacroDefinition} [def] |
| 17 | */ |
| 18 | |
| 19 | /** |
| 20 | * Options for creating a macro runtime error. |
| 21 | * |
| 22 | * @typedef {MacroErrorContext & { message: string }} MacroRuntimeErrorOptions |
| 23 | */ |
| 24 | |
| 25 | /** |
| 26 | * Options for logging macro warnings or errors. |
| 27 | * |
| 28 | * @typedef {MacroErrorContext & { message: string, error?: any }} MacroLogOptions |
| 29 | */ |
| 30 | |
| 31 | |
| 32 | // Use mutex here so even on parallel usage without awaiting the popup, this will only show up once. |
| 33 | export const onboardingExperimentalMacroEngineMutex = new SimpleMutex(onboardingExperimentalMacroEngineUnsafe); |
| 34 | |
| 35 | /** |
| 36 | * Onboards the user to use the experimental macro engine. |
| 37 | * Asks the user to enable it if they haven't already. |
| 38 | * |
| 39 | * @param {string|null} feature - The feature that requires the experimental macro engine, or null if not applicable or unknown. |
| 40 | * @returns {Promise<void>} - A promise that resolves when the user has been onboarded. |
| 41 | */ |
| 42 | export const onboardingExperimentalMacroEngine = onboardingExperimentalMacroEngineMutex.update.bind(onboardingExperimentalMacroEngineMutex); |
| 43 | |
| 44 | async function onboardingExperimentalMacroEngineUnsafe(feature = null) { |
| 45 | // Show a popup once telling a user that they are using experimental features that only work with the new engine. |
| 46 | // Ask them if they want to turn the experimental engine on. |
| 47 | if (power_user.experimental_macro_engine) return; |
| 48 | |
| 49 | // If already shown, do not show again |
| 50 | const shown = accountStorage.getItem('slash_command_experimental_engine_warning_shown'); |
| 51 | if (shown === 'true') return; |
| 52 | |
| 53 | const result = await Popup.show.confirm(t`Experimental Macro Engine`, ` |
| 54 | <p>${t`You are using experimental macro features that require the new macro engine.`}</p> |
| 55 | ${feature ? `<div class="info-block hint"> |
| 56 | <span>${t`Recognized Feature: `}<strong>${feature}</strong></span> |
| 57 | </div>` : ''} |
| 58 | <p>${t`For more information on the new macro engine, visit the <br />${`<a href="https://docs.sillytavern.app/usage/core-concepts/macros/">${t`Macro Documentation`}</a>`}.`}</p> |
| 59 | <p>${t`You can enable the engine any time under:<br />${t`User Settings`} → ${t`Experimental Macro Engine`}`}</p> |
| 60 | <p>${t`Would you like to enable it now?`}</p>`); |
| 61 | if (result == POPUP_RESULT.AFFIRMATIVE) { |
| 62 | power_user.experimental_macro_engine = true; |
| 63 | $('#experimental_macro_engine').prop('checked', power_user.experimental_macro_engine).trigger('input'); |
| 64 | } |
| 65 | |
| 66 | // Only show this once |
| 67 | accountStorage.setItem('slash_command_experimental_engine_warning_shown', 'true'); |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Creates an error representing a runtime macro invocation problem (such as |
| 72 | * arity or type mismatches). These errors are intended to be caught by the |
| 73 | * MacroEngine, which will log them as runtime warnings and leave the macro |
| 74 | * raw in the evaluated text. |
| 75 | * |
| 76 | * @param {MacroRuntimeErrorOptions} options |
| 77 | * @returns {Error} |
| 78 | */ |
| 79 | export function createMacroRuntimeError({ message, call, def, macroName }) { |
| 80 | const inferredName = inferMacroName(call, def, macroName); |
| 81 | |
| 82 | const error = new Error(message); |
| 83 | error.name = 'MacroRuntimeError'; |
| 84 | // @ts-ignore - custom tagging for downstream classification |
| 85 | error.isMacroRuntimeError = true; |
| 86 | // @ts-ignore - helpful metadata for debugging |
| 87 | error.macroName = inferredName; |
| 88 | // @ts-ignore - best-effort location information |
| 89 | error.macroRange = call && call.range ? call.range : null; |
| 90 | // @ts-ignore - attach raw call/definition for convenience |
| 91 | if (call) error.macroCall = call; |
| 92 | // @ts-ignore |
| 93 | if (def) error.macroDefinition = def; |
| 94 | |
| 95 | return error; |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Logs a macro runtime warning with consistent, helpful context. These |
| 100 | * correspond to issues in how a macro was written in the text (e.g. invalid |
| 101 | * arguments), not bugs in macro definitions or the engine itself. |
| 102 | * |
| 103 | * @param {MacroLogOptions} options |
| 104 | */ |
| 105 | export function logMacroRuntimeWarning({ message, call, def, macroName, error }) { |
| 106 | const payload = buildMacroPayload({ call, def, macroName, error }); |
| 107 | console.warn('[Macro] Warning:', message, payload); |
| 108 | } |
| 109 | |
| 110 | /** |
| 111 | * Logs an internal macro error (definition or engine bug) with a consistent |
| 112 | * schema. These are surfaced as red errors in the console. |
| 113 | * |
| 114 | * @param {MacroLogOptions} options |
| 115 | */ |
| 116 | export function logMacroInternalError({ message, call, macroName, error }) { |
| 117 | const payload = buildMacroPayload({ call, def: undefined, macroName, error }); |
| 118 | console.error('[Macro] Error:', message, payload); |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Logs a warning during macro registration. |
| 123 | * |
| 124 | * @param {{ message: string, macroName?: string, error?: any }} options |
| 125 | */ |
| 126 | export function logMacroRegisterWarning({ message, macroName, error = undefined }) { |
| 127 | const payload = buildMacroPayload({ macroName, error }); |
| 128 | console.warn('[Macro] Warning:', message, payload); |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Logs an error during macro registration. Used when registration fails |
| 133 | * and the macro will not be available. |
| 134 | * |
| 135 | * @param {{ message: string, macroName?: string, error?: any }} options |
| 136 | */ |
| 137 | export function logMacroRegisterError({ message, macroName, error = undefined }) { |
| 138 | const payload = buildMacroPayload({ macroName, error }); |
| 139 | console.error('[Macro] Registration Error:', message, payload); |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Logs a macro error with a consistent schema. |
| 144 | * |
| 145 | * @param {{ message: string, error?: any }} options |
| 146 | */ |
| 147 | export function logMacroGeneralError({ message, error }) { |
| 148 | console.error('[Macro] Error:', message, error); |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Logs lexer/parser syntax warnings for the macro engine with a compact, |
| 153 | * human-readable payload. |
| 154 | * |
| 155 | * @param {{ phase: 'lexing', input: string, errors: ILexingError[] }|{ phase: 'parsing', input: string, errors: IRecognitionException[] }} options |
| 156 | */ |
| 157 | export function logMacroSyntaxWarning({ phase, input, errors }) { |
| 158 | if (!errors || errors.length === 0) { |
| 159 | return; |
| 160 | } |
| 161 | |
| 162 | /** @type {{ message: string, line: number|null, column: number|null, length: number|null }[]} */ |
| 163 | const issues = errors.map((err) => { |
| 164 | const hasOwnLine = typeof err.line === 'number'; |
| 165 | const hasOwnColumn = typeof err.column === 'number'; |
| 166 | |
| 167 | const token = /** @type {{ startLine?: number, startColumn?: number, startOffset?: number, endOffset?: number }|undefined} */ (err.token); |
| 168 | |
| 169 | const line = hasOwnLine ? err.line : (token && typeof token.startLine === 'number' ? token.startLine : null); |
| 170 | const column = hasOwnColumn ? err.column : (token && typeof token.startColumn === 'number' ? token.startColumn : null); |
| 171 | |
| 172 | /** @type {number|null} */ |
| 173 | let length = null; |
| 174 | if (typeof err.length === 'number') { |
| 175 | length = err.length; |
| 176 | } else if (token && typeof token.startOffset === 'number' && typeof token.endOffset === 'number') { |
| 177 | length = token.endOffset - token.startOffset + 1; |
| 178 | } |
| 179 | |
| 180 | return { |
| 181 | message: err.message, |
| 182 | line, |
| 183 | column, |
| 184 | length, |
| 185 | }; |
| 186 | }); |
| 187 | |
| 188 | const label = phase === 'lexing' ? 'Lexing' : 'Parsing'; |
| 189 | |
| 190 | /** @type {Record<string, any>} */ |
| 191 | const payload = { |
| 192 | phase, |
| 193 | count: issues.length, |
| 194 | issues, |
| 195 | input, |
| 196 | }; |
| 197 | |
| 198 | console.warn('[Macro] Warning:', `${label} errors detected`, payload); |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * Builds a structured payload for macro logging. |
| 203 | * |
| 204 | * @param {MacroErrorContext & { error?: any }} ctx |
| 205 | */ |
| 206 | function buildMacroPayload({ call, def, macroName, error }) { |
| 207 | const inferredName = inferMacroName(call, def, macroName); |
| 208 | |
| 209 | /** @type {Record<string, any>} */ |
| 210 | const payload = { |
| 211 | macroName: inferredName, |
| 212 | }; |
| 213 | |
| 214 | if (call && call.range) payload.range = call.range; |
| 215 | if (call && typeof call.rawInner === 'string') payload.raw = call.rawInner; |
| 216 | if (call) payload.call = call; |
| 217 | if (def) payload.def = def; |
| 218 | if (error) payload.error = error; |
| 219 | |
| 220 | return payload; |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * Infers the most appropriate macro name from the available context. |
| 225 | * |
| 226 | * @param {MacroCall} [call] |
| 227 | * @param {MacroDefinition} [def] |
| 228 | * @param {string} [explicit] |
| 229 | * @returns {string} |
| 230 | */ |
| 231 | function inferMacroName(call, def, explicit) { |
| 232 | if (typeof explicit === 'string' && explicit.trim()) { |
| 233 | return explicit.trim(); |
| 234 | } |
| 235 | if (call && typeof call.name === 'string' && call.name.trim()) { |
| 236 | return call.name.trim(); |
| 237 | } |
| 238 | if (def && typeof def.name === 'string' && def.name.trim()) { |
| 239 | return def.name.trim(); |
| 240 | } |
| 241 | return 'unknown'; |
| 242 | } |