Macros 2.0 (v0.3) - Replacing the existing Macro System with a new Macro Engine (#4820) * Chevrotain lib and env setup * First draft of the macro lexer * fix ESLint types loading for chevrotain * Cleaner lexer modes * Readme link to Chevrotain & license * Add jsconfig to tests folder - Add jsconfig.json to tests folder, to prevent IDE errors on dynamic imports inside the page.evaluate execution. * Slight improvements on lexer & first tests * Add more lexer tests * More edge cases tests * Reorder tests * Add macro execution modifiers + more tests - Added macro flags (execution modifiers) to lexer - Fixed some lexing issues - Expanded lexer tests - Treat lexer errors as failed test * enable eslint for tests and run it * Fix lexing unknown flags - treat as error * Rewrote lexer modes/tokes to capture errors better * Add lexing for output modifiers * Clearer names for lexer tokens * Increase tests default timeout * Restructure lexer error testcases * Allow legacy underscores in macro identifiers * Test case for legacy single-colon syntax * Improve lexer, removing warnings * Basic setup for MacroParser + initial tests * Make parser errors testable * Add macros stuff to SillyTavern.getContext * macros test case naming + lint * Parser consumes basic macros - Fix lexer mode names - Add basic macro parsing (identifier, and arguments) - Tests: basic macro parsing tests - Tests: simplifyCstNode supports ignoring nodes, or flattening nodes to just plaintext * Improve macro argument parsing to allow colons in values Enhances separator handling by fixing separator type detection and enabling colon characters within argument values Updates validation to require at least one argument component and adds error cases for empty arguments Includes expanded test coverage for mixed separator scenarios and edge cases * More nested macro tests Add error case tests to enforce macro start position requirements Include nested macro parsing scenarios and invalid syntax checks Ensures parser correctly handles edge cases with embedded macros * Unvendor chevrotain * Add document rule * Implement visitor, switch built-ins to new type * Puppeteer -> playwright * Revert "Implement visitor, switch built-ins to new type" This reverts commit 706a94b4de62129df6bd6c25e2c6dec692d12226. * Converted puppeteeer tests * File rename * chore: reduce Playwright worker count to 4 for performance/stability * test: add comprehensive legacy macro parser test suite - Added 13 test cases covering legacy macro formats (roll, reverse, comment, datetime, time_UTC, banned, setvar) - Documented parser limitations with TODO comments for whitespace separators, special characters, and empty arguments - Tests validate parsing of various separator styles (space, colon, +/-) and argument formats (quoted, numeric, empty) * fix: handle legacy macro syntax with colon or whitespace separator - Modified arguments rule to support both double-colon (::) and single-colon (:) separators - Made single-colon separator optional to allow whitespace-separated legacy macros - Removed TODO comments as parser now correctly handles legacy macro formats * feat: support space-separated quoted arguments in macro parser - Added parsing support for equals signs and quotes as valid argument tokens - Removed TODO comments for legacy macro parsing with quoted arguments * fix: improves macro argument parsing with colon handling Enhances parser to correctly handle double colons within legacy single-colon arguments Introduces separate parsing rules for arguments with different colon constraints Adds test coverage for arguments containing double colons in legacy format * fix: allow empty macro arguments after double-colon separator - Changed argument rule from AT_LEAST_ONE to MANY to permit zero-length arguments - Updated tests to verify empty argument parsing (e.g., `{{something::}}`) - Enhanced simplifyCstNode helper with default flatten/ignore keys and improved null handling * refactor: improve test documentation with inline macro display - Added inline comments showing the actual macro syntax being tested for better readability - Removed duplicate comment in error test case - Cleaned up extra whitespace in legacy macro tests * feat: add legacy macro preprocessing for time offset format - Implemented preProcessFixLegacyMacros method to convert {{time_UTC±N}} to {{time::UTC±N}} format - Updated tests to use new preprocessing step for legacy time macro parsing - Added runPreProcessFix option to test helper functions for controlled legacy macro handling * feat: add support for comment macros with double-slash syntax - Added `DoubleSlash` token to lexer to recognize `//` as a valid macro identifier - Updated parser to accept either `//` or standard identifiers as macro names - Enhanced test suite with comprehensive comment macro test cases including multiline support * feat: implement macro evaluation engine with CST walking and registry integration (I'm tired, let's just throw this in right now) - Added CST walker and macro registry to engine initialization - Enhanced parseDocument to handle empty input, legacy macro preprocessing, and error collection - Implemented async evaluate method with full macro resolution pipeline - Added resolveMacro callback to handle unknown macros and registry execution - Integrated lexing/parsing error handling with console warnings - Added support for preserving unknown macro * refactor: improve type safety and code clarity in macro evaluation system - Simplified typedef imports to use correct Chevrotain types (CstNode, IToken) - Added TokenRange typedef for consistent offset handling - Enhanced error messages with context-specific prefixes - Replaced verbose type casts with inline JSDoc annotations - Condensed singleton pattern declarations to single lines - Improved null safety with optional chaining and nullish coalescing - Extracted resolveMacro logic into private root function * fix test macro whitespace arguments onls accepting one argument * Fix OpenRouter embeddings URL #4736 * Fix: Prevent data loss on bulk regex move to scoped scripts (#4760) * Fix: Prevent data loss in regex bulk move * prevents moving to scoped scripts with group selected for bulkedit * Refactor: make whitelist validation a bit more robust (#4757) * refactor: extract IP whitelist validation into helper function - Added isIPInWhitelist helper with error handling for individual whitelist entry checks - Replaced inline whitelist matching logic with reusable function calls - Added JSDoc type annotations and error logging for failed IP matching attempts * refactor: simplify whitelist validation with upfront filtering - Moved IP validation to startup time instead of per-request checking - Extracted validateWhitelist function to filter invalid entries once at initialization - Simplified isIPInWhitelist by removing redundant error handling after validation * fix: correct IP whitelist matching to use parsed CIDR notation * Feat: Improve multiline input handling in popups (#4756) * feat: improve multiline input handling in popups - Added Ctrl+Enter requirement for submission in multiline input popups to prevent accidental sends - Exported PopupUtils class for external use * refactor: remove redundant higher/different rows from input popups - Removed rows: 2 from callGenericPopup calls where default behavior is sufficient - Increased rows from 2 to 4 in caption extension for better multiline input experience * Fix npm audit * feat: add max-height and scrolling to world entry key input fields (#4769) - Added 160px max-height to select2 multiple selection, primary key, and secondary key text areas in world entries - Enabled vertical scrolling with hidden horizontal overflow for better UX with long content * Bump anti-troll tags limit Closes #4763 * OpenAI: Add Sora 2 API (#4748) * OpenAI: Add Sora 2 API * Add duration control * Support client generation abort * Reduce poll log amount * Simplify selector * Simplify model-specific control handling * Gemini: Pass non-success response content to frontend * Vertex: Add Vertex AI-specific safety setting (#4770) Closes #4455 * Feature: allows sorting tags by most used (#4768) * add sorting tags by most used * Fix whitespaces * Code review updates * Remove commented code * Fix capitalization in comment * Apply review suggestion * Simplify template init * Reformat * Add documentation for appendViewTagToList and printViewTagList functions * Reprint renamed tags regardless of sorting mode --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> * Move img.swipe notice block up * Preserve media playback state when running appendMediaToMessage (#4771) * Preserve media playback state when running appendMediaToMessage * Better selector specificity * Fix local variable name * Move typedef to global.d.ts * Check for readyState on save/restore * Only check for currentSrc on restoration callback * gpt-5.1 * A 500 billion dollar startup can’t filter API payload fields * Refactor macro argument validation to use requiredArgs and list pattern - Replace minArgs/maxArgs/enforceArity with requiredArgs and list specification - Add MacroListSpec typedef for flexible list argument constraints (min/max) - Rename enforceArity to strictArgs with inverted default behavior - Update validation logic to separately track required vs. list arguments - Add requiredArgs and list arrays to MacroExecutionContext for easier access - Improve error messages to clearly distinguish between required and list * Refactor MacroRegistry.registerMacro to use options object pattern - Move handler function into options object as required property - Improve validation with detailed error messages for all option fields - Add explicit type checking for requiredArgs, list, strictArgs, and description - Consolidate name normalization and validation logic - Simplify list option parsing with clearer conditional structure - Update all builtin macro registrations to use new signature * Add e2e tests for macro arity validation errors - Test ping macro rejects calls with unexpected arguments - Test upper macro rejects calls without required arguments - Verify macros remain unresolved and log warnings on arity violations * re-implement core macros as registered macros - Create shallow frozen copy of env object before passing to macro handlers - Add comprehensive MacroEnv typedef with nested types for names, character, system, and extra fields - Update MacroDefinitionOptions typedef to clarify default values and mark handler as required - Export variable manipulation functions for external use - Fix whitespace in ifCallback JSDoc comment * Defer slash command autocomplete initialization to firstLoadInit - Move slash command autocomplete setup from module-level to initSlashCommandAutoComplete function - Add null check for sendTextarea.value before accessing first character - Import and call initSlashCommandAutoComplete in firstLoadInit sequence - Export registerCoreMacros, registerInstructMacros, and registerVariableMacros using named export syntax - Call registerCoreMacros from initMacros with * Add variable manipulation functions to SillyTavern context API - Import deleteGlobalVariable, deleteLocalVariable, addGlobalVariable, addLocalVariable, incrementGlobalVariable, incrementLocalVariable, decrementGlobalVariable, and decrementLocalVariable from variables.js - Expose del, add, inc, and dec methods on both context.variables.local and context.variables.global objects - Provide consistent API for variable deletion, addition, increment, and decrement operations * Extract test setup utilities and fix trailing comma in core-macros.js - Add testSetup utility object with goST and awaitST helper functions for Playwright tests - Replace duplicated beforeEach setup code in MacroLexer, MacroParser, and MacroRegistry tests with testSetup.goST - Add explanatory comments indicating tests currently run without ST context - Fix missing trailing comma in input macro handler registration * Refactor MacroEngine e2e tests to use real core macros instead of prototypes - Replace prototype macros (ping, echo, upper, wrap, first) with actual core macros (newline, reverse, setvar, getvar, addvar, roll) - Use testSetup.awaitST helper from utils.js for beforeEach setup - Increase test timeout from 10s to 20s due to additional setup requirements - Remove registerPrototypeMacros import and registration logic from evaluateWithEngine - Add page.waitForTimeout(1000) to ensure macros are fully initialize - Remove now obsolete MacroBuiltins.js * Fix audit in tests * Rename frontend test utils file to frontent-test-utils.js and update imports - Rename tests/frontend/utils.js to tests/frontend/frontent-test-utils.js - Update testSetup imports across MacroEngine, MacroLexer, MacroParser, and MacroRegistry e2e tests * Refactor MacroRegistry for improved readability and consistency - Reorder MacroDefinition typedef fields to match registration order (handler moved to end) - Add inline JSDoc comments to MacroDefinitionOptions properties for clarity - Simplify name validation logic in unregisterMacro, hasMacro, and getMacro methods using early returns - Extract argument validation logic into dedicated isArgsValid helper function - Refactor executeMacro to be async and use Promise.resolve for consistent promise handling * Add returns field to MacroDefinitionOptions for documenting macro return values - Add returns property to MacroDefinitionOptions typedef with string type and null default - Include returns field in MacroDefinition typedef - Store returns value in macro definition object for documentation purposes * Add error handling to macro execution and refactor core macro handlers - Add catch handler in MacroRegistry.executeMacro to log errors and return empty string on handler failures - Update macro descriptions to use "index" instead of "ID" for message position macros - Simplify reverse macro description and remove redundant null coalescing - Add strictArgs: false to comment macro to ensure it's always removed - Enhance time macro description with UTC offset examples - Remove unnecessary null check * Refactor instruct-macros.js for consistency and reduce code duplication - Rename enabled helper to instEnabled and add sysEnabled helper for clarity - Consolidate defaultSystemPrompt, instructSystem, and instructSystemPrompt into single registerSimple call - Simplify systemPrompt handler by removing unnecessary null coalescing and intermediate variables - Convert chatSeparator and chatStart macros to use registerSimple helper - Fix JSDoc comment formatting for registerSimple helper function * Add normalize helper to MacroExecutionContext and refactor variable macros for consistency - Add normalizeMacroResult method to MacroEngine for converting macro results to strings - Include normalize function in MacroExecutionContext for handler use - Update normalizeMacroResult to handle arrays explicitly alongside objects - Add returns field to variable macros that produce side-effects only - Refactor variable macro handlers to use destructuring and normalize helper - Remove redundant null checks an * chore: Remove duplicate getglobalvar macro registration from variable-macros.js - Remove redundant getglobalvar macro definition (already registered earlier in the file) - Import MacroEngine in MacroRegistry for normalizeMacroResult access - Add fallback binding for normalize in MacroExecutionContext when not provided by caller - Update executeMacro to use executionContext.normalize instead of standalone normalizeMacroResult - Remove normalizeMacroResult helper function from MacroRegistry (now handled by MacroEngine) * Add core environment macros for names, character fields, system info, and deterministic pick - Add lastGenerationType tracking with event listeners for GENERATION_STARTED and CHAT_CHANGED - Add ensureLastGenerationTypeTracking helper to initialize event listeners once - Register name macros: user, char, group, groupNotMuted, notChar, charIfNotGroup - Register character card field macros: charPrompt, charInstruction, description, personality, scenario, persona, mesExamplesRaw, charDepthPrompt, cre * Refactor core macros into separate modules by category (env, state, chat, time) - Move name and character card field macros to new env-macros.js module - Move system/device/runtime state macros to new state-macros.js module - Move chat inspection macros (lastMessage, lastMessageId, etc.) to new chat-macros.js module - Move time/date macros (time, date, weekday, isotime, etc.) to new time-macros.js module - Remove lastGenerationType tracking logic and helper functions from core-macros.js (moved to state * Reorganize macro system into engine and definitions directories - Move macro engine components (MacroEngine, MacroRegistry, MacroLexer, MacroParser, MacroCstWalker) to macros/engine/ subdirectory - Move macro definition modules (core-macros, env-macros, state-macros, chat-macros, time-macros, variable-macros, instruct-macros) to macros/definitions/ subdirectory - Create macro-system.js as central entry point that exports engine singletons and initRegisterMacros function - Refactor variable-macros.js to use S * Export getGeneratingModel function and add MacroEnvBuilder to macro system exports - Export getGeneratingModel function from script.js for external use - Import MacroEnvBuilder in macro-system.js - Add envBuilder singleton to macros export object alongside existing engine components * Extract MacroEnv typedefs into separate MacroEnv.types.js file - Create MacroEnv.types.js with MacroEnv, MacroEnvNames, MacroEnvCharacter, and MacroEnvSystem typedefs - Remove MacroEnv typedef definitions from MacroRegistry.js - Update MacroRegistry.js, MacroEngine.js, MacroEnvBuilder.js, and env-macros.js to import MacroEnv from MacroEnv.types.js - Change MacroEngine.evaluate env parameter type from any to optional MacroEnv * Add comprehensive e2e tests for MacroEnvBuilder - Create MacroEnvBuilder.e2e.js with 13 test cases validating environment construction - Test name override precedence (overrides vs global fallback) - Test character field population based on replaceCharacterCard flag - Test original value one-shot helper function behavior - Test group override string propagation to group/group * Add substituteParamsAsync function with experimental macro engine support - Create substituteParamsAsync function in script.js as async alternative to substituteParams - Use object destructuring pattern for function parameters following RO-RO convention - Add experimental_macro_engine flag to power_user settings for feature gating - Add MacroEnvFunctions typedef with original and postProcess function types to MacroEnv.types.js - Add functions property to MacroEnv typedef for one-shot helpers an * Add dynamic macro support to MacroEngine with environment-based override and postProcess execution - Add defOverride parameter to MacroRegistry.executeMacro for temporary macro definitions - Check env.dynamicMacros in MacroEngine#resolveMacro and create temporary macro definition when found - Set strictArgs to true for dynamic macros to fail if called with arguments - Execute env.functions.postProcess on macro results in MacroEngine#resolveMacro with error handling - Update MacroEnv typedef to make * Refactor macro handlers to assume non-null env and remove optional chaining - Remove optional chaining (?.) from env property access in env-macros.js and instruct-macros.js - Change MacroExecutionContext env property from optional to required in MacroRegistry.js - Update executeMacro context parameter from optional to required - Simplify original macro handler to call env.functions.original() directly without try-catch - Update MacroHandler typedef to use arrow function syntax for consistency - Remove redundant null checks an * Refactor MacroRegistry.executeMacro to accept MacroCall object and make MacroExecutionContext properties non-optional - Change executeMacro to accept MacroCall object instead of separate name and context parameters - Move normalize function from context parameter to options object in executeMacro - Construct MacroExecutionContext from MacroCall properties within executeMacro - Set namedArgs to null in executionContext (currently unused) - Update MacroEngine.resolveMacro to pass MacroCall directly * Remove normalize parameter from MacroRegistry.executeMacro and bind normalizeMacroResult directly in execution context - Remove normalize parameter from executeMacro options object in MacroRegistry.js - Bind MacroEngine.normalizeMacroResult directly in executionContext instead of accepting override - Remove normalize option from MacroEngine.resolveMacro call to executeMacro - Add missing name fields (group, groupNotMuted, notChar) to MacroEnvBuilder default env object * Make MacroEnvRawContext properties optional and change original function from required to optional in MacroEnvFunctions - Change MacroEnvRawContext properties from required to optional with null defaults - Change original function from required to optional in MacroEnvFunctions typedef - Remove original function from default env object in MacroEnvBuilder (only include when provided) * Refactor MacroEnvBuilder tests to use optional properties and add macro arity validation tests - Remove explicit undefined/false assignments from MacroEnvRawContext test objects (now optional with defaults) - Rename additionalMacro to dynamicMacros in MacroEnvBuilder tests and update property access from env.extra to env.dynamicMacros - Move original function from env.extra to env.functions in test assertions - Remove test for additionalMacro overriding original helper (no longer applicable with * Improve macro error handling with dedicated diagnostics and runtime error propagation - Import and use logMacroInternalError and logMacroRuntimeWarning from MacroDiagnostics in MacroEngine - Wrap MacroRegistry.executeMacro call in try-catch to distinguish runtime vs internal errors - Nest postProcess execution in inner try-catch with dedicated error logging - Return raw macro syntax on execution failure instead of empty string - Replace console.warn with logMacroRuntimeWarning for argument count * Add macro argument type validation with positional argument definitions and runtime type checking - Add MacroArgType and MacroPositionalArgDef typedefs for argument metadata - Change requiredArgs option to accept number or MacroPositionalArgDef[] array - Add requiredArgDefs property to MacroDefinition to store normalized argument definitions - Validate requiredArgs array elements during macro registration (name, description, type fields) - Generate default argument definitions when requiredArgs is a * Add e2e tests for macro type validation and dynamic macro strict arity enforcement - Add test verifying strict typed macros fail resolution when argument type is invalid - Add test verifying non-strict typed macros execute with invalid types but log warnings - Add test verifying dynamic macros reject arguments due to strictArgs enforcement - Capture and assert runtime warning messages for type validation and arity violations - Register test macros with integer type requirements and varying strict * Add mesExamples macro with instruct mode formatting support - Register mesExamples macro in env-macros.js to format dialogue examples - Import parseMesExamples, main_api, power_user, and formatInstructModeExamples - Check instruct mode enabled state and main_api to determine formatting path - Parse raw examples using parseMesExamples with instruct mode flag - Return empty string when raw examples are missing or parsed result is empty - Format examples using formatInstructModeExamples when instruct mode is active * Add URL navigation wait in test setup and increase Playwright worker count to 4 - Add waitForURL check after user selection in awaitST to ensure navigation completes before preloader check - Increase Playwright workers from 1 to 4 for parallel test execution * Add e2e tests for multi-line macro arguments and comment macro functionality - Add test verifying reverse macro handles multi-line arguments with newline characters - Add test verifying comment macro removes single-line comments with simple body - Add test verifying comment macro accepts non-word characters immediately after // - Add test verifying comment macro ignores additional // sequences inside comment body - Add test verifying comment macro supports multi-line comment bodies * Standardize JSDoc type annotations to use explicit null defaults and union types instead of nullable shorthand * Remove individual test timeout configurations from frontend macro test files * Add positional argument definitions with sample values and descriptions to core, env, and time macros - Add sampleValue field to MacroPositionalArgDef typedef (optional string) - Replace numeric requiredArgs with positional argument definition arrays in roll, banned, outlet, datetimeformat, and timeDiff macros - Include name, sampleValue, description, and type fields for each positional argument - Update timeDiff description to clarify absolute difference calculation - Remove unnecessary blank lines in * Remove async/await from macro engine evaluation and convert all macro handlers to synchronous execution Makes me sad, but such is life * Refactor substituteParams to use options object signature with backward compatibility for legacy positional arguments - Rename original substituteParams to substituteParamsLegacy with unchanged positional argument signature - Rename substituteParamsNew to substituteParams as the new primary function - Add automatic detection and routing of legacy positional argument calls to substituteParamsLegacy - Update substituteParamsExtended to use new options object signature and mark as deprecated * forgor * fix missing import, and package-lock, finally. Maybe. * Add experimental macro engine toggle to UI settings - Add experimental_macro_engine setting to default settings.json - Move experimental_macro_engine property to correct position in power_user object (with other experimental settings) - Add checkbox UI control in settings panel with flask icon and tooltip explaining nested macro resolution and logical replacement order - Wire up checkbox event handler to save experimental_macro_engine setting - Load experimental_macro_engine state on settings initialization * Refactor macro pre/post-processing from parser to engine and improve error handling - Move legacy macro pre-processing (time_UTC format) from MacroParser to MacroEngine #runPreProcessors - Move trim macro post-processing to MacroEngine #runPostProcessors to handle cross-boundary behavior - Remove MacroLexer import from MacroEngine (now handled by MacroParser.parseDocument) - Update MacroParser.parseDocument to return separate lexingErrors and parserErrors arrays * Add {{trim}} macro placeholder that defers to post-processing for cross-boundary whitespace handling * Add eslint-plugin-playwright * Add logMacroSyntaxWarning function for structured lexer/parser error reporting with compact human-readable payload * Small code review fixes - import event types from events.js - Add chained fallback to {{input}}, just to be safe - switched variables.js to export-per-method - minor text adjustments in registered macro docs * fix lexer not capturing linebreaks correctly & simplify plaintext token - Replace alternation-based pattern with negated character class approach - Use `(?:[^{]|\{(?!\{))+` to match non-brace chars or single braces not followed by another brace - Add unicode flag for consistency - Update comment to clarify intent: consume anything that is not the start of a macro '{{' * Skip macro processing on char/group fields for env build (performance) - Add returnRaw parameter to getCharacterCardFields to optionally return raw values without baseChatReplace - Add returnRaw parameter to getGroupCharacterCards for consistent raw value handling - Replace direct baseChatReplace calls with conditional transform function based on returnRaw flag - Apply collapseNewlines when returnRaw is true and collapse_newlines setting is enabled - Update MacroEnvBuilder to use the returnRaw as true * Fix lexer failing to handle literal '{' before macro openers - Add PlaintextOpenBrace token to lexer with pattern `/\{(?=\{\{)/` to match single '{' immediately before '{{' - Update lexer mode definition to consume PlaintextOpenBrace before attempting macro start - Update parser document rule to handle PlaintextOpenBrace as alternative to Plaintext - Update MacroCstWalker to collect both Plaintext and Plaintext.OpenBrace tokens when building document items * Add legacy non-curly marker pre-processing (<USER>, <BOT>, <CHAR>, <GROUP>, <CHARIFNOTGROUP>) - Add pre-processing step in MacroEngine to rewrite legacy angle-bracket markers into their curly-brace macro equivalents - Map <USER> → {{user}}, <BOT> → {{char}}, <CHAR> → {{char}}, <GROUP> → {{group}}, <CHARIFNOTGROUP> → {{charIfNotGroup}} - Add e2e tests verifying legacy marker resolution through the engine pipeline - Tests cover <USER>, <BOT>/<CHAR>, and <GROUP>/<CHARIFNOTGROUP> markers * Add env.content to MacroEnv and use it for deterministic {{pick}} hashing - Add content property to MacroEnv type definition and MacroEnvBuilder to expose the full original input string - Update {{pick}} macro handler to use env.content hash instead of rawListString hash for seed generation - Ensures deterministic behavior when the same prompt position contains different list items across evaluations - Prevents {{pick}} from returning different values when nested macros resolve to different intermediate * add e2e tests for env.content exposure and deterministic {{pick}} behavior - Rename getChatIdHashCore → getChatIdHash in core-macros.js for consistency - Add e2e test verifying env.content is exposed to macro handlers - Add e2e test confirming {{pick}} returns stable results for same chat and content - Tests verify deterministic behavior by comparing multiple evaluations with fixed chat_id_hash * Rename chat macro helper functions to remove 'Core' suffix for consistency * Add MacrosParser deprecation warnings and bridge to new macro engine when experimental flag enabled - Import macroSystem and power_user for experimental macro engine integration - Add @deprecated JSDoc tag to MacrosParser class - Add #logDeprecated helper to warn about deprecated MacrosParser methods - Add #registerMacroInNewEngine to bridge legacy macro registrations into new engine - Add #unregisterMacroInNewEngine to bridge legacy macro unregistrations - Log deprecation warnings in get, has, register * Fix lint in tests * Add comprehensive bracket handling tests and improve macro lexer/parser resilience to invalid syntax - Add logMacroGeneralError function for non-macro-specific error logging - Add CST validation check in MacroEngine to return original input if parser produces invalid CST - Wrap MacroCstWalker.evaluateDocument in try-catch to gracefully handle evaluation failures - Update MacroLexer Unknown token pattern to capture single closing braces not followed by another closing brace - Add fallback mode exit * Add error recovery for incomplete macros by flattening them to plaintext while preserving nested complete macros - Enable Chevrotain error recovery in MacroParser constructor - Add #isRecoveryToken helper to detect tokens inserted during error recovery - Add #flattenIncompleteMacro to recursively convert incomplete macro nodes into plaintext items - Update #collectDocumentItems to detect recovery-inserted Macro.End tokens and flatten incomplete macros - Simplify plaintext token collection to use * Add e2e test verifying nested macros resolve even when outer macro has invalid argument count * Remove unused macro CST node caching mechanism from MacroCstWalker * Add JSDoc type re-exports and register shorthand to macro-system.js for improved DX - Re-export commonly used JSDoc types from MacroRegistry and MacroEnv modules for easier consumption by external code - Add macros.register shorthand function bound to MacroRegistry.registerMacro for convenient macro registration - Includes MacroDefinitionOptions, MacroHandler, MacroEnv, and related type definitions * Migrate legacy macro registrations to new macro system API across multiple modules - Replace MacrosParser.registerMacro calls with macros.register using object-based configuration - Update imports from './macros.js' to './macros/macro-system.js' - Extract macro registration into dedicated registerAuthorsNoteMacros function in authors-note.js - Add descriptions to all macro registrations for better documentation - Update MacrosParser iterator to yield from new registry when experimental engine is enabled * Add MacroCategory to all macro registrations across codebase for improved organization and discoverability - Import MacroCategory from macro-system.js in authors-note.js, memory/index.js, stable-diffusion/index.js, and macros.js - Add category property to all macro registrations using appropriate MacroCategory values - Assign 'legacy' category to MacrosParser auto-registered macros - Categorize macros across chat, character, prompts, utility, random, state, time, and names categories * fix lint * Add MacroBrowser UI component and integrate macro help system into chat interface - Add implementation of MacroBrowser - Add macros.css stylesheet link to index.html - Update core-macros.js ban macro to return empty string instead of 'Empty string' description - Add sampleValue property to positional argument definitions in MacroRegistry - Generate default sampleValue for numeric requiredArgs using 'arg{n}' pattern - Set default description to '<no description>' and returns to '<empty string>' when empty * Reorder JSDoc typedef declarations in MacroRegistry.js to have the most relevant first * fix missing category in legacy initMacros * Add displayOverride and exampleUsage properties to macro registration system - Add displayOverride property to MacroDefinitionOptions for custom signature display - Add exampleUsage property to MacroDefinitionOptions for documentation examples - Update MacroRegistry.registerMacro to validate and process displayOverride and exampleUsage - Add logMacroRegisterWarning function to MacroDiagnostics for registration-time warnings - Auto-wrap displayOverride and exampleUsage in curly braces if missing * Add error handling to MacroRegistry.registerMacro and logMacroRegisterError diagnostic function - Add logMacroRegisterError function to MacroDiagnostics for registration failures - Wrap MacroRegistry.registerMacro body in try-catch to handle registration errors gracefully - Change registerMacro return type from MacroDefinition to MacroDefinition|null - Log registration errors and return null instead of throwing, preventing macro registration failures from breaking the application * Optimize character card field access with lazy evaluation to improve macro execution performance * Add brace unescaping to MacroEngine post-processing to support literal curly braces in macro output - Add regex replacement to unescape \{ and \} to { and } after macro execution - Allows users to output literal braces by escaping them with backslashes - Escaped sequences like \{\{ don't match MacroStart pattern and pass through as plain text * Add alias system to macro registry with UI support for displaying and navigating macro aliases - Add `aliases` property to MacroDefinitionOptions for defining alternative macro names - Add `aliasOf` and `aliasVisible` properties to MacroDefinition to track alias relationships - Update MacroRegistry.registerMacro to create alias entries pointing to primary definitions - Add `getPrimaryMacro` method to retrieve primary definition from alias names - Add filtering options to `getAllMacros` to exclude aliases * Strip curly braces from MacroBrowser search query to match macro name format in search definitions * Add enhanced macro autocomplete with argument hints and context-aware suggestions - Add comprehensive CSS styling for enhanced macro autocomplete items with flex layout - Add argument hint banner styling with gradient background and border - Add current argument highlighting in details panel - Export formatMacroSignature, createSourceIndicator, createAliasIndicator, and createTypeBadge from MacroBrowser for reuse * Fix macro autocomplete not showing details/arguments because of trailing colons * Change macro details CSS selectors from `.macroBrowser` to `.macro-details` for better reusability and update autocomplete to use macro enum icon - Replace all `.macroBrowser` selectors with `.macro-details` in macros.css to allow macro details panel styling to work outside MacroBrowser context - Change autocomplete option icon from hardcoded '{}' to `enumIcons.macro` for consistency - Remove redundant `showCategory: false` option from renderMacroDetails call in autocomplete (now handled by default * Add MacroArgType enum to replace string literal type union for macro argument types - Add MacroArgType enum with STRING, INTEGER, NUMBER, and BOOLEAN values - Replace MacroArgType typedef string literal union with enum reference - Export MacroArgType from macro-system.js alongside MacroCategory - Remove MacroArgType typedef re-export (now an enum, not a type) * Add support for multiple argument types in macro definitions - Update createTypeBadge to handle both single type and array of types, displaying as "type1 | type2" with tooltip - Add JSDoc comments to MacroArgType enum values explaining each type - Update MacroArgDefinition typedef to allow type property to be single MacroArgType or array - Update MacroRegistry.registerMacro to validate array of types and default empty arrays to 'string' - Update validateArgTypes to check if argument value matches * Add returnType property to macro definitions with automatic type badge display in macro details panel - Rename MacroArgType enum to MacroValueType to reflect dual use for arguments and return types - Add returnType property to MacroDefinitionOptions (defaults to MacroValueType.STRING) - Add returnType validation in MacroRegistry.registerMacro to ensure valid type values - Update renderMacroDetails to always show Returns section with type badge - Add macro-returns-content CSS class with flex layout for type * Add detailed argument definitions, return types, and example usage to all variable macros - Replace numeric requiredArgs with detailed argument definition objects including name, type, and description - Add returnType property to all macros that return values (inc/dec/get variants) - Add returns property descriptions to all macros - Add exampleUsage arrays demonstrating typical usage patterns for each macro - Apply changes consistently * Add returns descriptions and return types to core macro definitions with improved documentation * Add returns descriptions, display overrides, and example usage to time macros with improved documentation * fix lint * Add returns descriptions and return types to chat, environment, instruct, and state macros with improved documentation * Add missing properties to dynamic macro definition override to match MacroDefinitionOptions structure * Replace console logging with MacroDiagnostics logging in MacroEnvBuilder and MacroRegistry * Add support for array-based argument types in macro autocomplete with union type display and tooltip * Rename requiredArgs to unnamedArgs and add support for optional unnamed arguments with bracket notation in macro signatures and hints * Add optional offset argument definition to time macro with type, sample value, and description * Simplify example usage for random and pick macros by removing surrounding context text * Add default value display for optional macro arguments in autocomplete and browser documentation * Fix macro args defaultValue not being converted into the normalized values for register and display * Allow STscript macro auto completion to still show up when typing closing braces * Add space macro with optional count argument for inserting multiple spaces * Add optional count argument to newline macro for inserting multiple newlines with default value of 1 * Remove MutationObserver and CTRL+F keyboard event handling from MacroBrowser * fix lint * Register `{{summary}}` macro for both old and new macro engines based on experimental flag * Register `{{charPrefix}}` and `{{charNegativePrefix}}` macros for both old and new macro engines based on experimental flag * Update hidden alias badge text to indicate deprecation status * Simplify macro name validation and error handling in MacroRegistry Remove redundant `macroName` variable by normalizing `name` parameter early and reusing it throughout the registration flow. Consolidate trim checks in validation condition. * Remove exp macro engine flag checks from auto complete and help, and remove legacy macro template Move macro registration to always use new engine regardless of experimental flag. Remove conditional logic for `experimental_macro_engine` in MacroBrowser, system messages, and slash command parser. Delete legacy `macros.html` template and associated static macro help generation. Always use MacroBrowser for macro documentation display. * Rename macros to camelCase and add backward-compatible aliases Rename `description`, `personality`, `scenario`, `creatorNotes` to `charDescription`, `charPersonality`, `charScenario`, `charCreatorNotes` respectively. Rename `idle_duration` to `idleDuration`. Add old names as aliases for backward compatibility. Add `comment` as visible alias for `//` macro. Mark `idle_duration` alias as hidden. * fix `random` and `pick` macros by using list parameter directly instead of raw string Remove `raw` parameter from `random` and `pick` macro handlers. Simplify legacy comma-separated list handling by using `list[0]` directly instead of `rawListString`. Rename `items` variable to `list` in `pick` macro for consistency. * fix `random` and `pick` not handling all colon-separated lists as before Extract `readSingleArgsRandomList` helper function to handle legacy comma-separated and double-colon list parsing. Reuse this helper in both `random` and `pick` macro handlers to eliminate duplicated list parsing logic. --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> Co-authored-by: bmen25124 <bmen25124@gmail.com>

6f8b6b098e7f214bb8bed94b3c2bc4aa38d5b8e6

Wolfsblvt <wolfsblvt@gmail.com>

Signed
45 files changed, +8506 -276Ignore whitespace
.github/readme.md+1 -0
@@ -79,6 +79,7 @@ GNU Affero General Public License for more details.**
79* Portions of CncAnon's TavernAITurbo mod used with permission79* Portions of CncAnon's TavernAITurbo mod used with permission
80* Visual Novel Mode inspired by the work of PepperTaco (<https://github.com/peppertaco/Tavern/>)80* Visual Novel Mode inspired by the work of PepperTaco (<https://github.com/peppertaco/Tavern/>)
81* Noto Sans font by Google (OFL license)81* Noto Sans font by Google (OFL license)
82* Lexer/Parser by Chevrotain (Apache-2.0 license) <https://github.com/chevrotain/chevrotain>
82* Icon theme by Font Awesome <https://fontawesome.com> (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)83* Icon theme by Font Awesome <https://fontawesome.com> (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
83* Default content by @OtisAlejandro (Seraphina character and lorebook) and @kallmeflocc (10K Discord Users Celebratory Background)84* Default content by @OtisAlejandro (Seraphina character and lorebook) and @kallmeflocc (10K Discord Users Celebratory Background)
84* Docker guide by [@mrguymiah](https://github.com/mrguymiah) and [@Bronya-Rand](https://github.com/Bronya-Rand)85* Docker guide by [@mrguymiah](https://github.com/mrguymiah) and [@Bronya-Rand](https://github.com/Bronya-Rand)
default/content/settings.json+1 -0
@@ -191,6 +191,7 @@
191 "custom_stopping_strings_macro": true,191 "custom_stopping_strings_macro": true,
192 "fuzzy_search": true,192 "fuzzy_search": true,
193 "encode_tags": false,193 "encode_tags": false,
194 "experimental_macro_engine": false,
194 "enableLabMode": false,195 "enableLabMode": false,
195 "enableZenSliders": false,196 "enableZenSliders": false,
196 "ui_mode": 1,197 "ui_mode": 1,
package-lock.json+61 -0
@@ -45,6 +45,7 @@
45 "bowser": "^2.12.1",45 "bowser": "^2.12.1",
46 "bytes": "^3.1.2",46 "bytes": "^3.1.2",
47 "chalk": "^5.6.0",47 "chalk": "^5.6.0",
48 "chevrotain": "^11.0.3",
48 "command-exists": "^1.2.9",49 "command-exists": "^1.2.9",
49 "compression": "^1.8.1",50 "compression": "^1.8.1",
50 "cookie-parser": "^1.4.6",51 "cookie-parser": "^1.4.6",
@@ -105,6 +106,7 @@
105 "sillytavern": "src/server-global.js"106 "sillytavern": "src/server-global.js"
106 },107 },
107 "devDependencies": {108 "devDependencies": {
109 "@chevrotain/types": "^11.0.3",
108 "@types/archiver": "^6.0.3",110 "@types/archiver": "^6.0.3",
109 "@types/bytes": "^3.1.5",111 "@types/bytes": "^3.1.5",
110 "@types/command-exists": "^1.2.3",112 "@types/command-exists": "^1.2.3",
@@ -171,6 +173,45 @@
171 "integrity": "sha512-KlmTftToTtmb6aLVdne4NluS+POWputPF5J8v25UN/EQS+K9vahWEIe1NPRSFqBQclObkqHaj7JOnFrmnSm5MA==",173 "integrity": "sha512-KlmTftToTtmb6aLVdne4NluS+POWputPF5J8v25UN/EQS+K9vahWEIe1NPRSFqBQclObkqHaj7JOnFrmnSm5MA==",
172 "license": "Apache-2.0"174 "license": "Apache-2.0"
173 },175 },
176 "node_modules/@chevrotain/cst-dts-gen": {
177 "version": "11.0.3",
178 "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
179 "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
180 "license": "Apache-2.0",
181 "dependencies": {
182 "@chevrotain/gast": "11.0.3",
183 "@chevrotain/types": "11.0.3",
184 "lodash-es": "4.17.21"
185 }
186 },
187 "node_modules/@chevrotain/gast": {
188 "version": "11.0.3",
189 "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
190 "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
191 "license": "Apache-2.0",
192 "dependencies": {
193 "@chevrotain/types": "11.0.3",
194 "lodash-es": "4.17.21"
195 }
196 },
197 "node_modules/@chevrotain/regexp-to-ast": {
198 "version": "11.0.3",
199 "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
200 "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
201 "license": "Apache-2.0"
202 },
203 "node_modules/@chevrotain/types": {
204 "version": "11.0.3",
205 "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
206 "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
207 "license": "Apache-2.0"
208 },
209 "node_modules/@chevrotain/utils": {
210 "version": "11.0.3",
211 "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
212 "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
213 "license": "Apache-2.0"
214 },
174 "node_modules/@es-joy/jsdoccomment": {215 "node_modules/@es-joy/jsdoccomment": {
175 "version": "0.46.0",216 "version": "0.46.0",
176 "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.46.0.tgz",217 "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.46.0.tgz",
@@ -3381,6 +3422,20 @@
3381 "url": "https://github.com/sponsors/fb55"3422 "url": "https://github.com/sponsors/fb55"
3382 }3423 }
3383 },3424 },
3425 "node_modules/chevrotain": {
3426 "version": "11.0.3",
3427 "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
3428 "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
3429 "license": "Apache-2.0",
3430 "dependencies": {
3431 "@chevrotain/cst-dts-gen": "11.0.3",
3432 "@chevrotain/gast": "11.0.3",
3433 "@chevrotain/regexp-to-ast": "11.0.3",
3434 "@chevrotain/types": "11.0.3",
3435 "@chevrotain/utils": "11.0.3",
3436 "lodash-es": "4.17.21"
3437 }
3438 },
3384 "node_modules/chrome-trace-event": {3439 "node_modules/chrome-trace-event": {
3385 "version": "1.0.4",3440 "version": "1.0.4",
3386 "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",3441 "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
@@ -6308,6 +6363,12 @@
6308 "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",6363 "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
6309 "license": "MIT"6364 "license": "MIT"
6310 },6365 },
6366 "node_modules/lodash-es": {
6367 "version": "4.17.21",
6368 "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
6369 "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
6370 "license": "MIT"
6371 },
6311 "node_modules/lodash.get": {6372 "node_modules/lodash.get": {
6312 "version": "4.4.2",6373 "version": "4.4.2",
6313 "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",6374 "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
package.json+2 -0
@@ -35,6 +35,7 @@
35 "bowser": "^2.12.1",35 "bowser": "^2.12.1",
36 "bytes": "^3.1.2",36 "bytes": "^3.1.2",
37 "chalk": "^5.6.0",37 "chalk": "^5.6.0",
38 "chevrotain": "^11.0.3",
38 "command-exists": "^1.2.9",39 "command-exists": "^1.2.9",
39 "compression": "^1.8.1",40 "compression": "^1.8.1",
40 "cookie-parser": "^1.4.6",41 "cookie-parser": "^1.4.6",
@@ -136,6 +137,7 @@
136 },137 },
137 "main": "server.js",138 "main": "server.js",
138 "devDependencies": {139 "devDependencies": {
140 "@chevrotain/types": "^11.0.3",
139 "@types/archiver": "^6.0.3",141 "@types/archiver": "^6.0.3",
140 "@types/bytes": "^3.1.5",142 "@types/bytes": "^3.1.5",
141 "@types/command-exists": "^1.2.3",143 "@types/command-exists": "^1.2.3",
public/css/macros.css+515 -0
@@ -0,0 +1,515 @@
1/**
2 * Macro System Styles
3 * Styles for the macro browser, documentation, and related UI components.
4 */
5
6/* =============================================================================
7 MACRO BROWSER
8 Dynamic documentation browser for macros, similar to SlashCommandBrowser.
9 ============================================================================= */
10
11.macroBrowser {
12 display: flex;
13 flex-direction: column;
14 gap: 0.5em;
15}
16
17/* -----------------------------------------------------------------------------
18 Toolbar (Search + Sort)
19 ----------------------------------------------------------------------------- */
20
21.macroBrowser .macro-toolbar {
22 display: flex;
23 gap: 1em;
24 align-items: center;
25 flex-wrap: wrap;
26}
27
28.macroBrowser .macro-search-label {
29 flex: 1 1 auto;
30 display: flex;
31 gap: 0.5em;
32 align-items: center;
33 min-width: 200px;
34 white-space: nowrap;
35}
36
37.macroBrowser .macro-search-input {
38 flex: 1 1 auto;
39 min-width: 100px;
40}
41
42.macroBrowser .macro-sort-btn {
43 flex: 0 0 auto;
44 white-space: nowrap;
45}
46
47.macroBrowser .macro-sort-btn.active {
48 background-color: var(--SmartThemeQuoteColor);
49}
50
51/* -----------------------------------------------------------------------------
52 Container (List + Details panels)
53 ----------------------------------------------------------------------------- */
54
55.macroBrowser .macro-container {
56 display: flex;
57 gap: 1em;
58 align-items: flex-start;
59 container-type: inline-size;
60}
61
62.macroBrowser .macro-list-panel {
63 flex: 1 1 60%;
64 display: flex;
65 flex-direction: column;
66 gap: 2px;
67 max-height: 60vh;
68 overflow-y: auto;
69}
70
71.macroBrowser .macro-details-panel {
72 flex: 0 0 40%;
73 position: sticky;
74 top: 0;
75 max-height: 60vh;
76 overflow-y: auto;
77 background: var(--SmartThemeBlurTintColor);
78 border-radius: 10px;
79 padding: 1em;
80}
81
82.macroBrowser .macro-details-placeholder {
83 opacity: 0.6;
84 text-align: center;
85 padding: 2em;
86}
87
88/* -----------------------------------------------------------------------------
89 Category Headers
90 ----------------------------------------------------------------------------- */
91
92.macroBrowser .macro-category-header {
93 font-weight: bold;
94 padding: 0.75em 0.5em 0.25em;
95 margin-top: 0.5em;
96 border-bottom: 1px solid var(--SmartThemeBorderColor);
97 border-radius: 10px 10px 0 0;
98 color: var(--SmartThemeQuoteColor);
99 position: sticky;
100 top: 0;
101 background: var(--SmartThemeChatTintColor);
102 z-index: 1;
103}
104
105.macroBrowser .macro-category-header:first-child {
106 margin-top: 0;
107}
108
109.macroBrowser .macro-category-header.isFiltered {
110 display: none;
111}
112
113/* -----------------------------------------------------------------------------
114 Macro List Items
115 Layout: [signature] [description (shrinks)] [source icon]
116 ----------------------------------------------------------------------------- */
117
118.macroBrowser .macro-item {
119 display: flex;
120 align-items: center;
121 gap: 0.5em;
122 padding: 0.5em;
123 border-radius: 4px;
124 cursor: pointer;
125 transition: background-color 0.15s;
126 min-width: 0; /* Allow children to shrink */
127}
128
129.macroBrowser .macro-item:hover {
130 background: var(--black30a);
131}
132
133.macroBrowser .macro-item.selected {
134 background: var(--SmartThemeQuoteColor);
135}
136
137.macroBrowser .macro-item.isFiltered {
138 display: none;
139}
140
141.macroBrowser .macro-signature {
142 padding: 0.2em 0;
143 font-family: var(--monoFontFamily);
144 font-size: 0.9em;
145 color: var(--SmartThemeQuoteColor);
146 flex: 0 1 auto; /* Can shrink, but prefers not to */
147 overflow: hidden;
148 text-overflow: ellipsis;
149 white-space: nowrap;
150 min-width: 3em; /* Minimum before fully hidden */
151}
152
153.macroBrowser .macro-desc-preview {
154 flex: 1 1 0; /* Shrinks first, starts at 0 basis */
155 opacity: 0.7;
156 font-size: 0.9em;
157 overflow: hidden;
158 text-overflow: ellipsis;
159 white-space: nowrap;
160 min-width: 0; /* Allow shrinking to nothing */
161}
162
163.macroBrowser .macro-source {
164 flex: 0 0 auto; /* Never shrinks */
165 font-size: 0.8em;
166 opacity: 0.7;
167}
168
169.macroBrowser .macro-source.isExtension.isThirdParty {
170 color: #f0a030;
171}
172
173.macroBrowser .macro-source.isExtension.isCore {
174 color: #30a0f0;
175}
176
177.macroBrowser .macro-source.isCore:not(.isExtension) {
178 color: #50c050;
179}
180
181/* -----------------------------------------------------------------------------
182 Macro Details Panel
183 ----------------------------------------------------------------------------- */
184
185.macro-details .macro-details-header {
186 display: flex;
187 align-items: flex-start;
188 justify-content: space-between;
189 gap: 0.5em;
190 margin-bottom: 0.75em;
191}
192
193.macro-details .macro-details-name {
194 font-family: var(--monoFontFamily);
195 font-size: 1.1em;
196 color: var(--SmartThemeQuoteColor);
197 background: var(--black30a);
198 padding: 0.15em 0.4em 0.3em;
199 border-radius: 4px;
200 word-break: break-all;
201 display: inline-block; /* Shrink-wrap to content */
202}
203
204.macro-details .macro-details-header > .macro-source {
205 flex: 0 0 auto;
206 font-size: 1em;
207 margin-top: 0.15em; /* Align with name top padding */
208}
209
210.macro-details .macro-category-badge {
211 display: inline-block;
212 font-size: 0.75em;
213 padding: 0.2em 0.6em;
214 border-radius: 10px;
215 background: color-mix(in srgb, var(--SmartThemeQuoteColor) 50%, transparent);
216 color: var(--SmartThemeBodyColor);
217 margin-bottom: 0.75em;
218}
219
220.macro-details .macro-details-section {
221 margin-bottom: 1em;
222}
223
224.macro-details .macro-details-label {
225 font-weight: bold;
226 font-size: 0.85em;
227 opacity: 0.8;
228 margin-bottom: 0.25em;
229 text-transform: uppercase;
230 letter-spacing: 0.05em;
231}
232
233.macro-details .macro-details-text {
234 line-height: 1.4;
235}
236
237.macro-details .macro-returns-content {
238 display: flex;
239 align-items: baseline;
240 gap: 0.5em;
241 flex-wrap: wrap;
242}
243
244/* -----------------------------------------------------------------------------
245 Arguments List (in details panel)
246 ----------------------------------------------------------------------------- */
247
248.macro-details .macro-args-list {
249 list-style: none;
250 padding: 0;
251 margin: 0;
252}
253
254.macro-details .macro-arg-item {
255 padding: 0.4em 0;
256 border-bottom: 1px solid var(--black30a);
257 display: flex;
258 flex-wrap: wrap;
259 align-items: baseline;
260 gap: 0.5em;
261}
262
263.macro-details .macro-arg-item:last-child {
264 border-bottom: none;
265}
266
267.macro-details .macro-arg-name {
268 font-family: var(--monoFontFamily);
269 font-weight: bold;
270 color: var(--SmartThemeQuoteColor);
271}
272
273.macro-details .macro-arg-type {
274 font-size: 0.75em;
275 padding: 0.1em 0.4em;
276 border-radius: 3px;
277 background: var(--black30a);
278 color: var(--SmartThemeEmColor);
279}
280
281.macro-details .macro-arg-required {
282 font-size: 0.8em;
283 color: var(--warning-color, #e8a97f);
284}
285
286.macro-details .macro-arg-desc {
287 flex: 1 1 100%;
288 font-size: 0.9em;
289 opacity: 0.8;
290}
291
292.macro-details .macro-arg-sample {
293 font-size: 0.85em;
294 opacity: 0.6;
295 font-style: italic;
296}
297
298.macro-details .macro-arg-list-info {
299 font-size: 0.85em;
300 opacity: 0.8;
301}
302
303.macro-details .macro-example-list {
304 list-style: disc;
305 padding-left: 1.5em;
306 margin: 0.25em 0;
307}
308
309.macro-details .macro-example-list li {
310 padding: 0.2em 0;
311}
312
313.macro-details .macro-example-list code {
314 font-family: var(--monoFontFamily);
315 background: var(--black30a);
316 padding: 0.1em 0.3em;
317 border-radius: 3px;
318}
319
320/* Alias indicator icon in list items */
321.macro-details .macro-alias-indicator {
322 flex: 0 0 auto;
323 font-size: 0.8em;
324 opacity: 0.6;
325 margin-left: 0.5em;
326 transform: rotate(90deg);
327}
328
329.macro-details .macro-item.isAlias .macro-signature {
330 opacity: 0.85;
331}
332
333/* Alias of indicator in details panel */
334.macro-details .macro-alias-of {
335 display: flex;
336 align-items: center;
337 gap: 0.5em;
338 margin: 0.5em 0;
339 padding: 0.4em 0.6em;
340 background: var(--black30a);
341 border-radius: 4px;
342 font-size: 0.9em;
343 opacity: 0.9;
344}
345
346.macro-details .macro-alias-of i {
347 transform: rotate(90deg);
348 font-size: 0.85em;
349 opacity: 0.7;
350}
351
352.macro-details .macro-alias-of code {
353 font-family: var(--monoFontFamily);
354 background: var(--black30a);
355 padding: 0.1em 0.4em;
356 border-radius: 3px;
357}
358
359/* Aliases list in details panel */
360.macro-details .macro-alias-list {
361 list-style: none;
362 padding: 0;
363 margin: 0.25em 0;
364 display: flex;
365 flex-wrap: wrap;
366 gap: 0.5em;
367}
368
369.macro-details .macro-alias-item {
370 display: inline-flex;
371 align-items: center;
372 gap: 0.3em;
373}
374
375.macro-details .macro-alias-item code {
376 font-family: var(--monoFontFamily);
377 background: var(--black30a);
378 padding: 0.2em 0.5em;
379 border-radius: 3px;
380}
381
382.macro-details .macro-alias-item.isHidden {
383 opacity: 0.5;
384}
385
386.macro-details .macro-alias-item.isHidden code {
387 text-decoration: line-through;
388 text-decoration-style: dashed;
389}
390
391.macro-details .macro-alias-hidden-badge {
392 font-size: 0.75em;
393 opacity: 0.7;
394 font-style: italic;
395}
396
397/* ============================================
398 Enhanced Macro Autocomplete Styles
399 ============================================ */
400
401/* Fix macro items in autocomplete list - override the grid display: contents */
402.autoComplete > .item.macro-ac-item {
403 display: flex !important;
404 flex-wrap: nowrap;
405 align-items: center;
406 gap: 0.5em;
407 grid-column: 1 / -1;
408 padding: 0.25em 0.5em;
409 min-height: 1.8em;
410}
411
412.autoComplete > .item.macro-ac-item > .type {
413 flex: 0 0 auto;
414 width: 2em;
415 text-align: center;
416}
417
418.autoComplete > .item.macro-ac-item > .specs {
419 flex: 0 0 auto;
420 max-width: 40%;
421}
422
423.autoComplete > .item.macro-ac-item > .specs > .name {
424 white-space: nowrap;
425 overflow: hidden;
426 text-overflow: ellipsis;
427}
428
429.autoComplete > .item.macro-ac-item > .stopgap {
430 flex: 0 0 0.5em;
431 display: block !important;
432}
433
434.autoComplete > .item.macro-ac-item > .help {
435 flex: 1 1 auto;
436 min-width: 0;
437 overflow: hidden;
438}
439
440.autoComplete > .item.macro-ac-item > .help > .helpContent {
441 white-space: nowrap;
442 overflow: hidden;
443 text-overflow: ellipsis;
444 display: block;
445}
446
447/* Indicator icons (alias and source) at the end */
448.autoComplete > .item.macro-ac-item > .macro-ac-indicator {
449 flex: 0 0 auto;
450 font-size: 0.85em;
451 opacity: 0.6;
452 margin-left: auto;
453}
454
455.autoComplete > .item.macro-ac-item > .macro-ac-indicator:first-of-type {
456 margin-left: auto;
457}
458
459.autoComplete > .item.macro-ac-item > .macro-ac-indicator + .macro-ac-indicator {
460 margin-left: 0.25em;
461}
462
463/* Third-party source indicator */
464.autoComplete > .item.macro-ac-item > .macro-source.isThirdParty {
465 color: #F89406;
466}
467
468/* Current argument hint banner in details */
469.macro-ac-arg-hint {
470 display: flex;
471 align-items: baseline;
472 gap: 0.5em;
473 padding: 0.5em 0.75em;
474 background: linear-gradient(90deg, var(--ac-color-selectedBackground, var(--SmartThemeQuoteColor)), transparent);
475 border-left: 3px solid var(--ac-color-matchedText, var(--SmartThemeBorderColor));
476 border-radius: 0 4px 4px 0;
477 margin-bottom: 0.5em;
478 font-size: 0.9em;
479}
480
481.macro-ac-arg-hint i {
482 color: var(--ac-color-matchedText, var(--SmartThemeBorderColor));
483 font-size: 0.8em;
484}
485
486.macro-ac-hint-type {
487 font-family: var(--monoFontFamily);
488 font-size: 0.85em;
489 padding: 0.1em 0.3em;
490 background: var(--ac-color-hoveredBackground, var(--black30a));
491 border-radius: 3px;
492 margin-left: 0.25em;
493}
494
495.macro-ac-hint-desc {
496 opacity: 0.8;
497}
498
499.macro-ac-hint-sample {
500 opacity: 0.6;
501 font-style: italic;
502}
503
504/* Details panel using MacroBrowser styles - just add autocomplete context adjustments */
505.autoComplete-details .macro-ac-details {
506 padding: 0.5em;
507}
508
509/* Highlight current argument in arguments list */
510.autoComplete-details .macro-arg-item.current {
511 background: var(--ac-color-selectedBackground, var(--SmartThemeQuoteColor));
512 padding: 0.25em 0.5em;
513 margin: 0 -0.5em;
514 border-radius: 4px;
515}
public/index.html+6 -0
@@ -42,6 +42,7 @@
42 <link rel="stylesheet" type="text/css" href="css/extensions-panel.css">42 <link rel="stylesheet" type="text/css" href="css/extensions-panel.css">
43 <link rel="stylesheet" type="text/css" href="css/select2-overrides.css">43 <link rel="stylesheet" type="text/css" href="css/select2-overrides.css">
44 <link rel="stylesheet" type="text/css" href="css/mobile-styles.css">44 <link rel="stylesheet" type="text/css" href="css/mobile-styles.css">
45 <link rel="stylesheet" type="text/css" href="css/macros.css">
45 <link rel="stylesheet" type="text/css" href="css/user.css">46 <link rel="stylesheet" type="text/css" href="css/user.css">
46 <link rel="icon" type="image/x-icon" href="favicon.ico">47 <link rel="icon" type="image/x-icon" href="favicon.ico">
47 <!-- Scripts are loaded at the end of the body to improve page load speed -->48 <!-- Scripts are loaded at the end of the body to improve page load speed -->
@@ -5258,6 +5259,11 @@
5258 <input id="encode_tags" type="checkbox" />5259 <input id="encode_tags" type="checkbox" />
5259 <small data-i18n="Show tags in responses">Show &lt;tags&gt; in responses</small>5260 <small data-i18n="Show tags in responses">Show &lt;tags&gt; in responses</small>
5260 </label>5261 </label>
5262 <label class="checkbox_label" for="experimental_macro_engine" title="Experimental new Macro Engine.&NewLine;&NewLine;Allows nested macros to be resolved correctly and has a dedicated, logical replacement order.&NewLine;The new engine is designed to cleanly replace the old regex-based macro system.">
5263 <input id="experimental_macro_engine" type="checkbox" />
5264 <small data-i18n="Experimental Macro Engine">Experimental Macro Engine</small>
5265 <i class="fa-solid fa-flask" title="Experimental feature. Currently in development to test." data-i18n="[title]Experimental feature. Currently in development to test."></i>
5266 </label>
5261 <label class="checkbox_label" for="disable_group_trimming" title="Allow AI messages in groups to contain lines spoken by other group members." data-i18n="[title]Allow AI messages in groups to contain lines spoken by other group members">5267 <label class="checkbox_label" for="disable_group_trimming" title="Allow AI messages in groups to contain lines spoken by other group members." data-i18n="[title]Allow AI messages in groups to contain lines spoken by other group members">
5262 <input id="disable_group_trimming" type="checkbox" />5268 <input id="disable_group_trimming" type="checkbox" />
5263 <small data-i18n="Relax message trim in Groups">Relax message trim in Groups</small>5269 <small data-i18n="Relax message trim in Groups">Relax message trim in Groups</small>
public/lib.js+3 -0
@@ -22,6 +22,7 @@ import morphdom from 'morphdom';
22import { toggle as slideToggle } from 'slidetoggle';22import { toggle as slideToggle } from 'slidetoggle';
23import chalk from 'chalk';23import chalk from 'chalk';
24import yaml from 'yaml';24import yaml from 'yaml';
25import * as chevrotain from 'chevrotain';
2526
26/**27/**
27 * Expose the libraries to the 'window' object.28 * Expose the libraries to the 'window' object.
@@ -100,6 +101,7 @@ export default {
100 slideToggle,101 slideToggle,
101 chalk,102 chalk,
102 yaml,103 yaml,
104 chevrotain,
103};105};
104106
105export {107export {
@@ -124,4 +126,5 @@ export {
124 slideToggle,126 slideToggle,
125 chalk,127 chalk,
126 yaml,128 yaml,
129 chevrotain,
127};130};
public/script.js+178 -56
@@ -69,7 +69,7 @@ import {
69 renameGroupChat,69 renameGroupChat,
70 importGroupChat,70 importGroupChat,
71 getGroupBlock,71 getGroupBlock,
72 getGroupCharacterCards,72 getGroupCharacterCardsLazy,
73 getGroupDepthPrompts,73 getGroupDepthPrompts,
74} from './scripts/group-chats.js';74} from './scripts/group-chats.js';
7575
@@ -187,7 +187,7 @@ import {
187import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, OVERSWIPE_BEHAVIOR, SCROLL_BEHAVIOR, SWIPE_DIRECTION, SWIPE_SOURCE, SWIPE_STATE } from './scripts/constants.js';187import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids, MEDIA_DISPLAY, MEDIA_SOURCE, MEDIA_TYPE, OVERSWIPE_BEHAVIOR, SCROLL_BEHAVIOR, SWIPE_DIRECTION, SWIPE_SOURCE, SWIPE_STATE } from './scripts/constants.js';
188188
189import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';189import { cancelDebouncedMetadataSave, doDailyExtensionUpdatesCheck, extension_settings, initExtensions, loadExtensionSettings, runGenerationInterceptors } from './scripts/extensions.js';
190import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';190import { COMMENT_NAME_DEFAULT, CONNECT_API_MAP, executeSlashCommandsOnChatInput, initDefaultSlashCommands, initSlashCommandAutoComplete, isExecutingCommandsFromChatInput, pauseScriptExecution, stopScriptExecution, UNIQUE_APIS } from './scripts/slash-commands.js';
191import {191import {
192 tag_map,192 tag_map,
193 tags,193 tags,
@@ -279,6 +279,8 @@ import { applyStreamFadeIn } from './scripts/util/stream-fadein.js';
279import { initDomHandlers } from './scripts/dom-handlers.js';279import { initDomHandlers } from './scripts/dom-handlers.js';
280import { SimpleMutex } from './scripts/util/SimpleMutex.js';280import { SimpleMutex } from './scripts/util/SimpleMutex.js';
281import { AudioPlayer } from './scripts/audio-player.js';281import { AudioPlayer } from './scripts/audio-player.js';
282import { MacroEnvBuilder } from './scripts/macros/engine/MacroEnvBuilder.js';
283import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
282import { addChatBackupsBrowser } from './scripts/chat-backups.js';284import { addChatBackupsBrowser } from './scripts/chat-backups.js';
283285
284// API OBJECT FOR EXTERNAL WIRING286// API OBJECT FOR EXTERNAL WIRING
@@ -707,6 +709,7 @@ async function firstLoadInit() {
707 initBackgrounds();709 initBackgrounds();
708 initAuthorsNote();710 initAuthorsNote();
709 await initPersonas();711 await initPersonas();
712 await initSlashCommandAutoComplete();
710 initWorldInfo();713 initWorldInfo();
711 initHorde();714 initHorde();
712 initRossMods();715 initRossMods();
@@ -2661,14 +2664,13 @@ export function scrollChatToBottom({ waitForFrame } = {}) {
2661}2664}
26622665
2663/**2666/**
2667 * @deprecated Function is not needed anymore, as the new signature of substituteParams is more flexible.
2668 *
2664 * Substitutes {{macro}} parameters in a string.2669 * Substitutes {{macro}} parameters in a string.
2665 * @param {string} content - The string to substitute parameters in.
2666 * @param {Record<string,any>} additionalMacro - Additional environment variables for substitution.
2667 * @param {(x: string) => string} [postProcessFn] - Post-processing function for each substituted macro.
2668 * @returns {string} The string with substituted parameters.2670 * @returns {string} The string with substituted parameters.
2669 */2671 */
2670export function substituteParamsExtended(content, additionalMacro = {}, postProcessFn = (x) => x) {2672export function substituteParamsExtended(content, additionalMacro = {}, postProcessFn = (x) => x) {
2671 return substituteParams(content, undefined, undefined, undefined, undefined, true, additionalMacro, postProcessFn);2673 return substituteParams(content, { dynamicMacros: additionalMacro, postProcessFn });
2672}2674}
26732675
2674/**2676/**
@@ -2683,11 +2685,24 @@ export function substituteParamsExtended(content, additionalMacro = {}, postProc
2683 * @param {(x: string) => string} [postProcessFn] - Post-processing function for each substituted macro.2685 * @param {(x: string) => string} [postProcessFn] - Post-processing function for each substituted macro.
2684 * @returns {string} The string with substituted parameters.2686 * @returns {string} The string with substituted parameters.
2685 */2687 */
2686export function substituteParams(content, _name1, _name2, _original, _group, _replaceCharacterCard = true, additionalMacro = {}, postProcessFn = (x) => x) {2688export function substituteParamsLegacy(content, _name1, _name2, _original, _group, _replaceCharacterCard = true, additionalMacro = {}, postProcessFn = (x) => x) {
2687 if (!content) {2689 if (!content) {
2688 return '';2690 return '';
2689 }2691 }
26902692
2693 // If experimental macro engine is enabled, use it. This code will be cleaned up in the future.
2694 if (power_user?.experimental_macro_engine) {
2695 return substituteParams(content, {
2696 name1Override: _name1,
2697 name2Override: _name2,
2698 original: _original,
2699 groupOverride: _group,
2700 replaceCharacterCard: _replaceCharacterCard ?? true,
2701 dynamicMacros: additionalMacro ?? {},
2702 postProcessFn: postProcessFn ?? ((x) => x),
2703 });
2704 }
2705
2691 const environment = {};2706 const environment = {};
26922707
2693 if (typeof _original === 'string') {2708 if (typeof _original === 'string') {
@@ -2787,6 +2802,55 @@ export function substituteParams(content, _name1, _name2, _original, _group, _re
2787 return evaluateMacros(content, environment, postProcessFn);2802 return evaluateMacros(content, environment, postProcessFn);
2788}2803}
27892804
2805/** @typedef {import('./scripts/macros/engine/MacroRegistry.js').MacroHandler} MacroHandler */
2806
2807/**
2808 * Substitutes {{macros}} in a string using the new macro engine.
2809 *
2810 * This will replace all registered macros and dynamic additional macros as environment context.
2811 *
2812 * @param {string} content - The string to substitute parameters in.
2813 * @param {Object} [options={}] - Options for the substitution.
2814 * @param {string} [options.name1Override] - The name of the user. Uses global name1 if not provided.
2815 * @param {string} [options.name2Override] - The name of the character. Uses global name2 if not provided.
2816 * @param {string} [options.original] - The original message for {{original}} substitution.
2817 * @param {string} [options.groupOverride] - The group members list for {{group}} substitution.
2818 * @param {boolean} [options.replaceCharacterCard=true] - Whether to replace character card macros.
2819 * @param {Record<string,string|MacroHandler>} [options.dynamicMacros={}] - Additional environment variables as dynamic macros for substitution. Registered as macro functions.
2820 * @param {(x: string) => string} [options.postProcessFn=(x) => x] - Post-processing function for each substituted macro.
2821 * @returns {string} The string with substituted parameters.
2822 */
2823export function substituteParams(content, options = {}) {
2824 if (!content) return '';
2825
2826 // Handle legacy signature calls to substituteParams
2827 // We'll simply re-route them to a temporary legacy function. In the future, we'll remove this and cleanly build the options object ourselves.
2828 const isOptionsObject = options && typeof options === 'object' && !Array.isArray(options);
2829 if (!isOptionsObject) {
2830 return substituteParamsLegacy.call(this, ...arguments);
2831 }
2832
2833 // Keep the new macro engine behind a feature switch for now
2834 if (!power_user?.experimental_macro_engine) {
2835 return substituteParamsLegacy(content, options.name1Override, options.name2Override, options.original, options.groupOverride, options.replaceCharacterCard, options.dynamicMacros, options.postProcessFn);
2836 }
2837
2838 const ctx = /** @type {import('./scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */ ({
2839 content,
2840 name1Override: options.name1Override,
2841 name2Override: options.name2Override,
2842 original: options.original,
2843 groupOverride: options.groupOverride,
2844 replaceCharacterCard: options.replaceCharacterCard ?? true,
2845 dynamicMacros: options.dynamicMacros ?? {},
2846 postProcessFn: options.postProcessFn ?? ((x) => x),
2847 });
2848
2849 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
2850 const result = MacroEngine.evaluate(content, env);
2851 return result;
2852}
2853
27902854
2791/**2855/**
2792 * Gets stopping sequences for the prompt.2856 * Gets stopping sequences for the prompt.
@@ -3110,11 +3174,7 @@ export function baseChatReplace(value, name1, name2) {
3110}3174}
31113175
3112/**3176/**
3113 * Returns the character card fields for the current character.3177 * @typedef {Object} CharacterCardFields
3114 * @param {object} [options]
3115 * @param {number} [options.chid] Optional character index
3116 *
3117 * @typedef {object} CharacterCardFields
3118 * @property {string} system System prompt3178 * @property {string} system System prompt
3119 * @property {string} mesExamples Message examples3179 * @property {string} mesExamples Message examples
3120 * @property {string} description Description3180 * @property {string} description Description
@@ -3125,57 +3185,119 @@ export function baseChatReplace(value, name1, name2) {
3125 * @property {string} version Character version3185 * @property {string} version Character version
3126 * @property {string} charDepthPrompt Character depth note3186 * @property {string} charDepthPrompt Character depth note
3127 * @property {string} creatorNotes Character creator notes3187 * @property {string} creatorNotes Character creator notes
3128 * @returns {CharacterCardFields} Character card fields
3129 */3188 */
3130export function getCharacterCardFields({ chid = null } = {}) {
3131 const currentChid = chid ?? this_chid;
3132
3133 const result = {
3134 system: '',
3135 mesExamples: '',
3136 description: '',
3137 personality: '',
3138 persona: '',
3139 scenario: '',
3140 jailbreak: '',
3141 version: '',
3142 charDepthPrompt: '',
3143 creatorNotes: '',
3144 };
3145 result.persona = baseChatReplace(power_user.persona_description?.trim(), name1, name2);
31463189
3147 const character = characters[currentChid];3190/**
31483191 * Helper to create an object with lazy, memoized getters from a map of field resolvers.
3149 if (!character) {3192 * @param {Record<string, () => string>} resolvers Map of field names to resolver functions
3150 return result;3193 * @returns {CharacterCardFields} Object with lazy getters
3194 */
3195export function createLazyFields(resolvers) {
3196 const result = /** @type {CharacterCardFields} */ ({});
3197 for (const [key, resolver] of Object.entries(resolvers)) {
3198 let cached;
3199 let resolved = false;
3200 Object.defineProperty(result, key, {
3201 get() {
3202 if (!resolved) {
3203 cached = resolver();
3204 resolved = true;
3205 }
3206 return cached;
3207 },
3208 enumerable: true,
3209 configurable: true,
3210 });
3151 }3211 }
3212 return result;
3213}
31523214
3153 const scenarioText = chat_metadata['scenario'] || character.scenario || '';3215/**
3154 const exampleDialog = chat_metadata['mes_example'] || character.mes_example || '';3216 * Returns the character card fields for the current character as lazy getters.
3155 const systemPrompt = chat_metadata['system_prompt'] || character.data?.system_prompt || '';3217 * Each field is only processed (baseChatReplace) when first accessed.
3218 * @param {Object} [options={}]
3219 * @param {number} [options.chid] Optional character index
3220 * @returns {CharacterCardFields} Character card fields with lazy evaluation
3221 */
3222export function getCharacterCardFieldsLazy({ chid = undefined } = {}) {
3223 const currentChid = chid ?? this_chid;
3224 const character = characters[currentChid];
31563225
3157 result.description = baseChatReplace(character.description?.trim(), name1, name2);3226 // For group chats, we need to check if group cards should be used
3158 result.personality = baseChatReplace(character.personality?.trim(), name1, name2);3227 const useGroupCards = selected_group && character;
3159 result.scenario = baseChatReplace(scenarioText.trim(), name1, name2);3228 const groupCardsLazy = useGroupCards ? getGroupCharacterCardsLazy(selected_group, Number(currentChid)) : null;
3160 result.mesExamples = baseChatReplace(exampleDialog.trim(), name1, name2);3229
3161 result.system = power_user.prefer_character_prompt ? baseChatReplace(systemPrompt.trim(), name1, name2) : '';3230 /** @type {Record<string, () => string>} */
3162 result.jailbreak = power_user.prefer_character_jailbreak ? baseChatReplace(character.data?.post_history_instructions?.trim(), name1, name2) : '';3231 const resolvers = {
3163 result.version = character.data?.character_version ?? '';3232 persona: () => baseChatReplace(power_user.persona_description?.trim(), name1, name2),
3164 result.charDepthPrompt = baseChatReplace(character.data?.extensions?.depth_prompt?.prompt?.trim(), name1, name2);3233 system: () => {
3165 result.creatorNotes = baseChatReplace(character.data?.creator_notes?.trim(), name1, name2);3234 if (!character) return '';
3235 const systemPrompt = chat_metadata['system_prompt'] || character.data?.system_prompt || '';
3236 return power_user.prefer_character_prompt ? baseChatReplace(systemPrompt.trim(), name1, name2) : '';
3237 },
3238 jailbreak: () => {
3239 if (!character) return '';
3240 return power_user.prefer_character_jailbreak ? baseChatReplace(character.data?.post_history_instructions?.trim(), name1, name2) : '';
3241 },
3242 version: () => character?.data?.character_version ?? '',
3243 charDepthPrompt: () => {
3244 if (!character) return '';
3245 return baseChatReplace(character.data?.extensions?.depth_prompt?.prompt?.trim(), name1, name2);
3246 },
3247 creatorNotes: () => {
3248 if (!character) return '';
3249 return baseChatReplace(character.data?.creator_notes?.trim(), name1, name2);
3250 },
3251 // These four fields may be overridden by group cards
3252 description: () => {
3253 if (groupCardsLazy) return groupCardsLazy.description;
3254 if (!character) return '';
3255 return baseChatReplace(character.description?.trim(), name1, name2);
3256 },
3257 personality: () => {
3258 if (groupCardsLazy) return groupCardsLazy.personality;
3259 if (!character) return '';
3260 return baseChatReplace(character.personality?.trim(), name1, name2);
3261 },
3262 scenario: () => {
3263 if (groupCardsLazy) return groupCardsLazy.scenario;
3264 if (!character) return '';
3265 const scenarioText = chat_metadata['scenario'] || character.scenario || '';
3266 return baseChatReplace(scenarioText.trim(), name1, name2);
3267 },
3268 mesExamples: () => {
3269 if (groupCardsLazy) return groupCardsLazy.mesExamples;
3270 if (!character) return '';
3271 const exampleDialog = chat_metadata['mes_example'] || character.mes_example || '';
3272 return baseChatReplace(exampleDialog.trim(), name1, name2);
3273 },
3274 };
31663275
3167 if (selected_group) {3276 return createLazyFields(resolvers);
3168 const groupCards = getGroupCharacterCards(selected_group, Number(currentChid));3277}
31693278
3170 if (groupCards) {3279/**
3171 result.description = groupCards.description;3280 * Returns the character card fields for the current character.
3172 result.personality = groupCards.personality;3281 * @param {Object} [options={}]
3173 result.scenario = groupCards.scenario;3282 * @param {number} [options.chid] Optional character index
3174 result.mesExamples = groupCards.mesExamples;3283 * @returns {CharacterCardFields} Character card fields
3175 }3284 */
3176 }3285export function getCharacterCardFields({ chid = undefined } = {}) {
3286 const lazy = getCharacterCardFieldsLazy({ chid });
31773287
3178 return result;3288 // Resolve all lazy fields into a plain object
3289 return {
3290 system: lazy.system,
3291 mesExamples: lazy.mesExamples,
3292 description: lazy.description,
3293 personality: lazy.personality,
3294 persona: lazy.persona,
3295 scenario: lazy.scenario,
3296 jailbreak: lazy.jailbreak,
3297 version: lazy.version,
3298 charDepthPrompt: lazy.charDepthPrompt,
3299 creatorNotes: lazy.creatorNotes,
3300 };
3179}3301}
31803302
3181/**3303/**
@@ -6610,7 +6732,7 @@ export function getGeneratingApi() {
6610 }6732 }
6611}6733}
66126734
6613function getGeneratingModel(mes) {6735export function getGeneratingModel(mes) {
6614 let model = '';6736 let model = '';
6615 switch (main_api) {6737 switch (main_api) {
6616 case 'kobold':6738 case 'kobold':
public/scripts/authors-note.js+21 -4
@@ -19,7 +19,7 @@ import { SlashCommand } from './slash-commands/SlashCommand.js';
19import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';19import { ARGUMENT_TYPE, SlashCommandArgument } from './slash-commands/SlashCommandArgument.js';
20export { MODULE_NAME as NOTE_MODULE_NAME };20export { MODULE_NAME as NOTE_MODULE_NAME };
21import { t } from './i18n.js';21import { t } from './i18n.js';
22import { MacrosParser } from './macros.js';22import { macros, MacroCategory } from './macros/macro-system.js';
2323
24const MODULE_NAME = '2_floating_prompt'; // <= Deliberate, for sorting lower than memory24const MODULE_NAME = '2_floating_prompt'; // <= Deliberate, for sorting lower than memory
2525
@@ -581,7 +581,24 @@ export function initAuthorsNote() {
581 }));581 }));
582 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);582 eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
583583
584 MacrosParser.registerMacro('authorsNote', () => chat_metadata[metadata_keys.prompt] ?? '', t`The contents of the Author's Note`);584 registerAuthorsNoteMacros();
585 MacrosParser.registerMacro('charAuthorsNote', () => this_chid !== undefined ? (extension_settings.note.chara.find((e) => e.name === getCharaFilename())?.prompt ?? '') : '', t`The contents of the Character Author's Note`);
586 MacrosParser.registerMacro('defaultAuthorsNote', () => extension_settings.note.default ?? '', t`The contents of the Default Author's Note`);
587}585}
586
587function registerAuthorsNoteMacros() {
588 macros.register('authorsNote', {
589 category: MacroCategory.PROMPTS,
590 description: t`The contents of the Author's Note`,
591 handler: () => chat_metadata[metadata_keys.prompt] ?? '',
592 });
593 macros.register('charAuthorsNote', {
594 category: MacroCategory.CHARACTER,
595 description: t`The contents of the Character Author's Note`,
596 handler: () => this_chid !== undefined ? (extension_settings.note.chara.find((e) => e.name === getCharaFilename())?.prompt ?? '') : '',
597 });
598 macros.register('defaultAuthorsNote', {
599 category: MacroCategory.PROMPTS,
600 description: t`The contents of the Default Author's Note`,
601 handler: () => extension_settings.note.default ?? '',
602 });
603}
604
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+260 -0
@@ -0,0 +1,260 @@
1/**
2 * Enhanced macro autocomplete option for the new MacroRegistry-based system.
3 * Reuses rendering logic from MacroBrowser for consistency and DRY.
4 */
5
6import { AutoCompleteOption } from './AutoCompleteOption.js';
7import {
8 formatMacroSignature,
9 createSourceIndicator,
10 createAliasIndicator,
11 renderMacroDetails,
12} from '../macros/MacroBrowser.js';
13import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
14
15/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
16
17/**
18 * Macro context passed from the parser to provide cursor position info.
19 * @typedef {Object} MacroAutoCompleteContext
20 * @property {string} fullText - The full macro text being typed (without {{ }}).
21 * @property {number} cursorOffset - Cursor position within the macro text.
22 * @property {string} identifier - The macro identifier (name).
23 * @property {string[]} args - Array of arguments typed so far.
24 * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
25 */
26
27export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
28 /** @type {MacroDefinition} */
29 #macro;
30
31 /** @type {MacroAutoCompleteContext|null} */
32 #context = null;
33
34 /**
35 * @param {MacroDefinition} macro - The macro definition from MacroRegistry.
36 * @param {MacroAutoCompleteContext} [context] - Optional context for argument hints.
37 */
38 constructor(macro, context = null) {
39 // Use the macro name as the autocomplete key
40 super(macro.name, enumIcons.macro);
41 this.#macro = macro;
42 this.#context = context;
43 // nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
44 this.nameOffset = 2;
45 }
46
47 /** @returns {MacroDefinition} */
48 get macro() {
49 return this.#macro;
50 }
51
52 /**
53 * Renders the list item for the autocomplete dropdown.
54 * Tight display: [icon] [signature] [description] [alias icon?] [source icon]
55 * @returns {HTMLElement}
56 */
57 renderItem() {
58 const li = document.createElement('li');
59 li.classList.add('item', 'macro-ac-item');
60 li.setAttribute('data-name', this.name);
61 li.setAttribute('data-option-type', 'macro');
62
63 // Type icon
64 const type = document.createElement('span');
65 type.classList.add('type', 'monospace');
66 type.textContent = '{}';
67 li.append(type);
68
69 // Specs container (for fuzzy highlight compatibility)
70 const specs = document.createElement('span');
71 specs.classList.add('specs');
72
73 // Name with character spans for fuzzy highlighting
74 const nameEl = document.createElement('span');
75 nameEl.classList.add('name', 'monospace');
76
77 // Build signature with individual character spans (includes {{ }})
78 const sigText = formatMacroSignature(this.#macro);
79 for (const char of sigText) {
80 const span = document.createElement('span');
81 span.textContent = char;
82 nameEl.append(span);
83 }
84 specs.append(nameEl);
85 li.append(specs);
86
87 // Stopgap (spacer for flex layout)
88 const stopgap = document.createElement('span');
89 stopgap.classList.add('stopgap');
90 li.append(stopgap);
91
92 // Help text (description)
93 const help = document.createElement('span');
94 help.classList.add('help');
95 const content = document.createElement('span');
96 content.classList.add('helpContent');
97 content.textContent = this.#macro.description || '';
98 help.append(content);
99 li.append(help);
100
101 // Alias indicator icon (if this is an alias)
102 const aliasIcon = createAliasIndicator(this.#macro);
103 if (aliasIcon) {
104 aliasIcon.classList.add('macro-ac-indicator');
105 li.append(aliasIcon);
106 }
107
108 // Source indicator icon
109 const sourceIcon = createSourceIndicator(this.#macro);
110 sourceIcon.classList.add('macro-ac-indicator');
111 li.append(sourceIcon);
112
113 return li;
114 }
115
116 /**
117 * Renders the details panel content.
118 * Reuses renderMacroDetails from MacroBrowser with autocomplete-specific options.
119 * @returns {DocumentFragment}
120 */
121 renderDetails() {
122 const frag = document.createDocumentFragment();
123
124 // Determine current argument index for highlighting
125 const currentArgIndex = this.#context?.currentArgIndex ?? -1;
126
127 // Render argument hint banner if we're typing an argument
128 if (currentArgIndex >= 0) {
129 const hint = this.#renderArgumentHint();
130 if (hint) frag.append(hint);
131 }
132
133 // Reuse MacroBrowser's renderMacroDetails with options
134 const details = renderMacroDetails(this.#macro, { currentArgIndex });
135
136 // Add class for autocomplete-specific styling overrides
137 details.classList.add('macro-ac-details');
138 frag.append(details);
139
140 return frag;
141 }
142
143 /**
144 * Renders the current argument hint banner.
145 * @returns {HTMLElement|null}
146 */
147 #renderArgumentHint() {
148 if (!this.#context || this.#context.currentArgIndex < 0) return null;
149
150 const argIndex = this.#context.currentArgIndex;
151 const isListArg = argIndex >= this.#macro.maxArgs;
152
153 // If we're beyond unnamed args and there's no list, no hint
154 if (isListArg && !this.#macro.list) return null;
155
156 const hint = document.createElement('div');
157 hint.classList.add('macro-ac-arg-hint');
158
159 const icon = document.createElement('i');
160 icon.classList.add('fa-solid', 'fa-arrow-right');
161 hint.append(icon);
162
163 if (isListArg) {
164 // List argument hint
165 const listIndex = argIndex - this.#macro.maxArgs + 1;
166 const text = document.createElement('span');
167 text.innerHTML = `<strong>List item ${listIndex}</strong>`;
168 hint.append(text);
169 } else {
170 // Unnamed argument hint (required or optional)
171 const argDef = this.#macro.unnamedArgDefs[argIndex];
172 let optionalLabel = '';
173 if (argDef?.optional) {
174 optionalLabel = argDef.defaultValue !== undefined
175 ? ` <em>(optional, default: ${argDef.defaultValue === '' ? '<empty string>' : argDef.defaultValue})</em>`
176 : ' <em>(optional)</em>';
177 }
178 const text = document.createElement('span');
179 text.innerHTML = `<strong>${argDef?.name || `Argument ${argIndex + 1}`}</strong>${optionalLabel}`;
180 if (argDef?.type) {
181 const typeSpan = document.createElement('code');
182 typeSpan.classList.add('macro-ac-hint-type');
183 if (Array.isArray(argDef.type)) {
184 typeSpan.textContent = argDef.type.join(' | ');
185 typeSpan.title = `Accepts: ${argDef.type.join(', ')}`;
186 } else {
187 typeSpan.textContent = argDef.type;
188 }
189 text.append(' ', typeSpan);
190 }
191 hint.append(text);
192
193 if (argDef?.description) {
194 const descSpan = document.createElement('span');
195 descSpan.classList.add('macro-ac-hint-desc');
196 descSpan.textContent = ` — ${argDef.description}`;
197 hint.append(descSpan);
198 }
199
200 if (argDef?.sampleValue) {
201 const sampleSpan = document.createElement('span');
202 sampleSpan.classList.add('macro-ac-hint-sample');
203 sampleSpan.textContent = ` (e.g. ${argDef.sampleValue})`;
204 hint.append(sampleSpan);
205 }
206 }
207
208 return hint;
209 }
210}
211
212/**
213 * Parses the macro text to determine current argument context.
214 * @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "random::a::b".
215 * @param {number} cursorOffset - Cursor position within macroText.
216 * @returns {MacroAutoCompleteContext}
217 */
218export function parseMacroContext(macroText, cursorOffset) {
219 const parts = [];
220 let currentPart = '';
221 let partStart = 0;
222 let i = 0;
223
224 while (i < macroText.length) {
225 if (macroText[i] === ':' && macroText[i + 1] === ':') {
226 parts.push({ text: currentPart, start: partStart, end: i });
227 currentPart = '';
228 i += 2;
229 partStart = i;
230 } else {
231 currentPart += macroText[i];
232 i++;
233 }
234 }
235 // Push the last part
236 parts.push({ text: currentPart, start: partStart, end: macroText.length });
237
238 // Determine which part the cursor is in
239 let currentArgIndex = -1;
240 for (let idx = 0; idx < parts.length; idx++) {
241 const part = parts[idx];
242 if (cursorOffset >= part.start && cursorOffset <= part.end) {
243 currentArgIndex = idx - 1; // -1 because first part is identifier
244 break;
245 }
246 }
247
248 // If cursor is after all parts (at the end), we're in the last arg
249 if (currentArgIndex === -1 && cursorOffset >= parts[parts.length - 1].end) {
250 currentArgIndex = parts.length - 1;
251 }
252
253 return {
254 fullText: macroText,
255 cursorOffset,
256 identifier: parts[0]?.text.trim() || '',
257 args: parts.slice(1).map(p => p.text),
258 currentArgIndex,
259 };
260}
public/scripts/extensions/memory/index.js+15 -3
@@ -19,17 +19,18 @@ import {
19 animation_easing,19 animation_easing,
20} from '../../../script.js';20} from '../../../script.js';
21import { is_group_generating, selected_group } from '../../group-chats.js';21import { is_group_generating, selected_group } from '../../group-chats.js';
22import { loadMovingUIState } from '../../power-user.js';22import { loadMovingUIState, power_user } from '../../power-user.js';
23import { dragElement } from '../../RossAscends-mods.js';23import { dragElement } from '../../RossAscends-mods.js';
24import { getTextTokens, getTokenCountAsync, tokenizers } from '../../tokenizers.js';24import { getTextTokens, getTokenCountAsync, tokenizers } from '../../tokenizers.js';
25import { debounce_timeout } from '../../constants.js';25import { debounce_timeout } from '../../constants.js';
26import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';26import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
27import { SlashCommand } from '../../slash-commands/SlashCommand.js';27import { SlashCommand } from '../../slash-commands/SlashCommand.js';
28import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';28import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
29import { MacrosParser } from '../../macros.js';29import { macros, MacroCategory } from '../../macros/macro-system.js';
30import { countWebLlmTokens, generateWebLlmChatPrompt, getWebLlmContextSize, isWebLlmSupported } from '../shared.js';30import { countWebLlmTokens, generateWebLlmChatPrompt, getWebLlmContextSize, isWebLlmSupported } from '../shared.js';
31import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';31import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
32import { removeReasoningFromString } from '../../reasoning.js';32import { removeReasoningFromString } from '../../reasoning.js';
33import { MacrosParser } from '/scripts/macros.js';
33export { MODULE_NAME };34export { MODULE_NAME };
3435
35const MODULE_NAME = '1_memory';36const MODULE_NAME = '1_memory';
@@ -1094,5 +1095,16 @@ jQuery(async function () {
1094 returns: ARGUMENT_TYPE.STRING,1095 returns: ARGUMENT_TYPE.STRING,
1095 }));1096 }));
10961097
1097 MacrosParser.registerMacro('summary', () => getLatestMemoryFromChat(getContext().chat));1098 if (power_user.experimental_macro_engine) {
1099 macros.register('summary', {
1100 category: MacroCategory.CHAT,
1101 description: 'Returns the latest memory/summary from the current chat.',
1102 handler: () => getLatestMemoryFromChat(getContext().chat),
1103 });
1104 } else {
1105 // TODO: Remove this when the experimental macro engine is replacing the old macro engine
1106 MacrosParser.registerMacro('summary',
1107 () => getLatestMemoryFromChat(getContext().chat),
1108 'Returns the latest memory/summary from the current chat.');
1109 }
1098});1110});
public/scripts/extensions/stable-diffusion/index.js+24 -3
@@ -57,9 +57,11 @@ import { SlashCommandEnumValue } from '../../slash-commands/SlashCommandEnumValu
57import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';57import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
58import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';58import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
59import { ToolManager } from '../../tool-calling.js';59import { ToolManager } from '../../tool-calling.js';
60import { MacrosParser } from '../../macros.js';60import { macros, MacroCategory } from '../../macros/macro-system.js';
61import { t, translate } from '../../i18n.js';61import { t, translate } from '../../i18n.js';
62import { oai_settings } from '../../openai.js';62import { oai_settings } from '../../openai.js';
63import { power_user } from '/scripts/power-user.js';
64import { MacrosParser } from '/scripts/macros.js';
6365
64export { MODULE_NAME };66export { MODULE_NAME };
6567
@@ -5145,6 +5147,25 @@ jQuery(async () => {
5145 return isNegative ? negativePrompt : characterPrompt;5147 return isNegative ? negativePrompt : characterPrompt;
5146 };5148 };
51475149
5148 MacrosParser.registerMacro('charPrefix', () => getMacroValue({ isNegative: false }), t`Character's positive positive Image Generation prompt prefix`);5150 if (power_user.experimental_macro_engine) {
5149 MacrosParser.registerMacro('charNegativePrefix', () => getMacroValue({ isNegative: true }), t`Character's negative Image Generation prompt prefix`);5151 macros.register('charPrefix', {
5152 category: MacroCategory.PROMPTS,
5153 description: t`Character's positive Image Generation prompt prefix`,
5154 handler: () => getMacroValue({ isNegative: false }),
5155 });
5156 macros.register('charNegativePrefix', {
5157 category: MacroCategory.PROMPTS,
5158 description: t`Character's negative Image Generation prompt prefix`,
5159 handler: () => getMacroValue({ isNegative: true }),
5160 });
5161 } else {
5162 MacrosParser.registerMacro('charPrefix',
5163 () => getMacroValue({ isNegative: false }),
5164 t`Character's positive Image Generation prompt prefix`,
5165 );
5166 MacrosParser.registerMacro('charNegativePrefix',
5167 () => getMacroValue({ isNegative: true }),
5168 t`Character's negative Image Generation prompt prefix`,
5169 );
5170 }
5150});5171});
public/scripts/group-chats.js+59 -51
@@ -73,6 +73,7 @@ import {
73 isChatSaving,73 isChatSaving,
74 setExternalAbortController,74 setExternalAbortController,
75 baseChatReplace,75 baseChatReplace,
76 createLazyFields,
76 depth_prompt_depth_default,77 depth_prompt_depth_default,
77 loadItemizedPrompts,78 loadItemizedPrompts,
78 animation_duration,79 animation_duration,
@@ -477,26 +478,43 @@ export function getGroupDepthPrompts(groupId, characterId) {
477 * @returns {{description: string, personality: string, scenario: string, mesExamples: string}} Group character cards combined478 * @returns {{description: string, personality: string, scenario: string, mesExamples: string}} Group character cards combined
478 */479 */
479export function getGroupCharacterCards(groupId, characterId) {480export function getGroupCharacterCards(groupId, characterId) {
481 const lazy = getGroupCharacterCardsLazy(groupId, characterId);
482 if (!lazy) return null;
483
484 // Resolve all lazy fields into a plain object
485 return {
486 description: lazy.description,
487 personality: lazy.personality,
488 scenario: lazy.scenario,
489 mesExamples: lazy.mesExamples,
490 };
491}
492
493/**
494 * Returns group character cards with lazy evaluation.
495 * Each field is only processed when first accessed.
496 * @param {string} groupId Group ID
497 * @param {number} characterId Current Character ID
498 * @returns {{description: string, personality: string, scenario: string, mesExamples: string}} Group character cards with lazy getters
499 */
500export function getGroupCharacterCardsLazy(groupId, characterId) {
480 const group = groups.find(x => x.id === groupId);501 const group = groups.find(x => x.id === groupId);
481502
503 // If no group cards should be generated, return null so caller knows to fall back
482 if (!group || !group?.generation_mode || !Array.isArray(group.members) || !group.members.length) {504 if (!group || !group?.generation_mode || !Array.isArray(group.members) || !group.members.length) {
483 return null;505 return null;
484 }506 }
485507
486 /**508 /**
487 * Runs the macro engine on a text, with custom <FIELDNAME> replace509 * Runs baseChatReplace on a text, with custom <FIELDNAME> replace
488 * @param {string} value Value to replace510 * @param {string} value Value to replace
489 * @param {string} fieldName Name of the field511 * @param {string} fieldName Name of the field
490 * @param {string} characterName Name of the character512 * @param {string} characterName Name of the character
491 * @param {boolean} trim Whether to trim the value513 * @param {boolean} trim Whether to trim the value
492 * @returns {string} Replaced text514 * @returns {string} Replaced text
493 * */515 */
494 function customBaseChatReplace(value, fieldName, characterName, trim) {516 function customTransform(value, fieldName, characterName, trim) {
495 if (!value) {517 if (!value) return '';
496 return '';
497 }
498
499 // We should do the custom field name replacement first, and then run it through the normal macro engine with provided names
500 value = value.replace(/<FIELDNAME>/gi, fieldName);518 value = value.replace(/<FIELDNAME>/gi, fieldName);
501 value = trim ? value.trim() : value;519 value = trim ? value.trim() : value;
502 return baseChatReplace(value, name1, characterName);520 return baseChatReplace(value, name1, characterName);
@@ -509,60 +527,50 @@ export function getGroupCharacterCards(groupId, characterId) {
509 * @param {string} fieldName Name of the field527 * @param {string} fieldName Name of the field
510 * @param {function(string): string} [preprocess] Preprocess function528 * @param {function(string): string} [preprocess] Preprocess function
511 * @returns {string} Prepared text529 * @returns {string} Prepared text
512 * */530 */
513 function replaceAndPrepareForJoin(value, characterName, fieldName, preprocess = null) {531 function replaceAndPrepareForJoin(value, characterName, fieldName, preprocess = null) {
514 value = value.trim();532 value = value?.trim() ?? '';
515 if (!value) {533 if (!value) return '';
516 return '';
517 }
518
519 // Run preprocess function
520 if (typeof preprocess === 'function') {534 if (typeof preprocess === 'function') {
521 value = preprocess(value);535 value = preprocess(value);
522 }536 }
523537 const prefix = customTransform(group.generation_mode_join_prefix, fieldName, characterName, false);
524 // Prepare and replace prefixes538 const suffix = customTransform(group.generation_mode_join_suffix, fieldName, characterName, false);
525 const prefix = customBaseChatReplace(group.generation_mode_join_prefix, fieldName, characterName, false);539 value = customTransform(value, fieldName, characterName, true);
526 const suffix = customBaseChatReplace(group.generation_mode_join_suffix, fieldName, characterName, false);
527 // Also run the macro replacement on the actual content
528 value = customBaseChatReplace(value, fieldName, characterName, true);
529
530 return `${prefix}${value}${suffix}`;540 return `${prefix}${value}${suffix}`;
531 }541 }
532542
533 const scenarioOverride = String(chat_metadata['scenario'] || '');543 /**
534 const mesExamplesOverride = String(chat_metadata['mes_example'] || '');544 * Collects and joins field values from all group members
535545 * @param {string} fieldName Display name of the field
536 let descriptions = [];546 * @param {function(import('../script.js').Character): string} getter Function to get field value from character
537 let personalities = [];547 * @param {function(string): string} [preprocess] Optional preprocess function
538 let scenarios = [];548 * @returns {string} Combined field values
539 let mesExamplesArray = [];549 */
540550 function collectField(fieldName, getter, preprocess = null) {
541 for (const member of group.members) {551 const values = [];
542 const index = characters.findIndex(x => x.avatar === member);552 for (const member of group.members) {
543 const character = characters[index];553 const index = characters.findIndex(x => x.avatar === member);
544554 const character = characters[index];
545 if (index === -1 || !character) {555 if (index === -1 || !character) continue;
546 console.debug(`Skipping missing member: ${member}`);556 if (group.disabled_members.includes(member) && characterId !== index && group.generation_mode !== group_generation_mode.APPEND_DISABLED) {
547 continue;557 continue;
548 }558 }
549559 values.push(replaceAndPrepareForJoin(getter(character), character.name, fieldName, preprocess));
550 if (group.disabled_members.includes(member) && characterId !== index && group.generation_mode !== group_generation_mode.APPEND_DISABLED) {
551 continue;
552 }560 }
553561 return values.filter(x => x.length).join('\n');
554 descriptions.push(replaceAndPrepareForJoin(character.description, character.name, 'Description'));
555 personalities.push(replaceAndPrepareForJoin(character.personality, character.name, 'Personality'));
556 scenarios.push(replaceAndPrepareForJoin(character.scenario, character.name, 'Scenario'));
557 mesExamplesArray.push(replaceAndPrepareForJoin(character.mes_example, character.name, 'Example Messages', (x) => !x.startsWith('<START>') ? `<START>\n${x}` : x));
558 }562 }
559563
560 const description = descriptions.filter(x => x.length).join('\n');564 const scenarioOverride = String(chat_metadata['scenario'] || '');
561 const personality = personalities.filter(x => x.length).join('\n');565 const mesExamplesOverride = String(chat_metadata['mes_example'] || '');
562 const scenario = baseChatReplace(scenarioOverride?.trim(), name1, name2) || scenarios.filter(x => x.length).join('\n');
563 const mesExamples = baseChatReplace(mesExamplesOverride?.trim(), name1, name2) || mesExamplesArray.filter(x => x.length).join('\n');
564566
565 return { description, personality, scenario, mesExamples };567 return createLazyFields({
568 description: () => collectField('Description', c => c.description),
569 personality: () => collectField('Personality', c => c.personality),
570 scenario: () => baseChatReplace(scenarioOverride?.trim(), name1, name2) || collectField('Scenario', c => c.scenario),
571 mesExamples: () => baseChatReplace(mesExamplesOverride?.trim(), name1, name2) ||
572 collectField('Example Messages', c => c.mes_example, x => !x.startsWith('<START>') ? `<START>\n${x}` : x),
573 });
566}574}
567575
568/**576/**
public/scripts/macros.js+137 -14
@@ -6,6 +6,8 @@ import { getInstructMacros } from './instruct-mode.js';
6import { getVariableMacros } from './variables.js';6import { getVariableMacros } from './variables.js';
7import { isMobile } from './RossAscends-mods.js';7import { isMobile } from './RossAscends-mods.js';
8import { inject_ids } from './constants.js';8import { inject_ids } from './constants.js';
9import { initRegisterMacros, macros as macroSystem } from './macros/macro-system.js';
10import { power_user } from './power-user.js';
911
10/**12/**
11 * @typedef Macro13 * @typedef Macro
@@ -33,6 +35,10 @@ Handlebars.registerHelper('helperMissing', function () {
33 * @property {string} description - Optional description of the macro35 * @property {string} description - Optional description of the macro
34 */36 */
3537
38/**
39 * @deprecated Use macros.registry.registerMacro (from scripts/macros/macro-system.js)
40 * or substituteParams({ dynamicMacros }) with the new macro engine.
41 */
36export class MacrosParser {42export class MacrosParser {
37 /**43 /**
38 * A map of registered macros.44 * A map of registered macros.
@@ -47,10 +53,97 @@ export class MacrosParser {
47 static #descriptions = new Map();53 static #descriptions = new Map();
4854
49 /**55 /**
56 * Logs a deprecation warning for MacrosParser APIs, pointing callers to
57 * the new macro engine registration surface.
58 *
59 * @param {string} method
60 * @param {string} replacement
61 * @returns {void}
62 */
63 static #logDeprecated(method, replacement) {
64 console.warn(`[DEPRECATED] MacrosParser.${method} is deprecated and will be removed in a future version. Use ${replacement} instead.`);
65 }
66
67 /**
68 * Bridges a legacy MacrosParser macro registration into the new macro
69 * engine when the experimental macro engine flag is enabled.
70 *
71 * This mirrors the simple "{{key}}" replacement behavior by registering
72 * a 0-arg macro in MacroRegistry that does not take arguments and returns
73 * the sanitized value from the legacy registry.
74 *
75 * @param {string} key
76 * @param {string|MacroFunction} value
77 * @param {string} description
78 * @returns {void}
79 */
80 static #registerMacroInNewEngine(key, value, description) {
81 if (!power_user.experimental_macro_engine) {
82 return;
83 }
84
85 // Like the old MacrosParser, we explicitly allow overriding macros, and only warn
86 if (macroSystem.registry.hasMacro(key)) {
87 console.warn(`Macro ${key} is already registered`);
88 }
89
90 const legacyValue = value;
91
92 macroSystem.registry.registerMacro(key, {
93 // Legacy MacrosParser macros never took arguments; keep the
94 // contract that only {{key}} without arguments is valid.
95 category: 'legacy',
96 description: typeof description === 'string' ? description : 'Automatically registered macro from MacrosParser',
97 handler: () => {
98 /** @type {string|MacroFunction|undefined} */
99 let stored = legacyValue;
100
101 if (typeof stored === 'function') {
102 try {
103 const nonce = uuidv4();
104 stored = stored(nonce);
105 } catch (e) {
106 console.warn(`Macro "${key}" function threw an error.`, e);
107 stored = '';
108 }
109 }
110
111 // Let the new macro engine's normalizeMacroResult handle type
112 // normalization for the returned value.
113 return stored;
114 },
115 });
116 }
117
118 /**
119 * Bridges a legacy MacrosParser macro unregistration into the new macro
120 * engine when the experimental macro engine flag is enabled.
121 *
122 * @param {string} key
123 * @returns {void}
124 */
125 static #unregisterMacroInNewEngine(key) {
126 if (!power_user.experimental_macro_engine) {
127 return;
128 }
129
130 macroSystem.registry.unregisterMacro(key);
131 }
132
133 /**
50 * Returns an iterator over all registered macros.134 * Returns an iterator over all registered macros.
51 * @returns {IterableIterator<CustomMacro>}135 * @returns {IterableIterator<CustomMacro>}
52 */136 */
53 static [Symbol.iterator] = function* () {137 static [Symbol.iterator] = function* () {
138 // When experimental macro engine is active, yield from the new registry
139 if (power_user.experimental_macro_engine) {
140 // Exclude hidden aliases for consistency with autocomplete behavior
141 for (const def of macroSystem.registry.getAllMacros({ excludeHiddenAliases: true })) {
142 yield { key: def.name, description: def.description || '' };
143 }
144 return;
145 }
146
54 for (const macro of MacrosParser.#macros.keys()) {147 for (const macro of MacrosParser.#macros.keys()) {
55 yield { key: macro, description: MacrosParser.#descriptions.get(macro) };148 yield { key: macro, description: MacrosParser.#descriptions.get(macro) };
56 }149 }
@@ -62,6 +155,7 @@ export class MacrosParser {
62 * @returns {string|MacroFunction|undefined} The macro value155 * @returns {string|MacroFunction|undefined} The macro value
63 */156 */
64 static get(key) {157 static get(key) {
158 MacrosParser.#logDeprecated('get', 'macros.registry.getMacro (from scripts/macros/macro-system.js)');
65 return MacrosParser.#macros.get(key);159 return MacrosParser.#macros.get(key);
66 }160 }
67161
@@ -71,6 +165,11 @@ export class MacrosParser {
71 * @returns {boolean} True if the macro is registered, false otherwise165 * @returns {boolean} True if the macro is registered, false otherwise
72 */166 */
73 static has(key) {167 static has(key) {
168 MacrosParser.#logDeprecated('has', 'macros.registry.hasMacro (from scripts/macros/macro-system.js)');
169 if (power_user.experimental_macro_engine) {
170 return macroSystem.registry.hasMacro(key);
171 }
172
74 return MacrosParser.#macros.has(key);173 return MacrosParser.#macros.has(key);
75 }174 }
76175
@@ -81,6 +180,7 @@ export class MacrosParser {
81 * @param {string} [description] Optional description of the macro180 * @param {string} [description] Optional description of the macro
82 */181 */
83 static registerMacro(key, value, description = '') {182 static registerMacro(key, value, description = '') {
183 MacrosParser.#logDeprecated('registerMacro', 'macros.registry.registerMacro (from scripts/macros/macro-system.js) or substituteParams({ dynamicMacros })');
84 if (typeof key !== 'string') {184 if (typeof key !== 'string') {
85 throw new Error('Macro key must be a string');185 throw new Error('Macro key must be a string');
86 }186 }
@@ -101,6 +201,11 @@ export class MacrosParser {
101 value = this.sanitizeMacroValue(value);201 value = this.sanitizeMacroValue(value);
102 }202 }
103203
204 MacrosParser.#registerMacroInNewEngine(key, value, description);
205 if (power_user.experimental_macro_engine) {
206 return;
207 }
208
104 if (this.#macros.has(key)) {209 if (this.#macros.has(key)) {
105 console.warn(`Macro ${key} is already registered`);210 console.warn(`Macro ${key} is already registered`);
106 }211 }
@@ -118,6 +223,7 @@ export class MacrosParser {
118 * @param {string} key Macro name (key)223 * @param {string} key Macro name (key)
119 */224 */
120 static unregisterMacro(key) {225 static unregisterMacro(key) {
226 MacrosParser.#logDeprecated('unregisterMacro', 'macros.registry.unregisterMacro (from scripts/macros/macro-system.js)');
121 if (typeof key !== 'string') {227 if (typeof key !== 'string') {
122 throw new Error('Macro key must be a string');228 throw new Error('Macro key must be a string');
123 }229 }
@@ -129,6 +235,11 @@ export class MacrosParser {
129 throw new Error('Macro key must not be empty or whitespace only');235 throw new Error('Macro key must not be empty or whitespace only');
130 }236 }
131237
238 if (power_user.experimental_macro_engine) {
239 MacrosParser.#unregisterMacroInNewEngine(key);
240 return;
241 }
242
132 const deleted = this.#macros.delete(key);243 const deleted = this.#macros.delete(key);
133244
134 if (!deleted) {245 if (!deleted) {
@@ -597,21 +708,33 @@ export function evaluateMacros(content, env, postProcessFn) {
597}708}
598709
599export function initMacros() {710export function initMacros() {
600 function initLastGenerationType() {711 // Only manually register those is new macro engine is not on. In the new one, they are already registered automatically
601 let lastGenerationType = '';712 if (!power_user.experimental_macro_engine) {
602713 function initLastGenerationType() {
603 MacrosParser.registerMacro('lastGenerationType', () => lastGenerationType);714 let lastGenerationType = '';
604715
605 eventSource.on(event_types.GENERATION_STARTED, (type, _params, isDryRun) => {716 MacrosParser.registerMacro('lastGenerationType',
606 if (isDryRun) return;717 () => lastGenerationType,
607 lastGenerationType = type || 'normal';718 'Returns the type of the last generation (e.g., "normal", "swipe", "continue", "impersonate", "quiet").',
608 });719 );
720
721 eventSource.on(event_types.GENERATION_STARTED, (type, _params, isDryRun) => {
722 if (isDryRun) return;
723 lastGenerationType = type || 'normal';
724 });
725
726 eventSource.on(event_types.CHAT_CHANGED, () => {
727 lastGenerationType = '';
728 });
729 }
609730
610 eventSource.on(event_types.CHAT_CHANGED, () => {731 MacrosParser.registerMacro('isMobile',
611 lastGenerationType = '';732 () => String(isMobile()),
612 });733 'Returns "true" if the user is on a mobile device, "false" otherwise.',
734 );
735 initLastGenerationType();
613 }736 }
614737
615 MacrosParser.registerMacro('isMobile', () => String(isMobile()));738 // TODO: Needs to be moved once old macros are deprecated and removed
616 initLastGenerationType();739 initRegisterMacros();
617}740}
public/scripts/macros/MacroBrowser.js+680 -0
@@ -0,0 +1,680 @@
1/**
2 * MacroBrowser - Dynamic documentation browser for macros.
3 * Similar to SlashCommandBrowser but for the macro system.
4 */
5
6import { MacroRegistry, MacroCategory } from './engine/MacroRegistry.js';
7import { performFuzzySearch } from '../power-user.js';
8
9/** @typedef {import('./engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
10/** @typedef {import('./engine/MacroRegistry.js').MacroValueType} MacroValueType */
11
12/**
13 * Category display names and order for documentation.
14 * @type {Record<string, { label: string, order: number }>}
15 */
16const CATEGORY_CONFIG = {
17 [MacroCategory.NAMES]: { label: 'Names & Participants', order: 1 },
18 [MacroCategory.UTILITY]: { label: 'Utilities', order: 2 },
19 [MacroCategory.RANDOM]: { label: 'Randomization', order: 3 },
20 [MacroCategory.TIME]: { label: 'Date & Time', order: 4 },
21 [MacroCategory.VARIABLE]: { label: 'Variables', order: 5 },
22 [MacroCategory.STATE]: { label: 'Runtime State', order: 6 },
23 [MacroCategory.CHARACTER]: { label: 'Character Card & Persona Fields', order: 7 },
24 [MacroCategory.CHAT]: { label: 'Chat History & Messages', order: 8 },
25 [MacroCategory.PROMPTS]: { label: 'Prompt Templates', order: 9 },
26 [MacroCategory.MISC]: { label: 'Miscellaneous', order: 10 },
27};
28
29/**
30 * MacroBrowser class for displaying searchable macro documentation.
31 */
32export class MacroBrowser {
33 /** @type {Map<string, MacroDefinition[]>} */
34 macrosByCategory = new Map();
35
36 /** @type {HTMLElement} */
37 dom;
38
39 /** @type {HTMLInputElement} */
40 searchInput;
41
42 /** @type {HTMLElement} */
43 detailsPanel;
44
45 /** @type {Map<string, HTMLElement>} */
46 itemMap = new Map();
47
48 /** @type {boolean} */
49 isSorted = false;
50
51 /**
52 * Groups macros by category in registration order.
53 * Excludes hidden aliases from the list.
54 */
55 #loadMacros() {
56 this.macrosByCategory.clear();
57 // Exclude hidden aliases - they won't show in the list
58 const allMacros = MacroRegistry.getAllMacros({ excludeHiddenAliases: true });
59
60 for (const macro of allMacros) {
61 const category = macro.category || MacroCategory.MISC;
62 if (!this.macrosByCategory.has(category)) {
63 this.macrosByCategory.set(category, []);
64 }
65 this.macrosByCategory.get(category).push(macro);
66 }
67 }
68
69 /**
70 * Sorts macros within each category alphabetically.
71 */
72 #sortMacros() {
73 for (const [, macros] of this.macrosByCategory) {
74 macros.sort((a, b) => a.name.localeCompare(b.name));
75 }
76 }
77
78 /**
79 * Gets categories sorted by their configured order.
80 * @returns {string[]}
81 */
82 #getSortedCategories() {
83 return Array.from(this.macrosByCategory.keys())
84 .sort((a, b) => getCategoryConfig(a).order - getCategoryConfig(b).order);
85 }
86
87 /**
88 * Renders the browser into a parent element.
89 * @param {HTMLElement} parent
90 * @returns {HTMLElement}
91 */
92 renderInto(parent) {
93 this.#loadMacros();
94
95 const root = document.createElement('div');
96 root.classList.add('macroBrowser');
97 this.dom = root;
98
99 // Search bar and sort button
100 const toolbar = document.createElement('div');
101 toolbar.classList.add('macro-toolbar');
102
103 const searchLabel = document.createElement('label');
104 searchLabel.classList.add('macro-search-label');
105 searchLabel.textContent = 'Search: ';
106
107 const searchInput = document.createElement('input');
108 searchInput.type = 'search';
109 searchInput.classList.add('macro-search-input', 'text_pole');
110 searchInput.placeholder = 'Search macros by name or description...';
111 searchInput.addEventListener('input', () => this.#handleSearch(searchInput.value));
112 this.searchInput = searchInput;
113 searchLabel.appendChild(searchInput);
114 toolbar.appendChild(searchLabel);
115
116 const sortBtn = document.createElement('button');
117 sortBtn.classList.add('macro-sort-btn', 'menu_button');
118 sortBtn.innerHTML = '<i class="fa-solid fa-arrow-down-a-z"></i> Sort A-Z';
119 sortBtn.title = 'Sort macros alphabetically within each category';
120 sortBtn.addEventListener('click', () => this.#toggleSort());
121 toolbar.appendChild(sortBtn);
122
123 root.appendChild(toolbar);
124
125 // Container for list and details
126 const container = document.createElement('div');
127 container.classList.add('macro-container');
128
129 // Macro list
130 const listPanel = document.createElement('div');
131 listPanel.classList.add('macro-list-panel');
132 this.#renderList(listPanel);
133 container.appendChild(listPanel);
134
135 // Details panel
136 const detailsPanel = document.createElement('div');
137 detailsPanel.classList.add('macro-details-panel');
138 detailsPanel.innerHTML = '<div class="macro-details-placeholder">Select a macro to view details</div>';
139 this.detailsPanel = detailsPanel;
140 container.appendChild(detailsPanel);
141
142 root.appendChild(container);
143 parent.appendChild(root);
144
145 return root;
146 }
147
148 /**
149 * Renders the macro list grouped by category.
150 * @param {HTMLElement} listPanel
151 */
152 #renderList(listPanel) {
153 listPanel.innerHTML = '';
154 this.itemMap.clear();
155
156 for (const category of this.#getSortedCategories()) {
157 const macros = this.macrosByCategory.get(category);
158 if (!macros || macros.length === 0) continue;
159
160 // Category header
161 const categoryHeader = document.createElement('div');
162 categoryHeader.classList.add('macro-category-header');
163 categoryHeader.textContent = getCategoryConfig(category).label;
164 categoryHeader.dataset.category = category;
165 listPanel.appendChild(categoryHeader);
166
167 // Macro items
168 for (const macro of macros) {
169 const item = renderMacroItem(macro);
170 item.addEventListener('click', () => this.#showDetails(macro, item));
171 this.itemMap.set(macro.name, item);
172 listPanel.appendChild(item);
173 }
174 }
175 }
176
177 /**
178 * Shows details for a selected macro.
179 * @param {MacroDefinition} macro
180 * @param {HTMLElement} item
181 */
182 #showDetails(macro, item) {
183 // Clear previous selection
184 this.dom.querySelectorAll('.macro-item.selected').forEach(el => el.classList.remove('selected'));
185 item.classList.add('selected');
186
187 // Render details
188 this.detailsPanel.innerHTML = '';
189 this.detailsPanel.appendChild(renderMacroDetails(macro));
190 }
191
192 /**
193 * Handles search input using fuzzy search.
194 * @param {string} query
195 */
196 #handleSearch(query) {
197 query = query.trim();
198
199 // Clear details on search
200 this.detailsPanel.innerHTML = '<div class="macro-details-placeholder">Select a macro to view details</div>';
201 this.dom.querySelectorAll('.macro-item.selected').forEach(el => el.classList.remove('selected'));
202
203 // If empty query, show all
204 if (!query) {
205 for (const item of this.itemMap.values()) {
206 item.classList.remove('isFiltered');
207 }
208 this.dom.querySelectorAll('.macro-category-header').forEach(h => h.classList.remove('isFiltered'));
209 return;
210 }
211
212 // Trim query of braces, as we don't have them in the macro names of the search definitions
213 query = query.replace(/[{}]/g, '');
214
215 // Build searchable data array from all macros
216 const allMacros = MacroRegistry.getAllMacros();
217 const searchData = allMacros.map(macro => ({
218 name: macro.name,
219 aliases: macro.aliases?.map(a => a.alias).join(' '),
220 description: macro.description || '',
221 category: getCategoryConfig(macro.category).label,
222 argNames: macro.unnamedArgDefs.map(d => d.name).join(' '),
223 argDescriptions: macro.unnamedArgDefs.map(d => d.description || '').join(' '),
224 }));
225
226 // Fuzzy search with weighted keys
227 const keys = [
228 { name: 'name', weight: 10 },
229 { name: 'aliases', weight: 1 }, // No need to rank those high, if they are important (visible) they have their own entry
230 { name: 'description', weight: 5 },
231 { name: 'category', weight: 3 },
232 { name: 'argNames', weight: 2 },
233 { name: 'argDescriptions', weight: 1 },
234 ];
235
236 const results = performFuzzySearch('macro-browser', searchData, keys, query);
237 const matchedNames = new Set(results.map(r => r.item.name));
238
239 // Filter items based on fuzzy results
240 for (const [name, item] of this.itemMap) {
241 item.classList.toggle('isFiltered', !matchedNames.has(name));
242 }
243
244 // Hide empty category headers
245 this.dom.querySelectorAll('.macro-category-header').forEach(header => {
246 if (!(header instanceof HTMLElement)) return;
247 const category = header.dataset.category;
248 const hasVisible = Array.from(this.itemMap.values())
249 .filter(item => item.dataset.macroName)
250 .some(item => {
251 const macro = MacroRegistry.getMacro(item.dataset.macroName);
252 return macro?.category === category && !item.classList.contains('isFiltered');
253 });
254 header.classList.toggle('isFiltered', !hasVisible);
255 });
256 }
257
258 /**
259 * Toggles alphabetical sorting.
260 */
261 #toggleSort() {
262 this.isSorted = !this.isSorted;
263
264 if (this.isSorted) {
265 this.#sortMacros();
266 } else {
267 this.#loadMacros(); // Reload to restore registration order
268 }
269
270 const listPanel = this.dom.querySelector('.macro-list-panel');
271 if (!(listPanel instanceof HTMLElement)) return;
272
273 this.#renderList(listPanel);
274 // Re-apply current search filter
275 if (this.searchInput?.value) {
276 this.#handleSearch(this.searchInput.value);
277 }
278
279 // Update button state
280 const sortBtn = this.dom.querySelector('.macro-sort-btn');
281 sortBtn?.classList.toggle('active', this.isSorted);
282 }
283
284 /**
285 * Handles keyboard shortcuts.
286 * @param {KeyboardEvent} evt
287 */
288 #handleKeyDown(evt) {
289 if (!evt.shiftKey && !evt.altKey && evt.ctrlKey && evt.key.toLowerCase() === 'f') {
290 if (!this.dom.closest('body')) return;
291 if (this.dom.closest('.mes') && !this.dom.closest('.last_mes')) return;
292 evt.preventDefault();
293 evt.stopPropagation();
294 evt.stopImmediatePropagation();
295 this.searchInput?.focus();
296 }
297 }
298}
299
300/**
301 * Gets the macro help content.
302 * If experimental_macro_engine is enabled, returns a placeholder for the browser.
303 * Otherwise returns the static template content.
304 *
305 * @returns {string} HTML string for help content
306 */
307export function getMacrosHelp() {
308 // Return a placeholder that will be replaced with the browser
309 return '<div class="macroHelp"><i class="fa-solid fa-spinner fa-spin"></i> Loading macro documentation...</div>';
310}
311
312/**
313 * Gets display config for a category.
314 * @param {string} category
315 * @returns {{ label: string, order: number }}
316 */
317function getCategoryConfig(category) {
318 return CATEGORY_CONFIG[category] ?? { label: category, order: 100 };
319}
320
321/**
322 * Formats a macro signature with its arguments.
323 * Uses displayOverride if available, otherwise auto-generates from args.
324 * Optional args are shown in [brackets].
325 * @param {MacroDefinition} macro
326 * @returns {string}
327 */
328export function formatMacroSignature(macro) {
329 // Use displayOverride if provided
330 if (macro.displayOverride) {
331 return macro.displayOverride;
332 }
333
334 const parts = [macro.name];
335
336 // Add all unnamed args (required + optional)
337 for (let i = 0; i < macro.unnamedArgDefs.length; i++) {
338 const argDef = macro.unnamedArgDefs[i];
339 const argName = argDef?.sampleValue || argDef?.name || `arg${i + 1}`;
340 // Wrap optional args in brackets
341 parts.push(argDef?.optional ? `[${argName}]` : argName);
342 }
343
344 // Add list args indicator
345 if (macro.list) {
346 const hasMin = macro.list.min > 0;
347 const hasMax = macro.list.max !== null;
348 if (hasMin && hasMax && macro.list.min === macro.list.max) {
349 // Fixed number of list items
350 for (let i = 0; i < macro.list.min; i++) {
351 parts.push(`item${i + 1}`);
352 }
353 } else {
354 // Variable list
355 parts.push('item1', 'item2', '...');
356 }
357 }
358
359 return `{{${parts.join('::')}}}`;
360}
361
362/**
363 * Creates a DOM element for a macro's source indicator (extension/third-party icons).
364 * @param {MacroDefinition} macro
365 * @returns {HTMLElement}
366 */
367export function createSourceIndicator(macro) {
368 const src = document.createElement('span');
369 src.classList.add('macro-source', 'fa-solid');
370
371 if (macro.source.isExtension) {
372 src.classList.add('isExtension', 'fa-cubes');
373 src.classList.add(macro.source.isThirdParty ? 'isThirdParty' : 'isCore');
374 } else {
375 src.classList.add('isCore', 'fa-star-of-life');
376 }
377
378 const titleParts = [
379 macro.source.isExtension ? 'Extension' : 'Core',
380 macro.source.isThirdParty ? 'Third Party' : (macro.source.isExtension ? 'Built-in' : null),
381 macro.source.name,
382 ].filter(Boolean);
383 src.title = titleParts.join('\n');
384
385 return src;
386}
387
388/**
389 * Creates a DOM element for alias indicator icon.
390 * @param {MacroDefinition} macro
391 * @returns {HTMLElement|null}
392 */
393export function createAliasIndicator(macro) {
394 if (!macro.aliasOf) return null;
395
396 const icon = document.createElement('span');
397 icon.classList.add('macro-alias-indicator', 'fa-solid', 'fa-arrow-turn-up');
398 icon.title = `Alias of {{${macro.aliasOf}}}`;
399 return icon;
400}
401
402/**
403 * Creates a type badge element. Supports single type or array of types.
404 * @param {MacroValueType|MacroValueType[]} type - Single type or array of accepted types.
405 * @returns {HTMLElement}
406 */
407export function createTypeBadge(type) {
408 const badge = document.createElement('span');
409 badge.classList.add('macro-arg-type');
410
411 if (Array.isArray(type)) {
412 badge.textContent = type.join(' | ');
413 badge.title = `Accepts: ${type.join(', ')}`;
414 } else {
415 badge.textContent = type;
416 }
417
418 return badge;
419}
420
421/**
422 * Renders a single macro item for the list.
423 * Order: [signature] [description (shrinks)] [alias icon?] [source icon]
424 * @param {MacroDefinition} macro
425 * @returns {HTMLElement}
426 */
427function renderMacroItem(macro) {
428 const item = document.createElement('div');
429 item.classList.add('macro-item');
430 if (macro.aliasOf) item.classList.add('isAlias');
431 item.dataset.macroName = macro.name;
432
433 // Signature (fixed width, truncates if too long)
434 const signature = document.createElement('code');
435 signature.classList.add('macro-signature');
436 signature.textContent = formatMacroSignature(macro);
437 item.appendChild(signature);
438
439 // Description preview (shrinks to fit, truncates)
440 const desc = document.createElement('span');
441 desc.classList.add('macro-desc-preview');
442 desc.textContent = macro.description || '<no description>';
443 item.appendChild(desc);
444
445 // Alias indicator (if this is an alias entry)
446 const aliasIcon = createAliasIndicator(macro);
447 if (aliasIcon) item.appendChild(aliasIcon);
448
449 // Source indicator (fixed, stays at right edge)
450 item.appendChild(createSourceIndicator(macro));
451
452 return item;
453}
454
455/**
456 * Renders detailed information for a macro.
457 * Can optionally highlight the current argument being typed.
458 * @param {MacroDefinition} macro
459 * @param {Object} [options]
460 * @param {number} [options.currentArgIndex=-1] - Index of argument to highlight (-1 for none).
461 * @param {boolean} [options.showCategory=true] - Whether to show category badge.
462 * @returns {HTMLElement}
463 */
464export function renderMacroDetails(macro, options = {}) {
465 const { currentArgIndex = -1, showCategory = true } = options;
466 const details = document.createElement('div');
467 details.classList.add('macro-details');
468
469 // Header with name and source
470 const header = document.createElement('div');
471 header.classList.add('macro-details-header');
472
473 const nameEl = document.createElement('code');
474 nameEl.classList.add('macro-details-name');
475 nameEl.textContent = formatMacroSignature(macro);
476 header.appendChild(nameEl);
477
478 header.appendChild(createSourceIndicator(macro));
479 details.appendChild(header);
480
481 // Category badge (optional)
482 if (showCategory) {
483 const categoryBadge = document.createElement('span');
484 categoryBadge.classList.add('macro-category-badge');
485 categoryBadge.textContent = getCategoryConfig(macro.category).label;
486 details.appendChild(categoryBadge);
487 }
488
489 // If this is an alias, show what it's an alias of
490 if (macro.aliasOf) {
491 const aliasOfSection = document.createElement('div');
492 aliasOfSection.classList.add('macro-alias-of');
493 aliasOfSection.innerHTML = `<i class="fa-solid fa-arrow-turn-up"></i> Alias of <code>{{${macro.aliasOf}}}</code>`;
494 details.appendChild(aliasOfSection);
495 }
496
497 // Description
498 const descSection = document.createElement('div');
499 descSection.classList.add('macro-details-section');
500 const descLabel = document.createElement('div');
501 descLabel.classList.add('macro-details-label');
502 descLabel.textContent = 'Description';
503 descSection.appendChild(descLabel);
504 const descText = document.createElement('div');
505 descText.classList.add('macro-details-text');
506 descText.textContent = macro.description || '<no description>';
507 descSection.appendChild(descText);
508 details.appendChild(descSection);
509
510 // Arguments section (if any)
511 if (macro.unnamedArgDefs.length > 0 || macro.list) {
512 const argsSection = document.createElement('div');
513 argsSection.classList.add('macro-details-section');
514 const argsLabel = document.createElement('div');
515 argsLabel.classList.add('macro-details-label');
516 argsLabel.textContent = 'Arguments';
517 argsSection.appendChild(argsLabel);
518
519 const argsList = document.createElement('ul');
520 argsList.classList.add('macro-args-list');
521
522 // Unnamed args (required + optional)
523 for (let i = 0; i < macro.unnamedArgDefs.length; i++) {
524 const argDef = macro.unnamedArgDefs[i];
525 const argItem = document.createElement('li');
526 argItem.classList.add('macro-arg-item');
527 if (argDef?.optional) argItem.classList.add('isOptional');
528 if (currentArgIndex === i) argItem.classList.add('current');
529
530 const argName = document.createElement('code');
531 argName.classList.add('macro-arg-name');
532 argName.textContent = argDef?.name || `arg${i + 1}`;
533 argItem.appendChild(argName);
534
535 argItem.appendChild(createTypeBadge(argDef.type ?? 'string'));
536
537 const argRequiredLabel = document.createElement('span');
538 argRequiredLabel.classList.add(argDef?.optional ? 'macro-arg-optional' : 'macro-arg-required');
539 if (argDef?.optional && argDef.defaultValue !== undefined) {
540 argRequiredLabel.textContent = `(optional, default: ${argDef.defaultValue === '' ? '<empty string>' : argDef.defaultValue})`;
541 } else {
542 argRequiredLabel.textContent = argDef?.optional ? '(optional)' : '(required)';
543 }
544 argItem.appendChild(argRequiredLabel);
545
546 if (argDef?.description) {
547 const argDesc = document.createElement('span');
548 argDesc.classList.add('macro-arg-desc');
549 argDesc.textContent = ` — ${argDef.description}`;
550 argItem.appendChild(argDesc);
551 }
552
553 if (argDef?.sampleValue) {
554 const sample = document.createElement('span');
555 sample.classList.add('macro-arg-sample');
556 sample.textContent = ` (e.g. ${argDef.sampleValue})`;
557 argItem.appendChild(sample);
558 }
559
560 argsList.appendChild(argItem);
561 }
562
563 // List args
564 if (macro.list) {
565 const listItem = document.createElement('li');
566 listItem.classList.add('macro-arg-item', 'macro-arg-list');
567 if (currentArgIndex >= macro.maxArgs) listItem.classList.add('current');
568
569 const listName = document.createElement('code');
570 listName.classList.add('macro-arg-name');
571 listName.textContent = 'item1::item2::...';
572 listItem.appendChild(listName);
573
574 const listInfo = document.createElement('span');
575 listInfo.classList.add('macro-arg-list-info');
576
577 const minMax = [];
578 if (macro.list.min > 0) minMax.push(`min: ${macro.list.min}`);
579 if (macro.list.max !== null) minMax.push(`max: ${macro.list.max}`);
580
581 if (minMax.length > 0) {
582 listInfo.textContent = ` (list, ${minMax.join(', ')})`;
583 } else {
584 listInfo.textContent = ' (variable-length list)';
585 }
586 listItem.appendChild(listInfo);
587
588 argsList.appendChild(listItem);
589 }
590
591 argsSection.appendChild(argsList);
592 details.appendChild(argsSection);
593 }
594
595 // Returns section (always show - at minimum shows the type)
596 {
597 const returnsSection = document.createElement('div');
598 returnsSection.classList.add('macro-details-section');
599 const returnsLabel = document.createElement('div');
600 returnsLabel.classList.add('macro-details-label');
601 returnsLabel.textContent = 'Returns';
602 returnsSection.appendChild(returnsLabel);
603
604 const returnsContent = document.createElement('div');
605 returnsContent.classList.add('macro-returns-content');
606
607 // Add return type badge
608 const returnTypeBadge = createTypeBadge(macro.returnType);
609 returnsContent.appendChild(returnTypeBadge);
610
611 // Add description text if provided
612 if (macro.returns) {
613 const returnsText = document.createElement('span');
614 returnsText.classList.add('macro-details-text');
615 returnsText.textContent = macro.returns;
616 returnsContent.appendChild(returnsText);
617 }
618
619 returnsSection.appendChild(returnsContent);
620 details.appendChild(returnsSection);
621 }
622
623 // Example usage section (if any)
624 if (macro.exampleUsage && macro.exampleUsage.length > 0) {
625 const exampleSection = document.createElement('div');
626 exampleSection.classList.add('macro-details-section');
627 const exampleLabel = document.createElement('div');
628 exampleLabel.classList.add('macro-details-label');
629 exampleLabel.textContent = 'Example Usage';
630 exampleSection.appendChild(exampleLabel);
631
632 const exampleList = document.createElement('ul');
633 exampleList.classList.add('macro-example-list');
634 for (const example of macro.exampleUsage) {
635 const li = document.createElement('li');
636 const code = document.createElement('code');
637 code.textContent = example;
638 li.appendChild(code);
639 exampleList.appendChild(li);
640 }
641 exampleSection.appendChild(exampleList);
642 details.appendChild(exampleSection);
643 }
644
645 // Aliases section (if this macro has aliases)
646 if (macro.aliases && macro.aliases.length > 0) {
647 const aliasSection = document.createElement('div');
648 aliasSection.classList.add('macro-details-section');
649 const aliasLabel = document.createElement('div');
650 aliasLabel.classList.add('macro-details-label');
651 aliasLabel.textContent = 'Aliases';
652 aliasSection.appendChild(aliasLabel);
653
654 const aliasList = document.createElement('ul');
655 aliasList.classList.add('macro-alias-list');
656 for (const { alias, visible } of macro.aliases) {
657 const li = document.createElement('li');
658 li.classList.add('macro-alias-item');
659 if (!visible) li.classList.add('isHidden');
660
661 const code = document.createElement('code');
662 code.textContent = `{{${alias}}}`;
663 li.appendChild(code);
664
665 if (!visible) {
666 const hiddenBadge = document.createElement('span');
667 hiddenBadge.classList.add('macro-alias-hidden-badge');
668 hiddenBadge.textContent = '(deprecated)';
669 hiddenBadge.title = 'This alias is deprecated and will not be shown in documentation or autocomplete';
670 li.appendChild(hiddenBadge);
671 }
672
673 aliasList.appendChild(li);
674 }
675 aliasSection.appendChild(aliasList);
676 details.appendChild(aliasSection);
677 }
678
679 return details;
680}
public/scripts/macros/definitions/chat-macros.js+136 -0
@@ -0,0 +1,136 @@
1import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
2import { chat, chat_metadata } from '../../../script.js';
3
4/**
5 * Registers macros that inspect the current chat log and swipe state
6 * (message texts, indices, swipes, and context boundaries).
7 */
8export function registerChatMacros() {
9 MacroRegistry.registerMacro('lastMessage', {
10 category: MacroCategory.CHAT,
11 description: 'Last message in the chat.',
12 returns: 'Last message in the chat.',
13 handler: () => String(getLastMessage() ?? ''),
14 });
15
16 MacroRegistry.registerMacro('lastMessageId', {
17 category: MacroCategory.CHAT,
18 description: 'Index of the last message in the chat.',
19 returns: 'Index of the last message in the chat.',
20 returnType: MacroValueType.INTEGER,
21 handler: () => String(getLastMessageId() ?? ''),
22 });
23
24 MacroRegistry.registerMacro('lastUserMessage', {
25 category: MacroCategory.CHAT,
26 description: 'Last user message in the chat.',
27 returns: 'Last user message in the chat.',
28 handler: () => String(getLastUserMessage() ?? ''),
29 });
30
31 MacroRegistry.registerMacro('lastCharMessage', {
32 category: MacroCategory.CHAT,
33 description: 'Last character/bot message in the chat.',
34 returns: 'Last character/bot message in the chat.',
35 handler: () => String(getLastCharMessage() ?? ''),
36 });
37
38 MacroRegistry.registerMacro('firstIncludedMessageId', {
39 category: MacroCategory.CHAT,
40 description: 'Index of the first message included in the current context.',
41 returns: 'Index of the first message included in the context.',
42 returnType: MacroValueType.INTEGER,
43 handler: () => String(getFirstIncludedMessageId() ?? ''),
44 });
45
46 MacroRegistry.registerMacro('firstDisplayedMessageId', {
47 category: MacroCategory.CHAT,
48 description: 'Index of the first displayed message in the chat.',
49 returns: 'Index of the first displayed message in the chat.',
50 returnType: MacroValueType.INTEGER,
51 handler: () => String(getFirstDisplayedMessageId() ?? ''),
52 });
53
54 MacroRegistry.registerMacro('lastSwipeId', {
55 category: MacroCategory.CHAT,
56 description: '1-based index of the last swipe for the last message.',
57 returns: '1-based index of the last swipe.',
58 returnType: MacroValueType.INTEGER,
59 handler: () => String(getLastSwipeId() ?? ''),
60 });
61
62 MacroRegistry.registerMacro('currentSwipeId', {
63 category: MacroCategory.CHAT,
64 description: '1-based index of the current swipe.',
65 returns: '1-based index of the current swipe.',
66 returnType: MacroValueType.INTEGER,
67 handler: () => String(getCurrentSwipeId() ?? ''),
68 });
69}
70
71function getLastMessageId({ exclude_swipe_in_propress = true, filter = null } = {}) {
72 if (!Array.isArray(chat) || chat.length === 0) {
73 return null;
74 }
75
76 for (let i = chat.length - 1; i >= 0; i--) {
77 const message = chat[i];
78
79 if (exclude_swipe_in_propress && message.swipes && message.swipe_id >= message.swipes.length) {
80 continue;
81 }
82
83 if (!filter || filter(message)) {
84 return i;
85 }
86 }
87
88 return null;
89}
90
91function getLastMessage() {
92 const mid = getLastMessageId();
93 return typeof mid === 'number' ? (chat[mid]?.mes ?? '') : '';
94}
95
96function getLastUserMessage() {
97 const mid = getLastMessageId({ filter: m => m.is_user && !m.is_system });
98 return typeof mid === 'number' ? (chat[mid]?.mes ?? '') : '';
99}
100
101function getLastCharMessage() {
102 const mid = getLastMessageId({ filter: m => !m.is_user && !m.is_system });
103 return typeof mid === 'number' ? (chat[mid]?.mes ?? '') : '';
104}
105
106function getFirstIncludedMessageId() {
107 const value = chat_metadata['lastInContextMessageId'];
108 return typeof value === 'number' ? value : null;
109}
110
111function getFirstDisplayedMessageId() {
112 const mesElement = document.querySelector('#chat .mes');
113 const mesId = Number(mesElement?.getAttribute('mesid'));
114 if (!Number.isNaN(mesId) && mesId >= 0) {
115 return mesId;
116 }
117 return null;
118}
119
120function getLastSwipeId() {
121 const mid = getLastMessageId({ exclude_swipe_in_propress: false });
122 if (typeof mid !== 'number') {
123 return null;
124 }
125 const swipes = chat[mid]?.swipes;
126 return Array.isArray(swipes) ? swipes.length : null;
127}
128
129function getCurrentSwipeId() {
130 const mid = getLastMessageId({ exclude_swipe_in_propress: false });
131 if (typeof mid !== 'number') {
132 return null;
133 }
134 const swipeId = chat[mid]?.swipe_id;
135 return typeof swipeId === 'number' ? swipeId + 1 : null;
136}
public/scripts/macros/definitions/core-macros.js+280 -0
@@ -0,0 +1,280 @@
1import { seedrandom, droll } from '../../../lib.js';
2import { chat_metadata, main_api, getMaxContextSize, extension_prompts, getCurrentChatId } from '../../../script.js';
3import { getStringHash } from '../../utils.js';
4import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js';
5import { inject_ids } from '../../constants.js';
6import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
7
8/**
9 * Registers SillyTavern's core built-in macros in the MacroRegistry.
10 *
11 * These macros correspond to the main {{...}} macros that are available
12 * in prompts (time/date/chat info, utility macros, etc.). They are
13 * intended to preserve the behavior of the existing regex-based macros
14 * in macros.js while using the new MacroRegistry/MacroEngine pipeline.
15 */
16export function registerCoreMacros() {
17 // {{space}} -> ' '
18 MacroRegistry.registerMacro('space', {
19 category: MacroCategory.UTILITY,
20 unnamedArgs: [
21 {
22 name: 'count',
23 optional: true,
24 defaultValue: '1',
25 type: MacroValueType.INTEGER,
26 description: 'Number of spaces to insert.',
27 },
28 ],
29 description: 'Returns one or more spaces. One space by default, more if the count argument is specified.',
30 returns: 'One or more spaces.',
31 exampleUsage: ['{{space}}', '{{space::4}}'],
32 handler: ({ unnamedArgs: [count] }) => ' '.repeat(Number(count ?? 1)),
33 });
34
35 // {{newline}} -> '\n'
36 MacroRegistry.registerMacro('newline', {
37 category: MacroCategory.UTILITY,
38 unnamedArgs: [
39 {
40 name: 'count',
41 optional: true,
42 defaultValue: '1',
43 type: MacroValueType.INTEGER,
44 description: 'Number of newlines to insert.',
45 },
46 ],
47 description: 'Inserts one or more newlines. One newline by default, more if the count argument is specified.',
48 returns: 'One or more \\n.',
49 exampleUsage: ['{{newline}}', '{{newline::2}}'],
50 handler: ({ unnamedArgs: [count] }) => '\n'.repeat(Number(count ?? 1)),
51 });
52
53 // {{noop}} -> ''
54 MacroRegistry.registerMacro('noop', {
55 category: MacroCategory.UTILITY,
56 description: 'Does nothing and produces an empty string.',
57 returns: '',
58 handler: () => '',
59 });
60
61 // {{trim}} -> macro will currently replace itself with itself. Trimming is handled in post-processing.
62 MacroRegistry.registerMacro('trim', {
63 category: MacroCategory.UTILITY,
64 description: 'Trims all whitespaces around the trim macro.',
65 returns: '',
66 handler: () => '{{trim}}',
67 });
68
69 // {{input}} -> current textarea content
70 MacroRegistry.registerMacro('input', {
71 category: MacroCategory.UTILITY,
72 description: 'Current text from the send textarea.',
73 returns: 'Current text from the send textarea.',
74 handler: () => (/** @type {HTMLTextAreaElement} */(document.querySelector('#send_textarea')))?.value ?? '',
75 });
76
77 // {{maxPrompt}} -> max context size
78 MacroRegistry.registerMacro('maxPrompt', {
79 category: MacroCategory.STATE,
80 description: 'Maximum prompt context size.',
81 returns: 'Maximum prompt context size.',
82 returnType: MacroValueType.INTEGER,
83 handler: () => String(getMaxContextSize()),
84 });
85
86 // String utilities
87 MacroRegistry.registerMacro('reverse', {
88 category: MacroCategory.UTILITY,
89 unnamedArgs: [
90 {
91 name: 'value',
92 type: MacroValueType.STRING,
93 description: 'The string to reverse.',
94 },
95 ],
96 description: 'Reverses the characters of the argument provided.',
97 returns: 'Reversed string.',
98 exampleUsage: ['{{reverse::I am Lana}}'],
99 handler: ({ unnamedArgs: [value] }) => Array.from(value).reverse().join(''),
100 });
101
102 // Comment macro: {{// ...}} -> '' (consumes any arguments)
103 MacroRegistry.registerMacro('//', {
104 aliases: [{ alias: 'comment' }],
105 category: MacroCategory.UTILITY,
106 list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
107 strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid
108 description: 'Comment macro that produces an empty string. Can be used for writing into prompt definitions, without being passed to the context.',
109 returns: '',
110 displayOverride: '{{// ...}}',
111 exampleUsage: ['{{// This is a comment}}'],
112 handler: () => '',
113 });
114
115 // Time and date macros
116 // Dice roll macro: {{roll 1d6}} or {{roll: 1d6}}
117 MacroRegistry.registerMacro('roll', {
118 category: MacroCategory.RANDOM,
119 unnamedArgs: [
120 {
121 name: 'formula',
122 sampleValue: '1d20',
123 description: 'Dice roll formula using droll syntax (e.g. 1d20).',
124 type: 'string',
125 },
126 ],
127 description: 'Rolls dice using droll syntax (e.g. {{roll 1d20}}).',
128 returns: 'Dice roll result.',
129 returnType: MacroValueType.INTEGER,
130 exampleUsage: [
131 '{{roll::1d20}}',
132 '{{roll::6}}',
133 '{{roll::3d6+4}}',
134 ],
135 handler: ({ unnamedArgs: [formula] }) => {
136 // If only digits were provided, treat it as `1dX`.
137 if (/^\d+$/.test(formula)) {
138 formula = `1d${formula}`;
139 }
140
141 const isValid = droll.validate(formula);
142 if (!isValid) {
143 console.debug(`Invalid roll formula: ${formula}`);
144 return '';
145 }
146
147 const result = droll.roll(formula);
148 if (result === false) return '';
149 return String(result.total);
150 },
151 });
152
153 // Random choice macro: {{random::a::b}} or {{random a,b}}
154 MacroRegistry.registerMacro('random', {
155 category: MacroCategory.RANDOM,
156 list: true,
157 description: 'Picks a random item from a list. Will be re-rolled every time macros are resolved.',
158 returns: 'Randomly selected item from the list.',
159 exampleUsage: ['{{random::blonde::brown::red::black::blue}}'],
160 handler: ({ list }) => {
161 // Handle old legacy cases, where we have to split the list manually
162 if (list.length === 1) {
163 list = readSingleArgsRandomList(list[0]);
164 }
165
166 if (list.length === 0) {
167 return '';
168 }
169
170 const rng = seedrandom('added entropy.', { entropy: true });
171 const randomIndex = Math.floor(rng() * list.length);
172 return list[randomIndex];
173 },
174 });
175
176 // Deterministic choice macro: {{pick::a::b}} or {{pick a,b}}
177 MacroRegistry.registerMacro('pick', {
178 category: MacroCategory.RANDOM,
179 list: true,
180 description: 'Picks a random item from a list, but keeps the choice stable for a given chat and macro position.',
181 returns: 'Stable randomly selected item from the list.',
182 exampleUsage: ['{{pick::blonde::brown::red::black::blue}}'],
183 handler: ({ list, range, env }) => {
184 // Handle old legacy cases, where we have to split the list manually
185 if (list.length === 1) {
186 list = readSingleArgsRandomList(list[0]);
187 }
188
189 if (!list.length) {
190 return '';
191 }
192
193 const chatIdHash = getChatIdHash();
194
195 // Use the full original input string for deterministic behavior
196 const rawContentHash = getStringHash(env.content);
197
198 const offset = typeof range?.startOffset === 'number' ? range.startOffset : 0;
199
200 const combinedSeedString = `${chatIdHash}-${rawContentHash}-${offset}`;
201 const finalSeed = getStringHash(combinedSeedString);
202 const rng = seedrandom(String(finalSeed));
203 const randomIndex = Math.floor(rng() * list.length);
204 return list[randomIndex];
205 },
206 });
207
208 /** @param {string} listString @return {string[]} */
209 function readSingleArgsRandomList(listString) {
210 // If it contains double colons, those will have precedence over comma-seperated lists.
211 // This can only happen if the macro only had a single colon to introduce the list...
212 // like, {{random:a::b::c}}
213 if (listString.includes('::')) {
214 return listString.split('::').map((/** @type {string} */ item) => item.trim());
215 }
216 // Otherwise, we fall back and split by commas that may be present
217 return listString
218 .replace(/\\,/g, '##�COMMA�##')
219 .split(',')
220 .map((/** @type {string} */ item) => item.trim().replace(/##�COMMA�##/g, ','));
221 }
222
223 // Banned words macro: {{banned "word"}}
224 MacroRegistry.registerMacro('banned', {
225 category: MacroCategory.UTILITY,
226 unnamedArgs: [
227 {
228 name: 'word',
229 sampleValue: 'word',
230 description: 'Word to ban for textgenerationwebui backend.',
231 type: 'string',
232 },
233 ],
234 description: 'Bans a word for textgenerationwebui backend. (Strips quotes surrounding the banned word, if present)',
235 returns: '',
236 exampleUsage: ['{{banned::delve}}'],
237 handler: ({ unnamedArgs: [bannedWord] }) => {
238 // Strip quotes via regex, which were allowed in legacy syntax
239 bannedWord = bannedWord.replace(/^"|"$/g, '');
240 if (main_api === 'textgenerationwebui') {
241 console.log('Found banned word in macros: ' + bannedWord);
242 textgenerationwebui_banned_in_macros.push(bannedWord);
243 }
244 return '';
245 },
246 });
247
248 // Outlet macro: {{outlet::key}}
249 MacroRegistry.registerMacro('outlet', {
250 category: MacroCategory.UTILITY,
251 unnamedArgs: [
252 {
253 name: 'key',
254 sampleValue: 'my-outlet-key',
255 description: 'Outlet key.',
256 type: 'string',
257 },
258 ],
259 description: 'Returns the world info outlet prompt for a given outlet key.',
260 returns: 'World info outlet prompt.',
261 exampleUsage: ['{{outlet::character-achievements}}'],
262 handler: ({ unnamedArgs: [outlet] }) => {
263 if (!outlet) return '';
264 const value = extension_prompts[inject_ids.CUSTOM_WI_OUTLET(outlet)]?.value;
265 return value || '';
266 },
267 });
268}
269
270function getChatIdHash() {
271 const cachedIdHash = chat_metadata['chat_id_hash'];
272 if (typeof cachedIdHash === 'number') {
273 return cachedIdHash;
274 }
275
276 const chatId = chat_metadata['main_chat'] ?? getCurrentChatId();
277 const chatIdHash = getStringHash(chatId);
278 chat_metadata['chat_id_hash'] = chatIdHash;
279 return chatIdHash;
280}
public/scripts/macros/definitions/env-macros.js+192 -0
@@ -0,0 +1,192 @@
1import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
2import { isMobile } from '../../RossAscends-mods.js';
3import { parseMesExamples, main_api } from '../../../script.js';
4import { power_user } from '../../power-user.js';
5import { formatInstructModeExamples } from '../../instruct-mode.js';
6
7/** @typedef {import('../engine/MacroEnv.types.js').MacroEnv} MacroEnv */
8
9/**
10 * Registers macros that mostly act as simple accessors to MacroEnv fields
11 * (names, character card fields, system metadata, extras) or basic
12 * environment flags.
13 */
14export function registerEnvMacros() {
15 // Names and participant macros (from MacroEnv.names)
16 MacroRegistry.registerMacro('user', {
17 category: MacroCategory.NAMES,
18 description: 'Your current Persona username.',
19 returns: 'Persona username.',
20 handler: ({ env }) => env.names.user,
21 });
22
23 MacroRegistry.registerMacro('char', {
24 category: MacroCategory.NAMES,
25 description: 'The character\'s name.',
26 returns: 'Character name.',
27 handler: ({ env }) => env.names.char,
28 });
29
30 MacroRegistry.registerMacro('group', {
31 aliases: [{ alias: 'charIfNotGroup', visible: false }],
32 category: MacroCategory.NAMES,
33 description: 'Comma-separated list of group member names (including muted) or the character name in solo chats.',
34 returns: 'List of group member names.',
35 handler: ({ env }) => env.names.group ?? '',
36 });
37
38 MacroRegistry.registerMacro('groupNotMuted', {
39 category: MacroCategory.NAMES,
40 description: 'Comma-separated list of group member names excluding muted members.',
41 returns: 'List of group member names excluding muted members.',
42 handler: ({ env }) => env.names.groupNotMuted ?? '',
43 });
44
45 MacroRegistry.registerMacro('notChar', {
46 category: MacroCategory.NAMES,
47 description: 'Comma-separated list of all participants except the current speaker.',
48 returns: 'List of all participants except the current speaker.',
49 handler: ({ env }) => env.names.notChar ?? '',
50 });
51
52 // Character card field macros (from MacroEnv.character)
53 MacroRegistry.registerMacro('charPrompt', {
54 category: MacroCategory.CHARACTER,
55 description: 'The character\'s Main Prompt override.',
56 returns: 'Character Main Prompt override.',
57 handler: ({ env }) => env.character.charPrompt ?? '',
58 });
59
60 MacroRegistry.registerMacro('charInstruction', {
61 category: MacroCategory.CHARACTER,
62 description: 'The character\'s Post-History Instructions override.',
63 returns: 'Character Post-History Instructions override.',
64 handler: ({ env }) => env.character.charInstruction ?? '',
65 });
66
67 MacroRegistry.registerMacro('charDescription', {
68 aliases: [{ alias: 'description' }],
69 category: MacroCategory.CHARACTER,
70 description: 'The character\'s description.',
71 returns: 'Character description.',
72 handler: ({ env }) => env.character.description ?? '',
73 });
74
75 MacroRegistry.registerMacro('charPersonality', {
76 aliases: [{ alias: 'personality' }],
77 category: MacroCategory.CHARACTER,
78 description: 'The character\'s personality.',
79 returns: 'Character personality.',
80 handler: ({ env }) => env.character.personality ?? '',
81 });
82
83 MacroRegistry.registerMacro('charScenario', {
84 aliases: [{ alias: 'scenario' }],
85 category: MacroCategory.CHARACTER,
86 description: 'The character\'s scenario.',
87 returns: 'Character scenario.',
88 handler: ({ env }) => env.character.scenario ?? '',
89 });
90
91 MacroRegistry.registerMacro('persona', {
92 category: MacroCategory.CHARACTER,
93 description: 'Your current Persona description.',
94 returns: 'Persona description.',
95 handler: ({ env }) => env.character.persona ?? '',
96 });
97
98 MacroRegistry.registerMacro('mesExamplesRaw', {
99 category: MacroCategory.CHARACTER,
100 description: 'Unformatted dialogue examples from the character card.',
101 returns: 'Unformatted dialogue examples.',
102 handler: ({ env }) => env.character.mesExamplesRaw ?? '',
103 });
104
105 MacroRegistry.registerMacro('mesExamples', {
106 category: MacroCategory.CHARACTER,
107 description: 'The character\'s dialogue examples, formatted for instruct mode when enabled.',
108 returns: 'Formatted dialogue examples.',
109 handler: ({ env }) => {
110 const raw = env.character.mesExamplesRaw ?? '';
111 if (!raw) return '';
112
113 const isInstruct = !!power_user?.instruct?.enabled && main_api !== 'openai';
114 const parsed = parseMesExamples(raw, isInstruct);
115
116 if (!Array.isArray(parsed) || parsed.length === 0) {
117 return '';
118 }
119 if (!isInstruct) {
120 return parsed.join('');
121 }
122
123 const formatted = formatInstructModeExamples(parsed, env.names.user, env.names.char);
124 return Array.isArray(formatted) ? formatted.join('') : '';
125 },
126 });
127
128 MacroRegistry.registerMacro('charDepthPrompt', {
129 category: MacroCategory.CHARACTER,
130 description: 'The character\'s @ Depth Note.',
131 returns: 'Character @ Depth Note.',
132 handler: ({ env }) => env.character.charDepthPrompt ?? '',
133 });
134
135 MacroRegistry.registerMacro('charCreatorNotes', {
136 aliases: [{ alias: 'creatorNotes' }],
137 category: MacroCategory.CHARACTER,
138 description: 'Creator notes from the character card.',
139 returns: 'Creator notes.',
140 handler: ({ env }) => env.character.creatorNotes ?? '',
141 });
142
143 // Character version macros (legacy variants and documented {{charVersion}})
144 MacroRegistry.registerMacro('charVersion', {
145 aliases: [
146 { alias: 'version', visible: false }, // Legacy alias
147 { alias: 'char_version', visible: false }, // Legacy underscore variant
148 ],
149 category: MacroCategory.CHARACTER,
150 description: 'The character\'s version number.',
151 returns: 'Character version number.',
152 handler: ({ env }) => env.character.version ?? '',
153 });
154
155 // System / env extras macros (from MacroEnv.system / MacroEnv.extra)
156 MacroRegistry.registerMacro('model', {
157 category: MacroCategory.STATE,
158 description: 'Model name for the currently selected API (Chat Completion or Chat Completion).',
159 returns: 'Model name.',
160 handler: ({ env }) => env.system.model,
161 });
162
163 // TODO: Move this to the summary extension, where it belongs
164 MacroRegistry.registerMacro('summary', {
165 category: MacroCategory.CHAT,
166 description: 'Latest chat summary from the "Summarize" extension (when available).',
167 returns: 'Latest chat summary.',
168 handler: ({ env }) => {
169 const value = /** @type {any} */ (env.extra).summary;
170 return value == null ? '' : String(value);
171 },
172 });
173
174 MacroRegistry.registerMacro('original', {
175 category: MacroCategory.CHARACTER,
176 description: 'Original message content for {{original}} substitution in in character prompt overrides.',
177 returns: 'Original message content.',
178 handler: ({ env }) => {
179 const value = env.functions.original();
180 return value;
181 },
182 });
183
184 // Device / environment macros
185 MacroRegistry.registerMacro('isMobile', {
186 category: MacroCategory.STATE,
187 description: '"true" if currently running in a mobile environment, "false" otherwise.',
188 returns: 'Whether the environment is mobile.',
189 returnType: MacroValueType.BOOLEAN,
190 handler: () => String(isMobile()),
191 });
192}
public/scripts/macros/definitions/instruct-macros.js+76 -0
@@ -0,0 +1,76 @@
1import { MacroRegistry, MacroCategory } from '../engine/MacroRegistry.js';
2import { power_user } from '../../power-user.js';
3
4/**
5 * Registers instruct-mode related {{...}} macros (instruct* and system
6 * prompt/context macros) in the MacroRegistry.
7 */
8export function registerInstructMacros() {
9 /**
10 * Helper to register macros that just expose a value from power_user.instruct.
11 * The first name is the primary, subsequent names become visible aliases.
12 * @param {string[]} names - First is primary, rest are aliases.
13 * @param {() => string} getValue
14 * @param {() => boolean} isEnabled
15 * @param {string} description
16 * @param {string} [category=MacroCategory.PROMPTS]
17 */
18 function registerSimple(names, getValue, isEnabled, description, category = MacroCategory.PROMPTS) {
19 const [primary, ...aliasNames] = names;
20 const aliases = aliasNames.map(alias => ({ alias }));
21
22 MacroRegistry.registerMacro(primary, {
23 category,
24 description,
25 aliases: aliases.length > 0 ? aliases : undefined,
26 handler: () => (isEnabled() ? (getValue() ?? '') : ''),
27 });
28 }
29
30 const instEnabled = () => !!power_user.instruct.enabled;
31 const sysEnabled = () => !!power_user.sysprompt.enabled;
32
33 // Instruct template macros
34 registerSimple(['instructStoryStringPrefix'], () => power_user.instruct.story_string_prefix, instEnabled, 'Instruct story string prefix.');
35 registerSimple(['instructStoryStringSuffix'], () => power_user.instruct.story_string_suffix, instEnabled, 'Instruct story string suffix.');
36
37 registerSimple(['instructUserPrefix', 'instructInput'], () => power_user.instruct.input_sequence, instEnabled, 'Instruct input / user prefix sequence.');
38 registerSimple(['instructUserSuffix'], () => power_user.instruct.input_suffix, instEnabled, 'Instruct input / user suffix sequence.');
39
40 registerSimple(['instructAssistantPrefix', 'instructOutput'], () => power_user.instruct.output_sequence, instEnabled, 'Instruct output / assistant prefix sequence.');
41 registerSimple(['instructAssistantSuffix', 'instructSeparator'], () => power_user.instruct.output_suffix, instEnabled, 'Instruct output / assistant suffix sequence.');
42
43 registerSimple(['instructSystemPrefix'], () => power_user.instruct.system_sequence, instEnabled, 'Instruct system prefix sequence.');
44 registerSimple(['instructSystemSuffix'], () => power_user.instruct.system_suffix, instEnabled, 'Instruct system suffix sequence.');
45
46 registerSimple(['instructFirstAssistantPrefix', 'instructFirstOutputPrefix'], () => power_user.instruct.first_output_sequence || power_user.instruct.output_sequence, instEnabled, 'Instruct first assistant / output prefix sequence');
47 registerSimple(['instructLastAssistantPrefix', 'instructLastOutputPrefix'], () => power_user.instruct.last_output_sequence || power_user.instruct.output_sequence, instEnabled, 'Instruct last assistant / output prefix sequence.');
48
49 registerSimple(['instructStop'], () => power_user.instruct.stop_sequence, instEnabled, 'Instruct stop sequence.');
50 registerSimple(['instructUserFiller'], () => power_user.instruct.user_alignment_message, instEnabled, 'Instruct user alignment filler.');
51 registerSimple(['instructSystemInstructionPrefix'], () => power_user.instruct.last_system_sequence, instEnabled, 'Instruct system instruction prefix sequence.');
52
53 registerSimple(['instructFirstUserPrefix', 'instructFirstInput'], () => power_user.instruct.first_input_sequence || power_user.instruct.input_sequence, instEnabled, 'Instruct first user / input prefix sequence.');
54 registerSimple(['instructLastUserPrefix', 'instructLastInput'], () => power_user.instruct.last_input_sequence || power_user.instruct.input_sequence, instEnabled, 'Instruct last user / input prefix sequence.');
55
56 // System prompt macros
57 registerSimple(['defaultSystemPrompt', 'instructSystem', 'instructSystemPrompt'], () => power_user.sysprompt.content, sysEnabled, 'Default system prompt.');
58
59 MacroRegistry.registerMacro('systemPrompt', {
60 category: MacroCategory.PROMPTS,
61 description: 'Active system prompt text (optionally overridden by character prompt)',
62 handler: ({ env }) => {
63 const isEnabled = !!power_user.sysprompt.enabled;
64 if (!isEnabled) return '';
65
66 if (power_user.prefer_character_prompt && env.character.charPrompt) {
67 return env.character.charPrompt;
68 }
69 return power_user.sysprompt.content ?? '';
70 },
71 });
72
73 // Context template macros
74 registerSimple(['exampleSeparator', 'chatSeparator'], () => power_user.context.example_separator, () => true, 'Separator used between example chat blocks in text completion prompts.');
75 registerSimple(['chatStart'], () => power_user.context.chat_start, () => true, 'Chat start marker used in text completion prompts.');
76}
public/scripts/macros/definitions/state-macros.js+40 -0
@@ -0,0 +1,40 @@
1import { MacroRegistry, MacroCategory } from '../engine/MacroRegistry.js';
2import { eventSource, event_types } from '../../events.js';
3
4let lastGenerationTypeValue = '';
5let lastGenerationTypeTrackingInitialized = false;
6
7function ensureLastGenerationTypeTracking() {
8 if (lastGenerationTypeTrackingInitialized) {
9 return;
10 }
11 lastGenerationTypeTrackingInitialized = true;
12
13 try {
14 eventSource?.on?.(event_types.GENERATION_STARTED, (type, _params, isDryRun) => {
15 if (isDryRun) return;
16 lastGenerationTypeValue = type || 'normal';
17 });
18
19 eventSource?.on?.(event_types.CHAT_CHANGED, () => {
20 lastGenerationTypeValue = '';
21 });
22 } catch {
23 // In non-runtime environments (tests), eventSource may be undefined or not fully initialized.
24 }
25}
26
27/**
28 * Registers macros that depend on runtime application state or event tracking
29 * rather than static environment fields.
30 */
31export function registerStateMacros() {
32 ensureLastGenerationTypeTracking();
33
34 MacroRegistry.registerMacro('lastGenerationType', {
35 category: MacroCategory.STATE,
36 description: 'Type of the last queued generation request (e.g. "normal", "impersonate", "regenerate", "quiet", "swipe", "continue"). Empty if none yet or chat was switched.',
37 returns: 'Type of the last queued generation request.',
38 handler: () => lastGenerationTypeValue,
39 });
40}
public/scripts/macros/definitions/time-macros.js+151 -0
@@ -0,0 +1,151 @@
1import { moment } from '../../../lib.js';
2import { chat } from '../../../script.js';
3import { timestampToMoment } from '../../utils.js';
4import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
5
6/**
7 * Registers time/date related macros and utilities.
8 */
9export function registerTimeMacros() {
10 // Time and date macros
11 MacroRegistry.registerMacro('time', {
12 category: MacroCategory.TIME,
13 // Optional single list argument: UTC offset, e.g. {{time::UTC+2}}
14 unnamedArgs: [
15 {
16 name: 'offset',
17 optional: true,
18 defaultValue: 'null',
19 type: MacroValueType.STRING,
20 sampleValue: 'UTC+2',
21 description: 'UTC offset in the format UTC±(offset).',
22 },
23 ],
24 description: 'Current local time, or UTC offset when called as {{time::UTC±(offset)}}',
25 returns: 'A time string in the format HH:mm.',
26 displayOverride: '{{time::[UTC±(offset)]}}',
27 exampleUsage: ['{{time}}', '{{time::UTC+2}}', '{{time::UTC-7}}'],
28 handler: ({ unnamedArgs: [offsetSpec] }) => {
29 if (!offsetSpec) return moment().format('LT');
30
31 const match = /^UTC([+-]\d+)$/.exec(offsetSpec);
32 if (!match) return moment().format('LT');
33
34 const offset = Number.parseInt(match[1], 10);
35 if (Number.isNaN(offset)) return moment().format('LT');
36
37 return moment().utc().utcOffset(offset).format('LT');
38 },
39 });
40
41 MacroRegistry.registerMacro('date', {
42 category: MacroCategory.TIME,
43 description: 'Current local date as a string in the local short format.',
44 returns: 'Current local date in local short format.',
45 handler: () => moment().format('LL'),
46 });
47
48 MacroRegistry.registerMacro('weekday', {
49 category: MacroCategory.TIME,
50 description: 'Current weekday name.',
51 returns: 'Current weekday name.',
52 handler: () => moment().format('dddd'),
53 });
54
55 MacroRegistry.registerMacro('isotime', {
56 category: MacroCategory.TIME,
57 description: 'Current time in HH:mm format.',
58 returns: 'Current time in HH:mm format.',
59 handler: () => moment().format('HH:mm'),
60 });
61
62 MacroRegistry.registerMacro('isodate', {
63 category: MacroCategory.TIME,
64 description: 'Current date in YYYY-MM-DD format.',
65 returns: 'Current date in YYYY-MM-DD format.',
66 handler: () => moment().format('YYYY-MM-DD'),
67 });
68
69 MacroRegistry.registerMacro('datetimeformat', {
70 category: MacroCategory.TIME,
71 unnamedArgs: [
72 {
73 name: 'format',
74 sampleValue: 'YYYY-MM-DD HH:mm:ss',
75 description: 'Moment.js format string.',
76 type: 'string',
77 },
78 ],
79 description: 'Formats the current date/time using the given moment.js format string.',
80 returns: 'Formatted date/time string.',
81 exampleUsage: ['{{datetimeformat::YYYY-MM-DD HH:mm:ss}}', '{{datetimeformat::LLLL}}'],
82 handler: ({ unnamedArgs: [format] }) => moment().format(format),
83 });
84
85 MacroRegistry.registerMacro('idleDuration', {
86 aliases: [{ alias: 'idle_duration', visible: false }],
87 category: MacroCategory.TIME,
88 description: 'Human-readable duration since the last user message.',
89 returns: 'Human-readable duration since the last user message.',
90 handler: () => getTimeSinceLastMessage(),
91 });
92
93 // Time difference between two values
94 MacroRegistry.registerMacro('timeDiff', {
95 category: MacroCategory.TIME,
96 unnamedArgs: [
97 {
98 name: 'left',
99 sampleValue: '2023-01-01 12:00:00',
100 description: 'Left time value.',
101 type: 'string',
102 },
103 {
104 name: 'right',
105 sampleValue: '2023-01-01 15:00:00',
106 description: 'Right time value.',
107 type: 'string',
108 },
109 ],
110 description: 'Human-readable difference between two times. Order of times does not matter, it will return the absolute difference.',
111 returns: 'Human-readable difference between two times.',
112 displayOverride: '{{timeDiff::left::right}}', // Shorten this, otherwise it's too long. Full dates don't really help for understanding the macro.
113 exampleUsage: ['{{ timeDiff :: 2023-01-01 12:00:00 :: 2023-01-01 15:00:00 }}'],
114 handler: ({ unnamedArgs: [left, right] }) => {
115 const diff = moment.duration(moment(left).diff(moment(right)));
116 return diff.humanize(true);
117 },
118 });
119}
120
121function getTimeSinceLastMessage() {
122 const now = moment();
123
124 if (Array.isArray(chat) && chat.length > 0) {
125 let lastMessage;
126 let takeNext = false;
127
128 for (let i = chat.length - 1; i >= 0; i--) {
129 const message = chat[i];
130
131 if (message.is_system) {
132 continue;
133 }
134
135 if (message.is_user && takeNext) {
136 lastMessage = message;
137 break;
138 }
139
140 takeNext = true;
141 }
142
143 if (lastMessage?.send_date) {
144 const lastMessageDate = timestampToMoment(lastMessage.send_date);
145 const duration = moment.duration(now.diff(lastMessageDate));
146 return duration.humanize();
147 }
148 }
149
150 return 'just now';
151}
public/scripts/macros/definitions/variable-macros.js+224 -0
@@ -0,0 +1,224 @@
1import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
2
3/**
4 * Registers variable-related {{...}} macros that operate on local and global
5 * variables (e.g. {{setvar}}, {{getvar}}, {{incvar}}, etc.).
6 */
7export function registerVariableMacros() {
8 const ctx = SillyTavern.getContext();
9
10 // {{setvar::name::value}} -> '' (side-effect on local variable)
11 MacroRegistry.registerMacro('setvar', {
12 category: MacroCategory.VARIABLE,
13 unnamedArgs: [
14 {
15 name: 'name',
16 type: MacroValueType.STRING,
17 description: 'The name of the local variable to set.',
18 },
19 {
20 name: 'value',
21 type: [MacroValueType.STRING, MacroValueType.NUMBER],
22 description: 'The value to set the local variable to.',
23 },
24 ],
25 description: 'Sets a local variable to the given value.',
26 returns: '',
27 exampleUsage: ['{{setvar::myvar::foo}}', '{{setvar::myintvar::3}}'],
28 handler: ({ unnamedArgs: [name, value] }) => {
29 ctx.variables.local.set(name, value);
30 return '';
31 },
32 });
33
34 // {{addvar::name::value}} -> '' (side-effect via addLocalVariable)
35 MacroRegistry.registerMacro('addvar', {
36 category: MacroCategory.VARIABLE,
37 unnamedArgs: [
38 {
39 name: 'name',
40 type: MacroValueType.STRING,
41 description: 'The name of the local variable to add to.',
42 },
43 {
44 name: 'value',
45 type: [MacroValueType.STRING, MacroValueType.NUMBER],
46 description: 'The value to add to the local variable.',
47 },
48 ],
49 description: 'Adds a value to an existing local variable (numeric or string append). If the variable does not exist, it will be created.',
50 returns: '',
51 exampleUsage: ['{{addvar::mystrvar::foo}}', '{{addvar::myintvar::3}}'],
52 handler: ({ unnamedArgs: [name, value] }) => {
53 ctx.variables.local.add(name, value);
54 return '';
55 },
56 });
57
58 // {{incvar::name}} -> returns new value
59 MacroRegistry.registerMacro('incvar', {
60 category: MacroCategory.VARIABLE,
61 unnamedArgs: [
62 {
63 name: 'name',
64 type: MacroValueType.STRING,
65 description: 'The name of the local variable to increment.',
66 },
67 ],
68 description: 'Increments a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
69 returns: 'The new value of the local variable.',
70 returnType: MacroValueType.NUMBER,
71 exampleUsage: ['{{incvar::myintvar}}'],
72 handler: ({ unnamedArgs: [name], normalize }) => {
73 const result = ctx.variables.local.inc(name);
74 return normalize(result);
75 },
76 });
77
78 // {{decvar::name}} -> returns new value
79 MacroRegistry.registerMacro('decvar', {
80 category: MacroCategory.VARIABLE,
81 unnamedArgs: [
82 {
83 name: 'name',
84 type: MacroValueType.STRING,
85 description: 'The name of the local variable to decrement.',
86 },
87 ],
88 description: 'Decrements a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
89 returns: 'The new value of the local variable.',
90 returnType: MacroValueType.NUMBER,
91 exampleUsage: ['{{decvar::myintvar}}'],
92 handler: ({ unnamedArgs: [name], normalize }) => {
93 const result = ctx.variables.local.dec(name);
94 return normalize(result);
95 },
96 });
97
98 // {{getvar::name}} -> returns current value
99 MacroRegistry.registerMacro('getvar', {
100 category: MacroCategory.VARIABLE,
101 unnamedArgs: [
102 {
103 name: 'name',
104 type: MacroValueType.STRING,
105 description: 'The name of the local variable to get.',
106 },
107 ],
108 description: 'Gets the value of a local variable.',
109 returns: 'The value of the local variable.',
110 returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
111 exampleUsage: ['{{getvar::myvar}}', '{{getvar::myintvar}}'],
112 handler: ({ unnamedArgs: [name], normalize }) => {
113 const result = ctx.variables.local.get(name);
114 return normalize(result);
115 },
116 });
117
118 // {{setglobalvar::name::value}} -> ''
119 MacroRegistry.registerMacro('setglobalvar', {
120 category: MacroCategory.VARIABLE,
121 unnamedArgs: [
122 {
123 name: 'name',
124 type: MacroValueType.STRING,
125 description: 'The name of the global variable to set.',
126 },
127 {
128 name: 'value',
129 type: [MacroValueType.STRING, MacroValueType.NUMBER],
130 description: 'The value to set the global variable to.',
131 },
132 ],
133 description: 'Sets a global variable to the given value.',
134 returns: '',
135 exampleUsage: ['{{setglobalvar::myvar::foo}}', '{{setglobalvar::myintvar::3}}'],
136 handler: ({ unnamedArgs: [name, value] }) => {
137 ctx.variables.global.set(name, value);
138 return '';
139 },
140 });
141
142 // {{addglobalvar::name::value}} -> ''
143 MacroRegistry.registerMacro('addglobalvar', {
144 category: MacroCategory.VARIABLE,
145 unnamedArgs: [
146 {
147 name: 'name',
148 type: MacroValueType.STRING,
149 description: 'The name of the global variable to add to.',
150 },
151 {
152 name: 'value',
153 type: [MacroValueType.STRING, MacroValueType.NUMBER],
154 description: 'The value to add to the global variable.',
155 },
156 ],
157 description: 'Adds a value to an existing global variable (numeric or string append). If the variable does not exist, it will be created.',
158 returns: '',
159 exampleUsage: ['{{addglobalvar::mystrvar::foo}}', '{{addglobalvar::myintvar::3}}'],
160 handler: ({ unnamedArgs: [name, value] }) => {
161 ctx.variables.global.add(name, value);
162 return '';
163 },
164 });
165
166 // {{incglobalvar::name}} -> returns new value
167 MacroRegistry.registerMacro('incglobalvar', {
168 category: MacroCategory.VARIABLE,
169 unnamedArgs: [
170 {
171 name: 'name',
172 type: MacroValueType.STRING,
173 description: 'The name of the global variable to increment.',
174 },
175 ],
176 description: 'Increments a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
177 returns: 'The new value of the global variable.',
178 returnType: MacroValueType.NUMBER,
179 handler: ({ unnamedArgs: [name], normalize }) => {
180 const result = ctx.variables.global.inc(name);
181 return normalize(result);
182 },
183 });
184
185 // {{decglobalvar::name}} -> returns new value
186 MacroRegistry.registerMacro('decglobalvar', {
187 category: MacroCategory.VARIABLE,
188 unnamedArgs: [
189 {
190 name: 'name',
191 type: MacroValueType.STRING,
192 description: 'The name of the global variable to decrement.',
193 },
194 ],
195 description: 'Decrements a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
196 returns: 'The new value of the global variable.',
197 returnType: MacroValueType.NUMBER,
198 exampleUsage: ['{{decglobalvar::myintvar}}'],
199 handler: ({ unnamedArgs: [name], normalize }) => {
200 const result = ctx.variables.global.dec(name);
201 return normalize(result);
202 },
203 });
204
205 // {{getglobalvar::name}} -> returns current value
206 MacroRegistry.registerMacro('getglobalvar', {
207 category: MacroCategory.VARIABLE,
208 unnamedArgs: [
209 {
210 name: 'name',
211 type: MacroValueType.STRING,
212 description: 'The name of the global variable to get.',
213 },
214 ],
215 description: 'Gets the value of a global variable.',
216 returns: 'The value of the global variable.',
217 returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
218 exampleUsage: ['{{getglobalvar::myvar}}', '{{getglobalvar::myintvar}}'],
219 handler: ({ unnamedArgs: [name], normalize }) => {
220 const result = ctx.variables.global.get(name);
221 return normalize(result);
222 },
223 });
224}
public/scripts/macros/engine/MacroCstWalker.js+433 -0
@@ -0,0 +1,433 @@
1/** @typedef {import('chevrotain').CstNode} CstNode */
2/** @typedef {import('chevrotain').IToken} IToken */
3/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
4
5/**
6 * @typedef {Object} MacroCall
7 * @property {string} name
8 * @property {string[]} args
9 * @property {MacroEnv} env
10 * @property {string} rawInner
11 * @property {string} rawWithBraces
12 * @property {{ startOffset: number, endOffset: number }} range
13 * @property {CstNode} cstNode
14 */
15
16/**
17 * @typedef {Object} EvaluationContext
18 * @property {string} text
19 * @property {MacroEnv} env
20 * @property {(call: MacroCall) => string} resolveMacro
21 */
22
23/**
24 * @typedef {Object} TokenRange
25 * @property {number} startOffset
26 * @property {number} endOffset
27 */
28
29/**
30 * The singleton instance of the MacroCstWalker.
31 *
32 * @type {MacroCstWalker}
33 */
34let instance;
35export { instance as MacroCstWalker };
36
37class MacroCstWalker {
38 /** @type {MacroCstWalker} */ static #instance;
39 /** @type {MacroCstWalker} */ static get instance() { return MacroCstWalker.#instance ?? (MacroCstWalker.#instance = new MacroCstWalker()); }
40
41 constructor() { }
42
43 /**
44 * Evaluates a full document CST into a resolved string.
45 *
46 * @param {EvaluationContext & { cst: CstNode }} options
47 * @returns {string}
48 */
49 evaluateDocument(options) {
50 const { text, cst, env, resolveMacro } = options;
51
52 if (typeof text !== 'string') {
53 throw new Error('MacroCstWalker.evaluateDocument: text must be a string');
54 }
55 if (!cst || typeof cst !== 'object' || !cst.children) {
56 throw new Error('MacroCstWalker.evaluateDocument: cst must be a CstNode');
57 }
58 if (typeof resolveMacro !== 'function') {
59 throw new Error('MacroCstWalker.evaluateDocument: resolveMacro must be a function');
60 }
61
62 /** @type {EvaluationContext} */
63 const context = { text, env, resolveMacro };
64 const items = this.#collectDocumentItems(cst);
65
66 if (items.length === 0) {
67 return text;
68 }
69
70 let result = '';
71 let cursor = 0;
72
73 // Iterate over all items in the document. Evaluate any macro being found, and keep them in the exact same place.
74 for (const item of items) {
75 if (item.startOffset > cursor) {
76 result += text.slice(cursor, item.startOffset);
77 }
78
79 // Items can be either plaintext or macro nodes
80 if (item.type === 'plaintext') {
81 result += text.slice(item.startOffset, item.endOffset + 1);
82 } else {
83 result += this.#evaluateMacroNode(item.node, context);
84 }
85
86 cursor = item.endOffset + 1;
87 }
88
89 if (cursor < text.length) {
90 result += text.slice(cursor);
91 }
92
93 return result;
94 }
95
96 /** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */
97 /** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode }} DocumentItemMacro */
98 /** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */
99
100 /**
101 * Collects top-level plaintext tokens and macro nodes from the document CST.
102 *
103 * @param {CstNode} cst
104 * @returns {Array<DocumentItem>}
105 */
106 #collectDocumentItems(cst) {
107 const plaintextTokens = /** @type {IToken[]} */ (cst.children.plaintext || []);
108 const macroNodes = /** @type {CstNode[]} */ (cst.children.macro || []);
109
110 /** @type {Array<DocumentItem>} */
111 const items = [];
112
113 for (const token of plaintextTokens) {
114 if (typeof token.startOffset !== 'number' || typeof token.endOffset !== 'number') {
115 continue;
116 }
117
118 items.push({
119 type: 'plaintext',
120 startOffset: token.startOffset,
121 endOffset: token.endOffset,
122 token,
123 });
124 }
125
126 for (const macroNode of macroNodes) {
127 const children = macroNode.children || {};
128 const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
129
130 // If the end token was inserted during error recovery, treat this macro as plaintext
131 if (this.#isRecoveryToken(endToken)) {
132 // Flatten the incomplete macro: collect its tokens as plaintext but keep nested macros
133 this.#flattenIncompleteMacro(macroNode, endToken, items);
134 continue;
135 }
136
137 const range = this.#getMacroRange(macroNode);
138 items.push({
139 type: 'macro',
140 startOffset: range.startOffset,
141 endOffset: range.endOffset,
142 node: macroNode,
143 });
144 }
145
146 items.sort((a, b) => {
147 if (a.startOffset !== b.startOffset) {
148 return a.startOffset - b.startOffset;
149 }
150 return a.endOffset - b.endOffset;
151 });
152
153 return items;
154 }
155
156 /**
157 * Evaluates a single macro CST node, resolving any nested macros first.
158 *
159 * @param {CstNode} macroNode
160 * @param {EvaluationContext} context
161 * @returns {string}
162 */
163 #evaluateMacroNode(macroNode, context) {
164 const { text, env, resolveMacro } = context;
165
166 const children = macroNode.children || {};
167 const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
168 const name = identifierTokens[0]?.image || '';
169
170 const range = this.#getMacroRange(macroNode);
171 const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);
172 const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
173
174 const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset;
175 const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset;
176
177 // Extract argument nodes from the "arguments" rule (if present)
178 const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]);
179 const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
180
181 /** @type {string[]} */
182 const args = [];
183 /** @type {({ value: string } & TokenRange)[]} */
184 const evaluatedArguments = [];
185
186 for (const argNode of argumentNodes) {
187 const argValue = this.#evaluateArgumentNode(argNode, context);
188 args.push(argValue);
189
190 const location = this.#getArgumentLocation(argNode);
191 if (location) {
192 evaluatedArguments.push({
193 value: argValue,
194 ...location,
195 });
196 }
197 }
198
199 evaluatedArguments.sort((a, b) => a.startOffset - b.startOffset);
200
201 // Build the inner raw string between the braces, with nested macros resolved.
202 // This uses the already evaluated argument strings and preserves any text
203 // between arguments (such as separators or whitespace).
204 let rawInner = '';
205 if (innerStart <= innerEnd) {
206 let cursor = innerStart;
207
208 for (const entry of evaluatedArguments) {
209 if (entry.startOffset > cursor) {
210 rawInner += text.slice(cursor, entry.startOffset);
211 }
212
213 rawInner += entry.value;
214 cursor = entry.endOffset + 1;
215 }
216
217 if (cursor <= innerEnd) {
218 rawInner += text.slice(cursor, innerEnd + 1);
219 }
220 }
221
222 /** @type {MacroCall} */
223 const call = {
224 name,
225 args,
226 rawInner,
227 rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
228 range,
229 cstNode: macroNode,
230 env,
231 };
232
233 const value = resolveMacro(call);
234 const stringValue = typeof value === 'string' ? value : String(value ?? '');
235
236 return stringValue;
237 }
238
239 /**
240 * Evaluates a single argument node by resolving nested macros and reconstructing
241 * the original argument text.
242 *
243 * @param {CstNode} argNode
244 * @param {EvaluationContext} context
245 * @returns {string}
246 */
247 #evaluateArgumentNode(argNode, context) {
248 const location = this.#getArgumentLocation(argNode);
249 if (!location) {
250 return '';
251 }
252
253 const { text } = context;
254
255 const nestedMacros = /** @type {CstNode[]} */ ((argNode.children || {}).macro || []);
256
257 // If there are no nested macros, we can just return the original text
258 if (nestedMacros.length === 0) {
259 return text.slice(location.startOffset, location.endOffset + 1);
260 }
261
262 // If there are macros, evaluate them one by one in appearing order, inside the argument, before we return the resolved argument
263 const nestedWithRange = nestedMacros.map(node => ({
264 node,
265 range: this.#getMacroRange(node),
266 }));
267
268 nestedWithRange.sort((a, b) => a.range.startOffset - b.range.startOffset);
269
270 let result = '';
271 let cursor = location.startOffset;
272
273 for (const entry of nestedWithRange) {
274 if (entry.range.startOffset < cursor) {
275 continue;
276 }
277
278 result += text.slice(cursor, entry.range.startOffset);
279 result += this.#evaluateMacroNode(entry.node, context);
280 cursor = entry.range.endOffset + 1;
281 }
282
283 if (cursor <= location.endOffset) {
284 result += text.slice(cursor, location.endOffset + 1);
285 }
286
287 return result;
288 }
289
290 /**
291 * Computes the character range of a macro node based on its start/end tokens
292 * or its own location if those are not available.
293 *
294 * @param {CstNode} macroNode
295 * @returns {TokenRange}
296 */
297 #getMacroRange(macroNode) {
298 const startToken = /** @type {IToken?} */ (((macroNode.children || {})['Macro.Start'] || [])[0]);
299 const endToken = /** @type {IToken?} */ (((macroNode.children || {})['Macro.End'] || [])[0]);
300
301 if (startToken && endToken) {
302 return { startOffset: startToken.startOffset, endOffset: endToken.endOffset };
303 }
304 if (macroNode.location) {
305 return { startOffset: macroNode.location.startOffset, endOffset: macroNode.location.endOffset };
306 }
307 return { startOffset: 0, endOffset: 0 };
308 }
309
310 /**
311 * Flattens an incomplete macro node into document items.
312 * Tokens from the incomplete macro become plaintext, but nested complete macros are preserved.
313 *
314 * @param {CstNode} macroNode
315 * @param {IToken} excludeToken - The recovery-inserted token to exclude
316 * @param {Array<DocumentItem>} items - The items array to add to
317 */
318 #flattenIncompleteMacro(macroNode, excludeToken, items) {
319 const children = macroNode.children || {};
320
321 for (const key of Object.keys(children)) {
322 for (const element of children[key] || []) {
323 // Skip the recovery-inserted token
324 if (element === excludeToken) continue;
325
326 // Handle IToken - add as plaintext
327 if ('startOffset' in element && typeof element.startOffset === 'number') {
328 items.push({
329 type: 'plaintext',
330 startOffset: element.startOffset,
331 endOffset: element.endOffset ?? element.startOffset,
332 token: element,
333 });
334 }
335 // Handle nested CstNode (macro or argument)
336 else if ('children' in element) {
337 const nestedChildren = element.children || {};
338 const nestedEnd = /** @type {IToken?} */ ((nestedChildren['Macro.End'] || [])[0]);
339 const nestedStart = /** @type {IToken?} */ ((nestedChildren['Macro.Start'] || [])[0]);
340
341 // Check if this is a complete macro node
342 if (nestedStart && nestedEnd) {
343 if (!this.#isRecoveryToken(nestedEnd)) {
344 // Complete nested macro - add as macro item
345 const range = this.#getMacroRange(element);
346 items.push({
347 type: 'macro',
348 startOffset: range.startOffset,
349 endOffset: range.endOffset,
350 node: element,
351 });
352 } else {
353 // Another incomplete nested macro - recurse
354 this.#flattenIncompleteMacro(element, nestedEnd, items);
355 }
356 } else {
357 // Not a macro node (e.g., arguments, argument) - recurse into it
358 this.#flattenIncompleteMacro(element, excludeToken, items);
359 }
360 }
361 }
362 }
363 }
364
365 /**
366 * Checks if a token was inserted during Chevrotain's error recovery.
367 * Recovery tokens have `isInsertedInRecovery=true` or invalid offset values.
368 *
369 * @param {IToken|null|undefined} token
370 * @returns {boolean}
371 */
372 #isRecoveryToken(token) {
373 return token?.isInsertedInRecovery === true
374 || typeof token?.startOffset !== 'number'
375 || Number.isNaN(token?.startOffset);
376 }
377
378 /**
379 * Computes the character range of an argument node based on all its child
380 * tokens and nested macros.
381 *
382 * @param {CstNode} argNode
383 * @returns {TokenRange|null}
384 */
385 #getArgumentLocation(argNode) {
386 const children = argNode.children || {};
387 let startOffset = Number.POSITIVE_INFINITY;
388 let endOffset = Number.NEGATIVE_INFINITY;
389
390 for (const key of Object.keys(children)) {
391 for (const element of children[key] || []) {
392 if (this.#isCstNode(element)) {
393 const location = element.location;
394 if (!location) {
395 continue;
396 }
397
398 if (location.startOffset < startOffset) {
399 startOffset = location.startOffset;
400 }
401 if (location.endOffset > endOffset) {
402 endOffset = location.endOffset;
403 }
404 } else if (element) {
405 if (element.startOffset < startOffset) {
406 startOffset = element.startOffset;
407 }
408 if (element.endOffset > endOffset) {
409 endOffset = element.endOffset;
410 }
411 }
412 }
413 }
414
415 if (!Number.isFinite(startOffset) || !Number.isFinite(endOffset)) {
416 return null;
417 }
418
419 return { startOffset, endOffset };
420 }
421
422 /**
423 * Determines whether the given value is a CST node.
424 *
425 * @param {any} value
426 * @returns {value is CstNode}
427 */
428 #isCstNode(value) {
429 return !!value && typeof value === 'object' && 'name' in value && 'children' in value;
430 }
431}
432
433instance = MacroCstWalker.instance;
public/scripts/macros/engine/MacroDiagnostics.js+197 -0
@@ -0,0 +1,197 @@
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/**
7 * @typedef {Object} MacroErrorContext
8 * @property {string} [macroName]
9 * @property {MacroCall} [call]
10 * @property {MacroDefinition} [def]
11 */
12
13/**
14 * Options for creating a macro runtime error.
15 *
16 * @typedef {MacroErrorContext & { message: string }} MacroRuntimeErrorOptions
17 */
18
19/**
20 * Options for logging macro warnings or errors.
21 *
22 * @typedef {MacroErrorContext & { message: string, error?: any }} MacroLogOptions
23 */
24
25/**
26 * Creates an error representing a runtime macro invocation problem (such as
27 * arity or type mismatches). These errors are intended to be caught by the
28 * MacroEngine, which will log them as runtime warnings and leave the macro
29 * raw in the evaluated text.
30 *
31 * @param {MacroRuntimeErrorOptions} options
32 * @returns {Error}
33 */
34export function createMacroRuntimeError({ message, call, def, macroName }) {
35 const inferredName = inferMacroName(call, def, macroName);
36
37 const error = new Error(message);
38 error.name = 'MacroRuntimeError';
39 // @ts-ignore - custom tagging for downstream classification
40 error.isMacroRuntimeError = true;
41 // @ts-ignore - helpful metadata for debugging
42 error.macroName = inferredName;
43 // @ts-ignore - best-effort location information
44 error.macroRange = call && call.range ? call.range : null;
45 // @ts-ignore - attach raw call/definition for convenience
46 if (call) error.macroCall = call;
47 // @ts-ignore
48 if (def) error.macroDefinition = def;
49
50 return error;
51}
52
53/**
54 * Logs a macro runtime warning with consistent, helpful context. These
55 * correspond to issues in how a macro was written in the text (e.g. invalid
56 * arguments), not bugs in macro definitions or the engine itself.
57 *
58 * @param {MacroLogOptions} options
59 */
60export function logMacroRuntimeWarning({ message, call, def, macroName, error }) {
61 const payload = buildMacroPayload({ call, def, macroName, error });
62 console.warn('[Macro] Warning:', message, payload);
63}
64
65/**
66 * Logs an internal macro error (definition or engine bug) with a consistent
67 * schema. These are surfaced as red errors in the console.
68 *
69 * @param {MacroLogOptions} options
70 */
71export function logMacroInternalError({ message, call, macroName, error }) {
72 const payload = buildMacroPayload({ call, def: undefined, macroName, error });
73 console.error('[Macro] Error:', message, payload);
74}
75
76/**
77 * Logs a warning during macro registration.
78 *
79 * @param {{ message: string, macroName?: string, error?: any }} options
80 */
81export function logMacroRegisterWarning({ message, macroName, error = undefined }) {
82 const payload = buildMacroPayload({ macroName, error });
83 console.warn('[Macro] Warning:', message, payload);
84}
85
86/**
87 * Logs an error during macro registration. Used when registration fails
88 * and the macro will not be available.
89 *
90 * @param {{ message: string, macroName?: string, error?: any }} options
91 */
92export function logMacroRegisterError({ message, macroName, error = undefined }) {
93 const payload = buildMacroPayload({ macroName, error });
94 console.error('[Macro] Registration Error:', message, payload);
95}
96
97/**
98 * Logs a macro error with a consistent schema.
99 *
100 * @param {{ message: string, error?: any }} options
101 */
102export function logMacroGeneralError({ message, error }) {
103 console.error('[Macro] Error:', message, error);
104}
105
106/**
107 * Logs lexer/parser syntax warnings for the macro engine with a compact,
108 * human-readable payload.
109 *
110 * @param {{ phase: 'lexing', input: string, errors: ILexingError[] }|{ phase: 'parsing', input: string, errors: IRecognitionException[] }} options
111 */
112export function logMacroSyntaxWarning({ phase, input, errors }) {
113 if (!errors || errors.length === 0) {
114 return;
115 }
116
117 /** @type {{ message: string, line: number|null, column: number|null, length: number|null }[]} */
118 const issues = errors.map((err) => {
119 const hasOwnLine = typeof err.line === 'number';
120 const hasOwnColumn = typeof err.column === 'number';
121
122 const token = /** @type {{ startLine?: number, startColumn?: number, startOffset?: number, endOffset?: number }|undefined} */ (err.token);
123
124 const line = hasOwnLine ? err.line : (token && typeof token.startLine === 'number' ? token.startLine : null);
125 const column = hasOwnColumn ? err.column : (token && typeof token.startColumn === 'number' ? token.startColumn : null);
126
127 /** @type {number|null} */
128 let length = null;
129 if (typeof err.length === 'number') {
130 length = err.length;
131 } else if (token && typeof token.startOffset === 'number' && typeof token.endOffset === 'number') {
132 length = token.endOffset - token.startOffset + 1;
133 }
134
135 return {
136 message: err.message,
137 line,
138 column,
139 length,
140 };
141 });
142
143 const label = phase === 'lexing' ? 'Lexing' : 'Parsing';
144
145 /** @type {Record<string, any>} */
146 const payload = {
147 phase,
148 count: issues.length,
149 issues,
150 input,
151 };
152
153 console.warn('[Macro] Warning:', `${label} errors detected`, payload);
154}
155
156/**
157 * Builds a structured payload for macro logging.
158 *
159 * @param {MacroErrorContext & { error?: any }} ctx
160 */
161function buildMacroPayload({ call, def, macroName, error }) {
162 const inferredName = inferMacroName(call, def, macroName);
163
164 /** @type {Record<string, any>} */
165 const payload = {
166 macroName: inferredName,
167 };
168
169 if (call && call.range) payload.range = call.range;
170 if (call && typeof call.rawInner === 'string') payload.raw = call.rawInner;
171 if (call) payload.call = call;
172 if (def) payload.def = def;
173 if (error) payload.error = error;
174
175 return payload;
176}
177
178/**
179 * Infers the most appropriate macro name from the available context.
180 *
181 * @param {MacroCall} [call]
182 * @param {MacroDefinition} [def]
183 * @param {string} [explicit]
184 * @returns {string}
185 */
186function inferMacroName(call, def, explicit) {
187 if (typeof explicit === 'string' && explicit.trim()) {
188 return explicit.trim();
189 }
190 if (call && typeof call.name === 'string' && call.name.trim()) {
191 return call.name.trim();
192 }
193 if (def && typeof def.name === 'string' && def.name.trim()) {
194 return def.name.trim();
195 }
196 return 'unknown';
197}
public/scripts/macros/engine/MacroEngine.js+212 -0
@@ -0,0 +1,212 @@
1import { MacroParser } from './MacroParser.js';
2import { MacroCstWalker } from './MacroCstWalker.js';
3import { MacroRegistry } from './MacroRegistry.js';
4import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
5
6/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
7/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
8/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
9
10/**
11 * The singleton instance of the MacroEngine.
12 *
13 * @type {MacroEngine}
14 */
15let instance;
16export { instance as MacroEngine };
17
18class MacroEngine {
19 /** @type {MacroEngine} */ static #instance;
20 /** @type {MacroEngine} */ static get instance() { return MacroEngine.#instance ?? (MacroEngine.#instance = new MacroEngine()); }
21
22 constructor() { }
23
24 /**
25 * Evaluates a string containing macros and resolves them.
26 *
27 * @param {string} input - The input string to evaluate.
28 * @param {MacroEnv} env - The environment to pass to the macro handler.
29 * @returns {string} The resolved string.
30 */
31 evaluate(input, env) {
32 if (!input) {
33 return '';
34 }
35 const safeEnv = Object.freeze({ ...env });
36
37 const preProcessed = this.#runPreProcessors(input, safeEnv);
38
39 const { cst, lexingErrors, parserErrors } = MacroParser.parseDocument(preProcessed);
40
41 // For now, we log and still try to process what we can.
42 if (lexingErrors && lexingErrors.length > 0) {
43 logMacroSyntaxWarning({ phase: 'lexing', input, errors: lexingErrors });
44 }
45 if (parserErrors && parserErrors.length > 0) {
46 logMacroSyntaxWarning({ phase: 'parsing', input, errors: parserErrors });
47 }
48
49 // If the parser did not produce a valid CST, fall back to the original input.
50 if (!cst || typeof cst !== 'object' || !cst.children) {
51 logMacroGeneralError({ message: 'Macro parser produced an invalid CST. Returning original input.', error: { input, lexingErrors, parserErrors } });
52 return input;
53 }
54
55 let evaluated;
56 try {
57 evaluated = MacroCstWalker.evaluateDocument({
58 text: preProcessed,
59 cst,
60 env: safeEnv,
61 resolveMacro: this.#resolveMacro.bind(this),
62 });
63 } catch (error) {
64 logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });
65 return input;
66 }
67
68 const result = this.#runPostProcessors(evaluated, safeEnv);
69
70 return result;
71 }
72
73 /**
74 * Resolves a macro call.
75 *
76 * @param {MacroCall} call - The macro call to resolve.
77 * @returns {string} The resolved macro.
78 */
79 #resolveMacro(call) {
80 const { name, env } = call;
81
82 const raw = `{{${call.rawInner}}}`;
83 if (!name) return raw;
84
85 // First check if this is a dynamic macro to use. If so, we will create a temporary macro definition for it and use that over any registered macro.
86 /** @type {MacroDefinition?} */
87 let defOverride = null;
88 if (Object.hasOwn(env.dynamicMacros, name)) {
89 const impl = env.dynamicMacros[name];
90 defOverride = {
91 name,
92 aliases: [],
93 category: 'dynamic',
94 description: 'Dynamic macro',
95 minArgs: 0,
96 maxArgs: 0,
97 unnamedArgDefs: [],
98 list: null,
99 strictArgs: true, // Fail dynamic macros if they are called with arguments
100 returns: null,
101 returnType: 'string',
102 displayOverride: null,
103 exampleUsage: [],
104 source: { name: 'dynamic', isExtension: false, isThirdParty: false },
105 aliasOf: null,
106 aliasVisible: null,
107 handler: typeof impl === 'function' ? impl : () => impl,
108 };
109 }
110
111 // If not, check if the macro exists and is registered
112 if (!defOverride && !MacroRegistry.hasMacro(name)) {
113 return raw; // Unknown macro: keep macro syntax, but nested macros inside rawInner are already resolved.
114 }
115
116 try {
117 const result = MacroRegistry.executeMacro(call, { defOverride });
118
119 try {
120 return call.env.functions.postProcess(result);
121 } catch (error) {
122 logMacroInternalError({ message: `Macro "${name}" postProcess function failed.`, call, error });
123 return result;
124 }
125 } catch (error) {
126 const isRuntimeError = !!(error && (error.name === 'MacroRuntimeError' || error.isMacroRuntimeError));
127 if (isRuntimeError) {
128 logMacroRuntimeWarning({ message: (error.message || `Macro "${name}" execution failed.`), call, error });
129 } else {
130 logMacroInternalError({ message: `Macro "${name}" internal execution error.`, call, error });
131 }
132 return raw;
133 }
134 }
135
136 /**
137 * Runs pre-processors on the input text, before the engine processes the input.
138 *
139 * @param {string} text - The input text to process.
140 * @param {MacroEnv} env - The environment to pass to the macro handler.
141 * @returns {string} The processed text.
142 */
143 #runPreProcessors(text, env) {
144 let result = text;
145
146 // This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
147 // {{time_UTC-10}} => {{time::UTC-10}}
148 result = result.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => {
149 return `{{time::${utcOffset}}}`;
150 });
151
152 // Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
153 // These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
154 result = result.replace(/<USER>/gi, '{{user}}');
155 result = result.replace(/<BOT>/gi, '{{char}}');
156 result = result.replace(/<CHAR>/gi, '{{char}}');
157 result = result.replace(/<GROUP>/gi, '{{group}}');
158 result = result.replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}');
159
160 return result;
161 }
162
163 /**
164 * Runs post-processors on the input text, after the engine finished processing the input.
165 *
166 * @param {string} text - The input text to process.
167 * @param {MacroEnv} env - The environment to pass to the macro handler.
168 * @returns {string} The processed text.
169 */
170 #runPostProcessors(text, env) {
171 let result = text;
172
173 // Unescape braces: \{ → { and \} → }
174 // Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.
175 // We only need to remove the backslashes in post-processing.
176 result = result.replace(/\\([{}])/g, '$1');
177
178 // The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.
179 // To treat {{trim}} as it was before, we won't process it by the engine itself,
180 // but doing a regex replace on {{trim}} and the surrounding area, after all other macros have been processed.
181 result = result.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, '');
182
183 return result;
184 }
185
186 /**
187 * Normalizes macro results into a string.
188 * This mirrors the behavior of the legacy macro system in a simplified way.
189 *
190 * @param {any} value
191 * @returns {string}
192 */
193 normalizeMacroResult(value) {
194 if (value === null || value === undefined) {
195 return '';
196 }
197 if (value instanceof Date) {
198 return value.toISOString();
199 }
200 if (typeof value === 'object' || Array.isArray(value)) {
201 try {
202 return JSON.stringify(value);
203 } catch (_error) {
204 return String(value);
205 }
206 }
207
208 return String(value);
209 }
210}
211
212instance = MacroEngine.instance;
public/scripts/macros/engine/MacroEnv.types.js+56 -0
@@ -0,0 +1,56 @@
1/**
2 * Shared typedefs for the structured macro environment object (MacroEnv)
3 * used by the macro engine, registry, env builder, and macro definition
4 * modules. This file intentionally only contains JSDoc typedefs so that
5 * it can be imported purely for type information from multiple modules
6 * without creating runtime dependencies.
7 */
8
9/** @typedef {import('./MacroRegistry.js').MacroHandler} MacroHandler */
10
11/**
12 * @typedef {Object} MacroEnvNames
13 * @property {string} user
14 * @property {string} char
15 * @property {string} group
16 * @property {string} groupNotMuted
17 * @property {string} notChar
18 */
19
20/**
21 * @typedef {Object} MacroEnvCharacter
22 * @property {string} [description]
23 * @property {string} [personality]
24 * @property {string} [scenario]
25 * @property {string} [persona]
26 * @property {string} [charPrompt]
27 * @property {string} [charInstruction]
28 * @property {string} [mesExamplesRaw]
29 * @property {string} [charDepthPrompt]
30 * @property {string} [creatorNotes]
31 * @property {string} [version]
32 */
33
34/**
35 * @typedef {Object} MacroEnvSystem
36 * @property {string} model
37 */
38
39/**
40 * @typedef {Object} MacroEnvFunctions
41 * @property {() => string} [original]
42 * @property {(text: string) => string} postProcess
43 */
44
45/**
46 * @typedef {Object} MacroEnv
47 * @property {string} content - The full original input string that is being processed by the macro engine. This is the same value as substituteParams "content" and is provided so macros can build deterministic behavior based on the whole prompt when needed.
48 * @property {MacroEnvNames} names
49 * @property {MacroEnvCharacter} character
50 * @property {MacroEnvSystem} system
51 * @property {MacroEnvFunctions} functions
52 * @property {Object<string, string|MacroHandler>} dynamicMacros
53 * @property {Record<string, unknown>} extra
54 */
55
56export {};
public/scripts/macros/engine/MacroEnvBuilder.js+197 -0
@@ -0,0 +1,197 @@
1import { name1, name2, characters, getCharacterCardFieldsLazy, getGeneratingModel } from '../../../script.js';
2import { groups, selected_group } from '../../../scripts/group-chats.js';
3import { logMacroGeneralError } from './MacroDiagnostics.js';
4/**
5 * MacroEnvBuilder is responsible for constructing the MacroEnv object
6 * that is passed to macro handlers.
7 *
8 * It does **not** depend on the legacy regex macro system. Instead, it
9 * works from the same raw inputs that substituteParams receives plus a
10 * small bundle of global helpers, so it can eventually replace the
11 * environment-building block in substituteParams.
12 */
13
14/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
15
16/**
17 * @typedef {Object} MacroEnvRawContext
18 * @property {string} content
19 * @property {string|null} [name1Override]
20 * @property {string|null} [name2Override]
21 * @property {string|null} [original]
22 * @property {string|null} [groupOverride]
23 * @property {boolean} [replaceCharacterCard]
24 * @property {Record<string, any>|null} [dynamicMacros]
25 * @property {(value: string) => string} [postProcessFn]
26 */
27
28/**
29 * @typedef {(env: MacroEnv, ctx: MacroEnvRawContext) => void} MacroEnvProvider
30 */
31
32/**
33 * @enum {number} Exposed ordering buckets for providers. Callers can use envBuilder.providerOrder.* when registering providers.
34 */
35export const env_provider_order = {
36 EARLIEST: 0,
37 EARLY: 10,
38 NORMAL: 50,
39 LATE: 90,
40 LATEST: 100,
41};
42
43/** @type {MacroEnvBuilder} */
44let instance;
45export { instance as MacroEnvBuilder };
46
47class MacroEnvBuilder {
48 /** @type {MacroEnvBuilder} */ static #instance;
49 /** @type {MacroEnvBuilder} */ static get instance() { return MacroEnvBuilder.#instance ?? (MacroEnvBuilder.#instance = new MacroEnvBuilder()); }
50
51 /** @type {{ fn: MacroEnvProvider, order: env_provider_order }[]} */
52 #providers;
53
54 constructor() {
55 this.#providers = [];
56 }
57
58 /**
59 * Registers a provider that can augment the MacroEnv with additional
60 * data (for extensions, extra context, etc.).
61 *
62 * Should be called once during initialization.
63 *
64 * @param {MacroEnvProvider} provider
65 * @param {env_provider_order} [order=env_provider_order.NORMAL]
66 * @returns {void}
67 */
68 registerProvider(provider, order = env_provider_order.NORMAL) {
69 if (typeof provider !== 'function') throw new Error('Provider must be a function');
70 this.#providers.push({ fn: provider, order });
71 }
72
73 /**
74 * Builds a MacroEnv from the raw arguments that are conceptually the
75 * same as substituteParams receives, plus a bundle of global helpers.
76 *
77 * @param {MacroEnvRawContext} ctx
78 * @returns {MacroEnv}
79 */
80 buildFromRawEnv(ctx) {
81 // Create the env first, we will populate it step by step.
82 // Some fields are marked as required, so we have to fill them with dummy fields here
83 /** @type {MacroEnv} */
84 const env = {
85 content: ctx.content,
86 names: { user: '', char: '', group: '', groupNotMuted: '', notChar: '' },
87 character: {},
88 system: { model: '' },
89 functions: { postProcess: (x) => x },
90 dynamicMacros: {},
91 extra: {},
92 };
93
94 if (ctx.replaceCharacterCard) {
95 // Use lazy fields - each property is only resolved when accessed
96 const fields = getCharacterCardFieldsLazy();
97 if (fields) {
98 // Define lazy getters on env.character that delegate to fields
99 const fieldMappings = /** @type {const} */ ([
100 ['charPrompt', 'system'],
101 ['charInstruction', 'jailbreak'],
102 ['description', 'description'],
103 ['personality', 'personality'],
104 ['scenario', 'scenario'],
105 ['persona', 'persona'],
106 ['mesExamplesRaw', 'mesExamples'],
107 ['version', 'version'],
108 ['charDepthPrompt', 'charDepthPrompt'],
109 ['creatorNotes', 'creatorNotes'],
110 ]);
111 for (const [envKey, fieldKey] of fieldMappings) {
112 Object.defineProperty(env.character, envKey, {
113 get() { return fields[fieldKey] || ''; },
114 enumerable: true,
115 configurable: true,
116 });
117 }
118 }
119 }
120
121 // Names
122 env.names.user = ctx.name1Override ?? name1 ?? '';
123 env.names.char = ctx.name2Override ?? name2 ?? '';
124 env.names.group = getGroupValue(ctx, { currentChar: env.names.char, includeMuted: true });
125 env.names.groupNotMuted = getGroupValue(ctx, { currentChar: env.names.char, includeMuted: false });
126 env.names.notChar = getGroupValue(ctx, { currentChar: env.names.char, filterOutChar: true, includeUser: env.names.user });
127
128 // System
129 env.system.model = getGeneratingModel();
130
131 // Functions
132 // original (one-shot) and arbitrary additional values
133 if (typeof ctx.original === 'string') {
134 let originalSubstituted = false;
135 env.functions.original = () => {
136 if (originalSubstituted) return '';
137 originalSubstituted = true;
138 return ctx.original;
139 };
140 }
141 env.functions.postProcess = typeof ctx.postProcessFn === 'function' ? ctx.postProcessFn : (x) => x;
142
143 // Dynamic, per-call macros that should be visible only for this evaluation run.
144 if (ctx.dynamicMacros && typeof ctx.dynamicMacros === 'object') {
145 env.dynamicMacros = { ...ctx.dynamicMacros };
146 }
147
148 // Let providers augment the env, if any are registered. Apply them in order,
149 // so callers can influence when their provider runs relative to others.
150 const orderedProviders = this.#providers.slice().sort((a, b) => a.order - b.order);
151 for (const { fn } of orderedProviders) {
152 try {
153 fn(env, ctx);
154 } catch (e) {
155 // Provider errors should not break macro evaluation
156 logMacroGeneralError({ message: 'MacroEnvBuilder: Provider error', error: e });
157 }
158 }
159
160 return env;
161 }
162}
163
164instance = MacroEnvBuilder.instance;
165
166/**
167 * @param {MacroEnvRawContext} ctx
168 * @param {Object} options
169 * @param {string} [options.currentChar=null]
170 * @param {boolean} [options.includeMuted=false]
171 * @param {boolean} [options.filterOutChar=false]
172 * @param {string|null} [options.includeUser=null]
173 * @returns {string}
174 */
175function getGroupValue(ctx, { currentChar = null, includeMuted = false, filterOutChar = false, includeUser = null }) {
176 if (typeof ctx.groupOverride === 'string') {
177 return ctx.groupOverride;
178 }
179
180 if (!selected_group) return filterOutChar ? (includeUser || '') : (currentChar ?? '');
181
182 const groupEntry = Array.isArray(groups) ? groups.find(x => x && x.id === selected_group) : null;
183 const members = /** @type {string[]} */ (groupEntry?.members ?? []);
184 const disabledMembers = /** @type {string[]} */ (groupEntry?.disabled_members ?? []);
185
186 const names = Array.isArray(members)
187 ? members
188 .filter(((id) => includeMuted ? true : !disabledMembers.includes(id)))
189 .map(m => Array.isArray(characters) ? characters.find(c => c && c.avatar === m) : null)
190 .filter(c => !!c && typeof c.name === 'string')
191 .filter(c => !filterOutChar || c.name !== currentChar)
192 .map(c => c.name)
193 .join(', ')
194 : '';
195
196 return names;
197}
public/scripts/macros/engine/MacroLexer.js+239 -0
@@ -0,0 +1,239 @@
1import { chevrotain } from '../../../lib.js';
2const { createToken, Lexer } = chevrotain;
3
4/** @typedef {import('chevrotain').TokenType} TokenType */
5
6/** @enum {string} */
7const modes = {
8 plaintext: 'plaintext_mode',
9 macro_def: 'macro_def_mode',
10 macro_identifier_end: 'macro_identifier_end_mode',
11 macro_args: 'macro_args_mode',
12 macro_filter_modifer: 'macro_filter_modifer_mode',
13 macro_filter_modifier_end: 'macro_filter_modifier_end_mode',
14};
15
16/** @readonly */
17const Tokens = {
18 // General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'.
19 Plaintext: createToken({ name: 'Plaintext', pattern: /(?:[^{]|\{(?!\{))+/u, line_breaks: true }),
20 // Single literal '{' that appears immediately before a macro opener '{{'.
21 PlaintextOpenBrace: createToken({ name: 'Plaintext.OpenBrace', pattern: /\{(?=\{\{)/ }),
22
23 // General macro capture
24 Macro: {
25 Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),
26 // Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
27 // We need this, because this token is going to switch lexer mode, while the general identifier does not.
28 Flags: createToken({ name: 'Macro.Flag', pattern: /[!?#~/.$]/ }),
29 DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),
30 Identifier: createToken({ name: 'Macro.Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
31 // At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces
32 EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
33 BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
34 End: createToken({ name: 'Macro.End', pattern: /\}\}/ }),
35 },
36
37 // Captures that only appear inside arguments
38 Args: {
39 DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }),
40 Colon: createToken({ name: 'Args.Colon', pattern: /:/ }),
41 Equals: createToken({ name: 'Args.Equals', pattern: /=/ }),
42 Quote: createToken({ name: 'Args.Quote', pattern: /"/ }),
43 },
44
45 Filter: {
46 EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
47 Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
48 Identifier: createToken({ name: 'Filter.Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
49 // At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces
50 EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
51 },
52
53 // All tokens that can be captured inside a macro
54 Identifier: createToken({ name: 'Identifier', pattern: /[a-zA-Z][\w-_]*/ }),
55 WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),
56
57 // Capture unknown characters one by one, to still allow other tokens being matched once they are there.
58 // This includes any possible braces that is not the double closing braces as MacroEnd.
59 Unknown: createToken({ name: 'Unknown', pattern: /([^}]|\}(?!\}))/ }),
60
61 // TODO: Capture-all rest for now, that is not the macro end or opening of a new macro. Might be replaced later down the line.
62 Text: createToken({ name: 'Text', pattern: /.+(?=\}\}|\{\{)/, line_breaks: true }),
63
64 // DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches.
65 // Can be used in modes that don't have a "defined" end really, like when capturing a single argument, argument list, etc.
66 // Has to ALWAYS be the last token.
67 ModePopper: createToken({ name: 'ModePopper', pattern: () => [''], line_breaks: false, group: Lexer.SKIPPED }),
68};
69
70/** @type {Map<string,string>} Saves all token definitions that are marked as entering modes */
71const enterModesMap = new Map();
72
73const Def = {
74 modes: {
75 [modes.plaintext]: [
76 using(Tokens.Plaintext),
77 using(Tokens.PlaintextOpenBrace),
78 enter(Tokens.Macro.Start, modes.macro_def),
79 ],
80 [modes.macro_def]: [
81 exits(Tokens.Macro.End, modes.macro_def),
82
83 // An explicit double-slash will be treated above flags to consume, as it'll introduce a comment macro. Directly following is the args then.
84 enter(Tokens.Macro.DoubleSlash, modes.macro_args),
85
86 using(Tokens.Macro.Flags),
87
88 // We allow whitspaces inbetween flags or in front of the modifier
89 using(Tokens.WhiteSpace),
90
91 // Inside a macro, we will match the identifier
92 // Enter 'macro_identifier_end' mode automatically at the end of the identifier, so we don't match more than one identifier
93 enter(Tokens.Macro.Identifier, modes.macro_identifier_end),
94
95 // If none of the tokens above are found, this is an invalid macro at runtime.
96 // We still need to exit the mode to prevent lexer errors
97 exits(Tokens.ModePopper, modes.macro_def),
98 ],
99 [modes.macro_identifier_end]: [
100 // Valid options after a macro identifier: whitespace, colon/double-colon (captured), macro end braces, or output modifier pipe.
101 exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),
102 enter(Tokens.Macro.EndOfIdentifier, modes.macro_args, { andExits: modes.macro_identifier_end }),
103 ],
104 [modes.macro_args]: [
105 // Macro args allow nested macros
106 enter(Tokens.Macro.Start, modes.macro_def),
107
108 // We allow escaped pipes to not start output modifiers. We need to capture this first, before the pipe
109 using(Tokens.Filter.EscapedPipe),
110
111 // If at any place during args writing there is a pipe, we lex it as an output identifier, and then continue with lex its args
112 enter(Tokens.Filter.Pipe, modes.macro_filter_modifer),
113
114 using(Tokens.Args.DoubleColon),
115 using(Tokens.Args.Colon),
116 using(Tokens.Args.Equals),
117 using(Tokens.Args.Quote),
118 using(Tokens.Identifier),
119
120 using(Tokens.WhiteSpace),
121
122 // Last fallback, before we need to exit the mode, as we might have characters we (wrongly) haven't defined yet
123 using(Tokens.Unknown),
124
125 // Args are optional, and we don't know how long, so exit the mode to be able to capture the actual macro end
126 exits(Tokens.ModePopper, modes.macro_args),
127 ],
128 [modes.macro_filter_modifer]: [
129 using(Tokens.WhiteSpace),
130
131 enter(Tokens.Filter.Identifier, modes.macro_filter_modifier_end, { andExits: modes.macro_filter_modifer }),
132 ],
133 [modes.macro_filter_modifier_end]: [
134 // Valid options after a filter itenfier: whitespace, colon/double-colon (captured), macro end braces, or output modifier pipe.
135 exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),
136 exits(Tokens.Filter.EndOfIdentifier, modes.macro_filter_modifer),
137 ],
138 },
139 defaultMode: modes.plaintext,
140};
141
142/**
143 * The singleton instance of the MacroLexer.
144 *
145 * @type {MacroLexer}
146 */
147let instance;
148export { instance as MacroLexer };
149
150class MacroLexer extends Lexer {
151 /** @type {MacroLexer} */ static #instance;
152 /** @type {MacroLexer} */ static get instance() { return MacroLexer.#instance ?? (MacroLexer.#instance = new MacroLexer()); }
153
154 // Define the tokens
155 /** @readonly */ static tokens = Tokens;
156 /** @readonly */ static def = Def;
157 /** @readonly */ tokens = Tokens;
158 /** @readonly */ def = MacroLexer.def;
159
160 /** @private */
161 constructor() {
162 super(MacroLexer.def, {
163 traceInitPerf: true,
164 });
165 }
166
167 test(input) {
168 const result = this.tokenize(input);
169 return {
170 errors: result.errors,
171 groups: result.groups,
172 tokens: result.tokens.map(({ tokenType, ...rest }) => ({ type: tokenType.name, ...rest, tokenType: tokenType })),
173 };
174 }
175}
176
177instance = MacroLexer.instance;
178
179/**
180 * [Utility]
181 * Set push mode on the token definition.
182 * Can be used inside the token mode definition block.
183 *
184 * Marks the token to **enter** the following lexer mode.
185 *
186 * Optionally, you can specify the modes to exit when entering this mode.
187 *
188 * @param {TokenType} token - The token to modify
189 * @param {string} mode - The mode to set
190 * @param {object} [options={}] - Additional options
191 * @param {string} [options.andExits] - The modes to exit when entering this mode
192 * @returns {TokenType} The token again
193 */
194function enter(token, mode, { andExits = undefined } = {}) {
195 if (!token) throw new Error('Token must not be undefined');
196 if (enterModesMap.has(token.name) && enterModesMap.get(token.name) !== mode) {
197 throw new Error(`Token ${token.name} already is set to enter mode ${enterModesMap.get(token.name)}. The token definition are global, so they cannot be used to lead to different modes.`);
198 }
199
200 if (andExits) exits(token, andExits);
201
202 token.PUSH_MODE = mode;
203 enterModesMap.set(token.name, mode);
204 return token;
205}
206
207/**
208 * [Utility]
209 * Set pop mode on the token definition.
210 * Can be used inside the token mode definition block.
211 *
212 * Marks the token to **exit** the following lexer mode.
213 *
214 * @param {TokenType} token - The token to modify
215 * @param {string} mode - The mode to leave
216 * @returns {TokenType} The token again
217 */
218function exits(token, mode) {
219 if (!token) throw new Error('Token must not be undefined');
220 token.POP_MODE = !!mode; // Always set to true. We just use the mode here, so the linter thinks it was used. We just pass it in for clarity in the definition
221 return token;
222}
223
224/**
225 * [Utility]
226 * Can be used inside the token mode definition block.
227 *
228 * Marks the token to to just be used/consumed, and not exit or enter a mode.
229 *
230 * @param {TokenType} token - The token to modify
231 * @returns {TokenType} The token again
232 */
233function using(token) {
234 if (!token) throw new Error('Token must not be undefined');
235 if (enterModesMap.has(token.name)) {
236 throw new Error(`Token ${token.name} is already marked to enter a mode (${enterModesMap.get(token.name)}). The token definition are global, so they cannot be used to lead or stay differently.`);
237 }
238 return token;
239}
public/scripts/macros/engine/MacroParser.js+149 -0
@@ -0,0 +1,149 @@
1import { chevrotain } from '../../../lib.js';
2import { MacroLexer } from './MacroLexer.js';
3
4const { CstParser } = chevrotain;
5
6/** @typedef {import('chevrotain').TokenType} TokenType */
7/** @typedef {import('chevrotain').CstNode} CstNode */
8/** @typedef {import('chevrotain').ILexingError} ILexingError */
9/** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */
10
11/**
12 * The singleton instance of the MacroParser.
13 *
14 * @type {MacroParser}
15 */
16let instance;
17export { instance as MacroParser };
18
19class MacroParser extends CstParser {
20 /** @type {MacroParser} */ static #instance;
21 /** @type {MacroParser} */ static get instance() { return MacroParser.#instance ?? (MacroParser.#instance = new MacroParser()); }
22
23 /** @private */
24 constructor() {
25 super(MacroLexer.def, {
26 traceInitPerf: true,
27 nodeLocationTracking: 'full',
28 recoveryEnabled: true,
29 });
30 const Tokens = MacroLexer.tokens;
31
32 const $ = this;
33
34 // Top-level document rule that can handle both plaintext and macros
35 $.document = $.RULE('document', () => {
36 $.MANY(() => {
37 $.OR([
38 { ALT: () => $.CONSUME(Tokens.Plaintext, { LABEL: 'plaintext' }) },
39 { ALT: () => $.CONSUME(Tokens.PlaintextOpenBrace, { LABEL: 'plaintext' }) },
40 { ALT: () => $.SUBRULE($.macro) },
41 { ALT: () => $.CONSUME(Tokens.Macro.Start, { LABEL: 'plaintext' }) },
42 ]);
43 });
44 });
45
46 // Basic Macro Structure
47 $.macro = $.RULE('macro', () => {
48 $.CONSUME(Tokens.Macro.Start);
49 $.OR([
50 { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
51 { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
52 ]);
53 $.OPTION(() => $.SUBRULE($.arguments));
54 $.CONSUME(Tokens.Macro.End);
55 });
56
57 // Arguments Parsing
58 $.arguments = $.RULE('arguments', () => {
59 $.OR([
60 {
61 ALT: () => {
62 $.CONSUME(Tokens.Args.DoubleColon, { LABEL: 'separator' });
63 $.AT_LEAST_ONE_SEP({
64 SEP: Tokens.Args.DoubleColon,
65 DEF: () => $.SUBRULE($.argument, { LABEL: 'argument' }),
66 });
67 },
68 },
69 {
70 ALT: () => {
71 $.OPTION(() => {
72 $.CONSUME(Tokens.Args.Colon, { LABEL: 'separator' });
73 });
74 $.SUBRULE($.argumentAllowingColons, { LABEL: 'argument' });
75 },
76 // So, this is a bit hacky. But implemented below, the argument capture does explicitly exclude double colons
77 // from being captured as the first token. The potential ambiguity chevrotain claims here is not possible.
78 // It says stuff like <Args.DoubleColon, Identifier/Macro/Unknown> is possible in both branches, but it is not.
79 IGNORE_AMBIGUITIES: true,
80 },
81 ]);
82 });
83
84 // List the argument tokens here, as we need two rules, one to be able to parse with double colons and one without
85 const validArgumentTokens = [
86 { ALT: () => $.SUBRULE($.macro) }, // Nested Macros
87 { ALT: () => $.CONSUME(Tokens.Identifier) },
88 { ALT: () => $.CONSUME(Tokens.Unknown) },
89 { ALT: () => $.CONSUME(Tokens.Args.Colon) },
90 { ALT: () => $.CONSUME(Tokens.Args.Equals) },
91 { ALT: () => $.CONSUME(Tokens.Args.Quote) },
92 ];
93
94 $.argument = $.RULE('argument', () => {
95 $.MANY(() => {
96 $.OR([...validArgumentTokens]);
97 });
98 });
99 $.argumentAllowingColons = $.RULE('argumentAllowingColons', () => {
100 $.AT_LEAST_ONE(() => {
101 $.OR([
102 ...validArgumentTokens,
103 { ALT: () => $.CONSUME(Tokens.Args.DoubleColon) },
104 ]);
105 });
106 });
107
108 this.performSelfAnalysis();
109 }
110
111 /**
112 * Parses a document into a CST.
113 *
114 * @param {string} input
115 * @returns {{ cst: CstNode|null, errors: ({ message: string }|ILexingError|IRecognitionException)[] , lexingErrors: ILexingError[], parserErrors: IRecognitionException[] }}
116 */
117 parseDocument(input) {
118 if (!input) {
119 return { cst: null, errors: [{ message: 'Input is empty' }], lexingErrors: [], parserErrors: [] };
120 }
121
122 const lexingResult = MacroLexer.tokenize(input);
123
124 this.input = lexingResult.tokens;
125 const cst = this.document();
126
127 const errors = [
128 ...lexingResult.errors,
129 ...this.errors,
130 ];
131
132 return { cst, errors, lexingErrors: lexingResult.errors, parserErrors: this.errors };
133 }
134
135 test(input) {
136 const lexingResult = MacroLexer.tokenize(input);
137 // "input" is a setter which will reset the parser's state.
138 this.input = lexingResult.tokens;
139 const cst = this.macro();
140
141 // For testing purposes we need to actually persist the error messages in the object,
142 // otherwise the test cases cannot read those, as they don't have access to the exception object type.
143 const errors = this.errors.map(x => ({ message: x.message, ...x, stack: x.stack }));
144
145 return { cst, errors: errors };
146 }
147}
148
149instance = MacroParser.instance;
public/scripts/macros/engine/MacroRegistry.js+672 -0
@@ -0,0 +1,672 @@
1/** @typedef {import('chevrotain').CstNode} CstNode */
2/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
3/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
4
5import { isFalseBoolean, isTrueBoolean } from '../../utils.js';
6import { MacroEngine } from './MacroEngine.js';
7import { createMacroRuntimeError, logMacroRegisterError, logMacroRegisterWarning, logMacroRuntimeWarning } from './MacroDiagnostics.js';
8
9/**
10 * Enum of standard macro categories for grouping in documentation and autocomplete.
11 * Extensions may use these or define custom category strings.
12 *
13 * @readonly
14 * @enum {string}
15 */
16export const MacroCategory = Object.freeze({
17 /** Basic utilities and text manipulation (newline, noop, trim, reverse, comment) */
18 UTILITY: 'utility',
19 /** Randomization and dice rolling (random, pick, roll) */
20 RANDOM: 'random',
21 /** Participant names and name lists (user, char, group, notChar) */
22 NAMES: 'names',
23 /** Character card fields and persona (description, personality, scenario, mesExamples, persona) */
24 CHARACTER: 'character',
25 /** Chat history, messages, and swipes */
26 CHAT: 'chat',
27 /** Date, time, and duration macros */
28 TIME: 'time',
29 /** Local and global variable operations */
30 VARIABLE: 'variable',
31 /** Prompt templates for text completion (instruct sequences, system prompts, author's notes, context templates) */
32 PROMPTS: 'prompts',
33 /** Runtime application state (model, API, lastGenerationType, isMobile) */
34 STATE: 'state',
35 /** Macros that don't fit in any of the other categories, but don't really need/deserve their own */
36 MISC: 'misc',
37});
38
39/**
40 * Enum of standard macro value types for type checking and documentation.
41 * Used for both argument types and return types.
42 *
43 * @readonly
44 * @enum {string}
45 */
46export const MacroValueType = Object.freeze({
47 /** String value of any kind */
48 STRING: 'string',
49 /** Integer value (natural number, no decimal spaces) */
50 INTEGER: 'integer',
51 /** Number value (decimal spaces allowed, includes integers values) */
52 NUMBER: 'number',
53 /** Boolean value (true/false, 1/0, yes/no, on/off) */
54 BOOLEAN: 'boolean',
55});
56
57/**
58 * @typedef {Object} MacroDefinitionOptions
59 * @property {MacroAliasDef[]} [aliases] - Alternative names for this macro. Each alias creates a lookup entry pointing to the same definition.
60 * @property {MacroCategory|string} category - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.
61 * @property {number|MacroUnnamedArgDef[]} [unnamedArgs=0] - Specifies the macro's unnamed positional arguments. Can be a number (all required) or an array of definitions (supports optional args). Optional args must be a suffix.
62 * @property {boolean|MacroListSpec} [list] - Whether the macro allows a list of arguments (optional min and max values can be set). These arguments will be added AFTER the unnamed args.
63 * @property {boolean} [strictArgs=true] - Whether the macro should be strict about its arguments.
64 * @property {string} [description=''] - Add a description of what the macro does.
65 * @property {string} [returns] - Add a specific description of what the macro returns, if it is not obvious from the description.
66 * @property {MacroValueType|MacroValueType[]} [returnType=MacroValueType.STRING] - The type(s) this macro returns. Defaults to string.
67 * @property {string} [displayOverride] - Override the auto-generated macro signature for display (must include curly braces, e.g. "{{macro::arg}}").
68 * @property {string|string[]} [exampleUsage] - Example usage(s) shown in documentation (must include curly braces).
69 * @property {MacroHandler} handler - The handler function for the macro.
70 */
71
72/**
73 * @typedef {Object} MacroAliasDef
74 * @property {string} alias - The alias name.
75 * @property {boolean} [visible=true] - Whether this alias appears in documentation/autocomplete. Defaults to true.
76 */
77
78/**
79 * @typedef {Object} MacroUnnamedArgDef
80 * @property {string} name
81 * @property {boolean} [optional=false] - Whether this argument is optional. Optional args must form a contiguous suffix (no required args after an optional).
82 * @property {string} [defaultValue] - Default value for optional args. ONLY meaningful when optional is true. Shown in docs/autocomplete.
83 * @property {MacroValueType|MacroValueType[]} [type=MacroValueType.STRING] - Single type or array of accepted types.
84 * @property {string} [sampleValue]
85 * @property {string} [description]
86 */
87
88/**
89 * @typedef {Object} MacroListSpec
90 * @property {number} [min]
91 * @property {number} [max]
92 */
93
94/**
95 * @typedef {(context: MacroExecutionContext) => string} MacroHandler
96 */
97
98/**
99 * @typedef {Object} MacroExecutionContext
100 * @property {string} name
101 * @property {string[]} args - All unnamed arguments passed to the macro.
102 * @property {string[]} unnamedArgs - Unnamed positional arguments (both required and optional, up to the defined count).
103 * @property {string[]|null} list - List arguments (after unnamed args), or null if list is not enabled.
104 * @property {{ [key: string]: string }|null} namedArgs - Reserved for future named argument support.
105 * @property {string} raw
106 * @property {MacroEnv} env
107 * @property {CstNode|null} cstNode
108 * @property {{ startOffset: number, endOffset: number }|null} range
109 * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.
110 */
111
112/**
113 * @typedef {Object} MacroDefinition
114 * @property {string} name - Primary macro name.
115 * @property {MacroResolvedAlias[]} aliases - Parsed alias definitions for this macro.
116 * @property {MacroCategory|string} category
117 * @property {number} minArgs - Minimum number of unnamed args required (excludes optional args).
118 * @property {number} maxArgs - Maximum number of unnamed args accepted (includes optional args).
119 * @property {MacroUnnamedArgDef[]} unnamedArgDefs - Definitions for all unnamed positional arguments (required + optional).
120 * @property {{ min: number, max: (number|null) }|null} list
121 * @property {boolean} strictArgs
122 * @property {string} description
123 * @property {string|null} returns
124 * @property {MacroValueType|MacroValueType[]} returnType - The type(s) this macro returns.
125 * @property {string|null} displayOverride - Override for the auto-generated macro signature display.
126 * @property {string[]} exampleUsage - Example usage strings for documentation.
127 * @property {MacroHandler} handler
128 * @property {MacroSource} source
129 * @property {string|null} aliasOf - If this is an alias, the primary macro name this is an alias of. Can also be used to check if this is an alias macro.
130 * @property {boolean|null} aliasVisible - If this is an alias, whether this alias is visible in docs/autocomplete.
131 */
132
133/**
134 * @typedef {Object} MacroResolvedAlias
135 * @property {string} alias - The alias name.
136 * @property {boolean} visible - Whether this alias is visible in documentation/autocomplete.
137 */
138
139/**
140 * @typedef {Object} MacroSource
141 * @property {string} name - Source identifier (extension name or script path)
142 * @property {boolean} isExtension - True if registered from an extension
143 * @property {boolean} isThirdParty - True if registered from a third-party extension
144 */
145
146/**
147 * The singleton instance of the MacroRegistry.
148 *
149 * @type {MacroRegistry}
150 */
151let instance;
152export { instance as MacroRegistry };
153
154class MacroRegistry {
155 /** @type {MacroRegistry} */ static #instance;
156 /** @type {MacroRegistry} */ static get instance() { return MacroRegistry.#instance ?? (MacroRegistry.#instance = new MacroRegistry()); }
157
158 /** @type {Map<string, MacroDefinition>} */
159 #macros;
160
161 /**
162 * @private
163 */
164 constructor() {
165 /** @type {Map<string, MacroDefinition>} */
166 this.#macros = new Map();
167 }
168
169 /**
170 * Registers a macro with the registry.
171 * Errors during registration are caught and logged, the macro will not be registered, and the function returns null.
172 *
173 * @param {string} name - Macro name (identifier).
174 * @param {MacroDefinitionOptions} options - Macro registration options including handler and metadata.
175 * @returns {MacroDefinition|null} The registered definition, or null if registration failed.
176 */
177 registerMacro(name, options) {
178 // Extract name early for error logging
179 name = typeof name === 'string' ? name.trim() : String(name);
180
181 try {
182 if (typeof name !== 'string' || !name) throw new Error('Macro name must be a non-empty string');
183 if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
184
185 const {
186 aliases: rawAliases,
187 category: rawCategory,
188 unnamedArgs: rawUnnamedArgs,
189 list: rawList,
190 strictArgs: rawStrictArgs,
191 description: rawDescription,
192 returns: rawReturns,
193 returnType: rawReturnType,
194 displayOverride: rawDisplayOverride,
195 exampleUsage: rawExampleUsage,
196 handler,
197 } = options;
198
199 if (typeof handler !== 'function') throw new Error(`Macro "${name}" options.handler must be a function.`);
200
201 /** @type {MacroResolvedAlias[]} */
202 const aliases = [];
203 if (rawAliases !== undefined && rawAliases !== null) {
204 if (!Array.isArray(rawAliases)) throw new Error(`Macro "${name}" options.aliases must be an array.`);
205 for (const [i, aliasDef] of rawAliases.entries()) {
206 if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
207 if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
208 const aliasName = aliasDef.alias.trim();
209 if (aliasName === name) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name.`);
210 const visible = aliasDef.visible !== false; // Default to true
211 aliases.push({ alias: aliasName, visible });
212 }
213 }
214
215 if (typeof rawCategory !== 'string' || !rawCategory.trim()) throw new Error(`Macro "${name}" options.category must be a non-empty string.`);
216 const category = rawCategory.trim();
217
218 let minArgs = 0;
219 let maxArgs = 0;
220 /** @type {MacroUnnamedArgDef[]} */
221 let unnamedArgDefs = [];
222 if (rawUnnamedArgs !== undefined) {
223 if (Array.isArray(rawUnnamedArgs)) {
224 // Parse array of argument definitions with optional support
225 let foundOptional = false;
226 unnamedArgDefs = rawUnnamedArgs.map((def, index) => {
227 if (!def || typeof def !== 'object') throw new Error(`Macro "${name}" options.unnamedArgs[${index}] must be an object when using argument definitions.`);
228 if (typeof def.name !== 'string' || !def.name.trim()) throw new Error(`Macro "${name}" options.unnamedArgs[${index}].name must be a non-empty string when using argument definitions.`);
229
230 // Validate: no required args after optional
231 if (foundOptional && !def.optional) {
232 throw new Error(`Macro "${name}" options.unnamedArgs[${index}] is required but follows an optional argument. Optional args must be a suffix.`);
233 }
234 if (def.optional) foundOptional = true;
235
236 /** @type {MacroUnnamedArgDef} */
237 const normalized = {
238 name: def.name.trim(),
239 optional: def.optional || false,
240 defaultValue: def.defaultValue?.trim(),
241 type: Array.isArray(def.type) && def.type.length === 0 ? 'string' : def.type ?? 'string',
242 sampleValue: def.sampleValue?.trim(),
243 description: typeof def.description === 'string' ? def.description : undefined,
244 };
245
246 const validTypes = ['string', 'integer', 'number', 'boolean'];
247 const type = Array.isArray(normalized.type) ? normalized.type : [normalized.type];
248 if (type.some(t => !validTypes.includes(t))) {
249 throw new Error(`Macro "${name}" options.unnamedArgs[${index}].type must be one of "string", "integer", "number", or "boolean" when provided.`);
250 }
251
252 return normalized;
253 });
254
255 // Compute minArgs (required count) and maxArgs (total count)
256 maxArgs = unnamedArgDefs.length;
257 minArgs = unnamedArgDefs.findIndex(d => d.optional);
258 if (minArgs === -1) minArgs = maxArgs; // No optional args, all are required
259 } else if (typeof rawUnnamedArgs === 'number') {
260 if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {
261 throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);
262 }
263 minArgs = rawUnnamedArgs;
264 maxArgs = rawUnnamedArgs;
265 unnamedArgDefs = Array.from({ length: rawUnnamedArgs }, (_, i) => ({
266 name: `arg${i + 1}`,
267 optional: false,
268 type: 'string',
269 sampleValue: `arg${i + 1}`,
270 }));
271 } else {
272 throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer or an array of argument definitions when provided.`);
273 }
274 }
275
276 /** @type {{ min: number, max: (number|null) }|null} */
277 let list = null;
278 if (rawList !== undefined) {
279 if (typeof rawList === 'boolean') {
280 list = rawList ? { min: 0, max: null } : null;
281 } else if (typeof rawList === 'object' && rawList !== null) {
282 if (typeof rawList.min !== 'number' || rawList.min < 0) throw new Error(`Macro "${name}" options.list.min must be a non-negative integer when provided.`);
283 if (rawList.max !== undefined && typeof rawList.max !== 'number') throw new Error(`Macro "${name}" options.list.max must be a number when provided.`);
284 if (rawList.max !== undefined && rawList.max < rawList.min) throw new Error(`Macro "${name}" options.list.max must be greater than or equal to options.list.min.`);
285 list = { min: rawList.min, max: rawList.max ?? null };
286 } else {
287 throw new Error(`Macro "${name}" options.list must be a boolean or an object with numeric min/max when provided.`);
288 }
289 }
290
291 let strictArgs = true;
292 if (rawStrictArgs !== undefined) {
293 if (typeof rawStrictArgs !== 'boolean') throw new Error(`Macro "${name}" options.strictArgs must be a boolean when provided.`);
294 strictArgs = rawStrictArgs;
295 }
296
297 let description = '<no description>';
298 if (rawDescription !== undefined) {
299 if (typeof rawDescription !== 'string') throw new Error(`Macro "${name}" options.description must be a string when provided.`);
300 description = rawDescription;
301 }
302
303 let returns = null;
304 if (rawReturns !== undefined && rawReturns !== null) {
305 if (typeof rawReturns !== 'string') throw new Error(`Macro "${name}" options.returns must be a string when provided.`);
306 returns = rawReturns || '<empty string>';
307 }
308
309 // Process and validate returnType (defaults to 'string')
310 const validTypes = ['string', 'integer', 'number', 'boolean'];
311 let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');
312 if (rawReturnType !== undefined && rawReturnType !== null) {
313 // Normalize to non-empty value or default
314 returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;
315 // Validate all types
316 const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];
317 if (typesToValidate.some(t => !validTypes.includes(t))) {
318 throw new Error(`Macro "${name}" options.returnType must be one of "string", "integer", "number", or "boolean" (or an array of these) when provided.`);
319 }
320 }
321
322 let displayOverride = null;
323 if (rawDisplayOverride !== undefined && rawDisplayOverride !== null) {
324 if (typeof rawDisplayOverride !== 'string') throw new Error(`Macro "${name}" options.displayOverride must be a string when provided.`);
325 displayOverride = rawDisplayOverride.trim();
326 if (displayOverride && !displayOverride.startsWith('{{')) {
327 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.displayOverride should include curly braces. Auto-wrapping.` });
328 displayOverride = `{{${displayOverride}}}`;
329 }
330 }
331
332 /** @type {string[]} */
333 let exampleUsage = [];
334 if (rawExampleUsage !== undefined && rawExampleUsage !== null) {
335 const examples = Array.isArray(rawExampleUsage) ? rawExampleUsage : [rawExampleUsage];
336 for (const [i, ex] of examples.entries()) {
337 if (typeof ex !== 'string') throw new Error(`Macro "${name}" options.exampleUsage[${i}] must be a string.`);
338 let trimmed = ex.trim();
339 if (trimmed && !trimmed.startsWith('{{')) {
340 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.exampleUsage[${i}] should include curly braces. Auto-wrapping.` });
341 trimmed = `{{${trimmed}}}`;
342 }
343 if (trimmed) exampleUsage.push(trimmed);
344 }
345 }
346
347 if (this.#macros.has(name)) {
348 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
349 }
350
351 // Detect extension/third-party status from call stack
352 const { isExtension, isThirdParty, source } = detectMacroSource();
353
354 /** @type {MacroDefinition} */
355 const definition = {
356 name: name,
357 aliases,
358 category,
359 minArgs,
360 maxArgs,
361 unnamedArgDefs,
362 list,
363 strictArgs,
364 description,
365 returns,
366 returnType,
367 displayOverride,
368 exampleUsage,
369 handler,
370 source: {
371 name: source,
372 isExtension,
373 isThirdParty,
374 },
375 aliasOf: null,
376 aliasVisible: null,
377 };
378
379 this.#macros.set(name, definition);
380
381 // Register alias entries pointing to the same definition
382 for (const { alias, visible } of aliases) {
383 if (this.#macros.has(alias)) {
384 logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
385 }
386 /** @type {MacroDefinition} */
387 const aliasEntry = {
388 ...definition,
389 name: alias, // The lookup name is the alias
390 aliasOf: name,
391 aliasVisible: visible,
392 };
393 this.#macros.set(alias, aliasEntry);
394 }
395
396 return definition;
397 } catch (error) {
398 logMacroRegisterError({
399 message: `Failed to register macro "${name}". The macro will not be available.`,
400 macroName: name,
401 error,
402 });
403 return null;
404 }
405 }
406
407 /**
408 * Unregisters a macro.
409 *
410 * @param {string} name - Macro name (identifier).
411 * @returns {boolean} True if a macro was removed.
412 */
413 unregisterMacro(name) {
414 if (typeof name !== 'string' || !name.trim()) throw new Error('Macro name must be a non-empty string');
415 name = name.trim();
416 return this.#macros.delete(name);
417 }
418
419 /**
420 * Checks whether a macro with the given name is registered.
421 *
422 * @param {string} name - Macro name (identifier).
423 * @returns {boolean}
424 */
425 hasMacro(name) {
426 if (typeof name !== 'string' || !name.trim()) return false;
427 name = name.trim();
428 return this.#macros.has(name);
429 }
430
431 /**
432 * Returns the macro definition for a given name.
433 *
434 * @param {string} name - Macro name (identifier).
435 * @returns {MacroDefinition|undefined}
436 */
437 getMacro(name) {
438 if (typeof name !== 'string' || !name.trim()) return undefined;
439 name = name.trim();
440 return this.#macros.get(name);
441 }
442
443 /**
444 * Returns the primary (non-alias) definition for a macro.
445 * If given an alias name, returns the primary definition it points to.
446 *
447 * @param {string} name - Macro name or alias.
448 * @returns {MacroDefinition|undefined}
449 */
450 getPrimaryMacro(name) {
451 const def = this.getMacro(name);
452 if (!def) return undefined;
453 return def.aliasOf ? this.getMacro(def.aliasOf) : def;
454 }
455
456 /**
457 * Returns an array of all registered macros.
458 *
459 * @param {Object} [options] - Filter options.
460 * @param {boolean} [options.excludeAliases=false] - If true, excludes alias entries (only returns primary definitions).
461 * @param {boolean} [options.excludeHiddenAliases=false] - If true, excludes alias entries where visible=false.
462 * @returns {MacroDefinition[]}
463 */
464 getAllMacros({ excludeAliases = false, excludeHiddenAliases = false } = {}) {
465 let macros = Array.from(this.#macros.values());
466 if (excludeAliases) {
467 macros = macros.filter(m => !m.aliasOf);
468 } else if (excludeHiddenAliases) {
469 macros = macros.filter(m => !m.aliasOf || m.aliasVisible !== false);
470 }
471 return macros;
472 }
473
474 /**
475 * Executes a macro for a given call.
476 *
477 * @param {MacroCall} call - Macro call information.
478 * @param {Object} [options] - Additional options.
479 * @param {MacroDefinition} [options.defOverride] - Override the macro definition.
480 * @returns {string}
481 */
482 executeMacro(call, { defOverride } = {}) {
483 const name = call.name;
484 const def = defOverride || this.getMacro(name);
485 if (!def) {
486 throw new Error(`Macro "${name}" is not registered`);
487 }
488
489 const args = Array.isArray(call.args) ? call.args : [];
490
491 if (!isArgsValid(def, args)) {
492 const expectedMin = def.list ? def.minArgs + def.list.min : def.minArgs;
493 const expectedMax = def.list && def.list.max !== null
494 ? def.maxArgs + def.list.max
495 : (def.list ? null : def.maxArgs);
496
497 const expectation = (() => {
498 if (expectedMax !== null && expectedMax !== expectedMin) return `between ${expectedMin} and ${expectedMax}`;
499 if (expectedMax !== null && expectedMax === expectedMin) return `${expectedMin}`;
500 return `at least ${expectedMin}`;
501 })();
502
503 const message = `Macro "${def.name}" called with ${args.length} unnamed arguments but expects ${expectation}.`;
504 if (def.strictArgs) {
505 throw createMacroRuntimeError({ message, call, def });
506 }
507 logMacroRuntimeWarning({ message, call, def });
508 }
509
510 // Compute unnamed args (required + optional, up to maxArgs)
511 const unnamedArgsCount = Math.min(args.length, def.maxArgs);
512 const unnamedArgsValues = args.slice(0, unnamedArgsCount);
513 const listValues = !def.list ? null : args.length > def.maxArgs ? args.slice(def.maxArgs) : [];
514
515 // Perform best-effort type validation for documented positional arguments.
516 // This can throw an error if the arguments are invalid.
517 validateArgTypes(call, def, unnamedArgsValues);
518
519 const namedArgs = null;
520
521 /** @type {MacroExecutionContext} */
522 const executionContext = {
523 name: def.name,
524 args,
525 unnamedArgs: unnamedArgsValues,
526 list: listValues,
527 namedArgs,
528 raw: call.rawInner,
529 env: call.env,
530 cstNode: call.cstNode,
531 range: call.range,
532 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
533 };
534
535 const result = def.handler(executionContext);
536 return executionContext.normalize(result);
537 }
538}
539
540instance = MacroRegistry.instance;
541
542/**
543 * Validates the arguments for a macro definition.
544 * Supports required args (minArgs), optional args (up to maxArgs), and list tail.
545 *
546 * @param {MacroDefinition} def - Macro definition.
547 * @param {any[]} args - Arguments to validate.
548 * @returns {boolean} True if the arguments are valid, false otherwise.
549 */
550function isArgsValid(def, args) {
551 const hasListArgs = def.list !== null;
552
553 // Without list: args must be between minArgs and maxArgs (inclusive)
554 if (!hasListArgs) {
555 return args.length >= def.minArgs && args.length <= def.maxArgs;
556 }
557
558 // With list: args must be at least minArgs + list.min
559 const minRequired = def.minArgs + def.list.min;
560 if (args.length < minRequired) return false;
561
562 // List items are everything after maxArgs positional slots
563 const listCount = Math.max(0, args.length - def.maxArgs);
564 if (def.list.max !== null && listCount > def.list.max) return false;
565
566 return true;
567}
568
569/**
570 * Performs type validation for unnamed positional arguments using the metadata
571 * defined on the macro definition. When strictArgs is true, invalid argument
572 * types cause an error to be thrown. When strictArgs is false, only warnings
573 * are logged and execution continues.
574 *
575 * @param {MacroCall} call
576 * @param {MacroDefinition} def
577 * @param {string[]} unnamedArgs
578 */
579function validateArgTypes(call, def, unnamedArgs) {
580 if (def.unnamedArgDefs.length === 0) return;
581
582 const defs = def.unnamedArgDefs;
583 const count = Math.min(defs.length, unnamedArgs.length);
584 for (let i = 0; i < count; i++) {
585 const argDef = defs[i];
586 const value = unnamedArgs[i];
587 if (!argDef || !argDef.type || typeof value !== 'string') {
588 // Misconfigured macro definition: always surface as an error.
589 throw new Error(`Macro "${call.name}" (position ${i + 1}) has invalid definition or type.`);
590 }
591
592 const types = Array.isArray(argDef.type) ? argDef.type : [argDef.type];
593 if (!types.some(type => isValueOfType(value, type))) {
594 const argName = argDef.name || `Argument ${i + 1}`;
595 const optionalLabel = argDef.optional ? ' (optional)' : '';
596 const message = `Macro "${call.name}" (position ${i + 1}${optionalLabel}) argument "${argName}" expected type ${argDef.type} but got value "${value}".`;
597 if (def.strictArgs) {
598 throw createMacroRuntimeError({ message, call, def: def });
599 }
600 logMacroRuntimeWarning({ message, call, def: def });
601 }
602 }
603}
604
605/**
606 * Checks whether a string value conforms to the given macro argument type.
607 *
608 * @param {string} value
609 * @param {MacroValueType} type
610 * @returns {boolean}
611 */
612function isValueOfType(value, type) {
613 const trimmed = value.trim();
614
615 if (type === 'string') {
616 return true;
617 }
618 if (type === 'integer') {
619 return /^-?\d+$/.test(trimmed);
620 }
621 if (type === 'number') {
622 const n = Number(trimmed);
623 return Number.isFinite(n);
624 }
625 if (type === 'boolean') {
626 return isTrueBoolean(trimmed) || isFalseBoolean(trimmed);
627 }
628
629 // Unknown type: treat it as invalid.
630 return false;
631}
632
633/**
634 * Detects the source of a macro registration from the call stack.
635 * Similar to how SlashCommandParser detects command sources.
636 *
637 * @returns {{ isExtension: boolean, isThirdParty: boolean, source: string }}
638 */
639function detectMacroSource() {
640 const stack = new Error().stack?.split('\n').map(line => line.trim()) ?? [];
641
642 const isExtension = stack.some(line => line.includes('/scripts/extensions/'));
643 const isThirdParty = stack.some(line => line.includes('/scripts/extensions/third-party/'));
644
645 let source = 'unknown';
646 if (isThirdParty) {
647 const match = stack.find(line => line.includes('/scripts/extensions/third-party/'));
648 if (match) {
649 source = match.replace(/^.*?\/scripts\/extensions\/third-party\/([^/]+)\/.*$/, '$1');
650 }
651 } else if (isExtension) {
652 const match = stack.find(line => line.includes('/scripts/extensions/'));
653 if (match) {
654 source = match.replace(/^.*?\/scripts\/extensions\/([^/]+)\/.*$/, '$1');
655 }
656 } else {
657 // Find the first meaningful caller outside MacroRegistry
658 const callerIdx = stack.findIndex(line =>
659 line.includes('registerMacro') && line.includes('MacroRegistry'),
660 );
661 if (callerIdx >= 0 && callerIdx + 1 < stack.length) {
662 const callerLine = stack[callerIdx + 1];
663 // Extract script path from stack frame
664 const scriptMatch = callerLine.match(/\/((?:scripts\/)?(?:macros\/)?[^/]+\.js)/);
665 if (scriptMatch) {
666 source = scriptMatch[1];
667 }
668 }
669 }
670
671 return { isExtension, isThirdParty, source };
672}
public/scripts/macros/macro-system.js+83 -0
@@ -0,0 +1,83 @@
1/**
2 * Central entry point for the new macro system.
3 *
4 * Exposes the MacroEngine / MacroRegistry singletons and provides a
5 * single registerMacros() function that wires up all built-in macro
6 * definition sets (core, env, state, chat, time, variables, instruct).
7 */
8
9// Engine singletons and enums
10import { MacroEngine } from './engine/MacroEngine.js';
11import { MacroRegistry, MacroCategory, MacroValueType } from './engine/MacroRegistry.js';
12import { MacroLexer } from './engine/MacroLexer.js';
13import { MacroParser } from './engine/MacroParser.js';
14import { MacroCstWalker } from './engine/MacroCstWalker.js';
15import { MacroEnvBuilder } from './engine/MacroEnvBuilder.js';
16
17// Macro definition groups
18import { registerCoreMacros } from './definitions/core-macros.js';
19import { registerEnvMacros } from './definitions/env-macros.js';
20import { registerStateMacros } from './definitions/state-macros.js';
21import { registerChatMacros } from './definitions/chat-macros.js';
22import { registerTimeMacros } from './definitions/time-macros.js';
23import { registerVariableMacros } from './definitions/variable-macros.js';
24import { registerInstructMacros } from './definitions/instruct-macros.js';
25
26// Re-export the category enum for external use
27export { MacroCategory, MacroValueType };
28
29// Re-export most-used jsdoc definitions
30/** @typedef {import('./engine/MacroRegistry.js').MacroDefinitionOptions} MacroDefinitionOptions */
31/** @typedef {import('./engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
32/** @typedef {import('./engine/MacroRegistry.js').MacroUnnamedArgDef} MacroUnnamedArgDef */
33/** @typedef {import('./engine/MacroRegistry.js').MacroListSpec} MacroListSpec */
34/** @typedef {import('./engine/MacroRegistry.js').MacroHandler} MacroHandler */
35/** @typedef {import('./engine/MacroRegistry.js').MacroExecutionContext} MacroExecutionContext */
36
37/** @typedef {import('chevrotain').CstNode} CstNode */
38/** @typedef {import('./engine/MacroEnv.types.js').MacroEnv} MacroEnv */
39/** @typedef {import('./engine/MacroEnv.types.js').MacroEnvNames} MacroEnvNames */
40/** @typedef {import('./engine/MacroEnv.types.js').MacroEnvCharacter} MacroEnvCharacter */
41/** @typedef {import('./engine/MacroEnv.types.js').MacroEnvSystem} MacroEnvSystem */
42/** @typedef {import('./engine/MacroEnv.types.js').MacroEnvFunctions} MacroEnvFunctions */
43
44export const macros = {
45 // engine singletons
46 engine: MacroEngine,
47 registry: MacroRegistry,
48 envBuilder: MacroEnvBuilder,
49 lexer: MacroLexer,
50 parser: MacroParser,
51 cstWalker: MacroCstWalker,
52
53 // enums
54 category: MacroCategory,
55
56 // shorthand functions
57 register: MacroRegistry.registerMacro.bind(MacroRegistry),
58};
59
60/**
61 * Registers all built-in macros in a well-defined order.
62 * Intended to be called once during app initialization.
63 */
64export function initRegisterMacros() {
65 // Core utilities and generic helpers
66 registerCoreMacros();
67
68 // Env / character / system / extras
69 registerEnvMacros();
70
71 // Runtime state tracking (eventSource etc.)
72 registerStateMacros();
73
74 // Chat/history inspection macros
75 registerChatMacros();
76
77 // Time / date / durations
78 registerTimeMacros();
79
80 // Variable and instruct macros
81 registerVariableMacros();
82 registerInstructMacros();
83}
public/scripts/power-user.js+7 -0
@@ -305,6 +305,7 @@ export const power_user = {
305 custom_stopping_strings_macro: true,305 custom_stopping_strings_macro: true,
306 fuzzy_search: false,306 fuzzy_search: false,
307 encode_tags: false,307 encode_tags: false,
308 experimental_macro_engine: false,
308 servers: [],309 servers: [],
309 bogus_folders: false,310 bogus_folders: false,
310 zoomed_avatar_magnification: false,311 zoomed_avatar_magnification: false,
@@ -1664,6 +1665,7 @@ export async function loadPowerUserSettings(settings, data) {
1664 $('#persona_allow_multi_connections').prop('checked', power_user.persona_allow_multi_connections);1665 $('#persona_allow_multi_connections').prop('checked', power_user.persona_allow_multi_connections);
1665 $('#persona_auto_lock').prop('checked', power_user.persona_auto_lock);1666 $('#persona_auto_lock').prop('checked', power_user.persona_auto_lock);
1666 $('#encode_tags').prop('checked', power_user.encode_tags);1667 $('#encode_tags').prop('checked', power_user.encode_tags);
1668 $('#experimental_macro_engine').prop('checked', power_user.experimental_macro_engine);
1667 $('#example_messages_behavior').val(getExampleMessagesBehavior());1669 $('#example_messages_behavior').val(getExampleMessagesBehavior());
1668 $(`#example_messages_behavior option[value="${getExampleMessagesBehavior()}"]`).prop('selected', true);1670 $(`#example_messages_behavior option[value="${getExampleMessagesBehavior()}"]`).prop('selected', true);
1669 $('#instruct_derived').parent().find('i').toggleClass('toggleEnabled', !!power_user.instruct_derived);1671 $('#instruct_derived').parent().find('i').toggleClass('toggleEnabled', !!power_user.instruct_derived);
@@ -4009,6 +4011,11 @@ jQuery(() => {
4009 saveSettingsDebounced();4011 saveSettingsDebounced();
4010 });4012 });
40114013
4014 $('#experimental_macro_engine').on('input', function () {
4015 power_user.experimental_macro_engine = !!$(this).prop('checked');
4016 saveSettingsDebounced();
4017 });
4018
4012 $('#disable_group_trimming').on('input', function () {4019 $('#disable_group_trimming').on('input', function () {
4013 power_user.disable_group_trimming = !!$(this).prop('checked');4020 power_user.disable_group_trimming = !!$(this).prop('checked');
4014 saveSettingsDebounced();4021 saveSettingsDebounced();
public/scripts/reasoning.js+16 -4
@@ -4,7 +4,7 @@ import {
4import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveChatDebounced, saveSettingsDebounced, substituteParams, syncMesToSwipe, updateMessageBlock } from '../script.js';4import { chat, closeMessageEditor, event_types, eventSource, main_api, messageFormatting, saveChatConditional, saveChatDebounced, saveSettingsDebounced, substituteParams, syncMesToSwipe, updateMessageBlock } from '../script.js';
5import { getRegexedString, regex_placement } from './extensions/regex/engine.js';5import { getRegexedString, regex_placement } from './extensions/regex/engine.js';
6import { getCurrentLocale, t, translate } from './i18n.js';6import { getCurrentLocale, t, translate } from './i18n.js';
7import { MacrosParser } from './macros.js';7import { macros, MacroCategory } from './macros/macro-system.js';
8import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';8import { chat_completion_sources, getChatCompletionModel, oai_settings } from './openai.js';
9import { Popup } from './popup.js';9import { Popup } from './popup.js';
10import { performFuzzySearch, power_user } from './power-user.js';10import { performFuzzySearch, power_user } from './power-user.js';
@@ -1000,9 +1000,21 @@ function registerReasoningSlashCommands() {
1000}1000}
10011001
1002function registerReasoningMacros() {1002function registerReasoningMacros() {
1003 MacrosParser.registerMacro('reasoningPrefix', () => power_user.reasoning.prefix, t`Reasoning Prefix`);1003 macros.register('reasoningPrefix', {
1004 MacrosParser.registerMacro('reasoningSuffix', () => power_user.reasoning.suffix, t`Reasoning Suffix`);1004 category: MacroCategory.PROMPTS,
1005 MacrosParser.registerMacro('reasoningSeparator', () => power_user.reasoning.separator, t`Reasoning Separator`);1005 description: t`The prefix string used before reasoning blocks`,
1006 handler: () => power_user.reasoning.prefix,
1007 });
1008 macros.register('reasoningSuffix', {
1009 category: MacroCategory.PROMPTS,
1010 description: t`The suffix string used after reasoning blocks`,
1011 handler: () => power_user.reasoning.suffix,
1012 });
1013 macros.register('reasoningSeparator', {
1014 category: MacroCategory.PROMPTS,
1015 description: t`The separator between thinking content and response`,
1016 handler: () => power_user.reasoning.separator,
1017 });
1006}1018}
10071019
1008function setReasoningEventHandlers() {1020function setReasoningEventHandlers() {
public/scripts/slash-commands.js+12 -10
@@ -5613,13 +5613,15 @@ export async function setSlashCommandAutoComplete(textarea, isFloating = false)
5613 );5613 );
5614 return ac;5614 return ac;
5615}5615}
5616/**@type {HTMLTextAreaElement} */5616
5617const sendTextarea = document.querySelector('#send_textarea');5617export async function initSlashCommandAutoComplete() {
5618setSlashCommandAutoComplete(sendTextarea);5618 const sendTextarea = /** @type {HTMLTextAreaElement} */ (document.querySelector('#send_textarea'));
5619sendTextarea.addEventListener('input', () => {5619 setSlashCommandAutoComplete(sendTextarea);
5620 if (sendTextarea.value[0] == '/') {5620 sendTextarea.addEventListener('input', () => {
5621 sendTextarea.style.fontFamily = 'var(--monoFontFamily, monospace)';5621 if (sendTextarea.value && sendTextarea.value[0] == '/') {
5622 } else {5622 sendTextarea.style.fontFamily = 'var(--monoFontFamily, monospace)';
5623 sendTextarea.style.fontFamily = null;5623 } else {
5624 }5624 sendTextarea.style.fontFamily = null;
5625});5625 }
5626 });
5627}
public/scripts/slash-commands/SlashCommandParser.js+51 -16
@@ -15,13 +15,12 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js';
15import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';15import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
18import { MacroAutoCompleteOption } from '../autocomplete/MacroAutoCompleteOption.js';18import { EnhancedMacroAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
19import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';19import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
20import { SlashCommandDebugController } from './SlashCommandDebugController.js';20import { SlashCommandDebugController } from './SlashCommandDebugController.js';
21import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';21import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
22import { SlashCommandBreak } from './SlashCommandBreak.js';22import { SlashCommandBreak } from './SlashCommandBreak.js';
23import { MacrosParser } from '../macros.js';23import { macros as macroSystem } from '../macros/macro-system.js';
24import { t } from '../i18n.js';
2524
26/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */25/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
27/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */26/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
@@ -489,23 +488,21 @@ export class SlashCommandParser {
489 if (childClosure !== null) return null;488 if (childClosure !== null) return null;
490 const macro = this.macroIndex.findLast(it=>it.start <= index && it.end >= index);489 const macro = this.macroIndex.findLast(it=>it.start <= index && it.end >= index);
491 if (macro) {490 if (macro) {
492 const frag = document.createRange().createContextualFragment(await (await fetch('/scripts/templates/macros.html')).text());491 // Calculate cursor position within the macro for argument context
493 const options = [...frag.querySelectorAll('ul:nth-of-type(2n+1) > li')].map(li=>new MacroAutoCompleteOption(492 const cursorInMacro = index - macro.start - 2; // -2 for {{
494 li.querySelector('tt').textContent.slice(2, -2).replace(/^([^\s:]+[\s:]+).*$/, '$1'),493 const macroContent = text.slice(macro.start + 2, macro.end - (text.slice(macro.end - 2, macro.end) === '}}' ? 2 : 0));
495 li.querySelector('tt').textContent,494 const context = parseMacroContext(macroContent, cursorInMacro);
496 (li.querySelector('tt').remove(),li.innerHTML),495
497 ));496 // Extract just the identifier (strip trailing colons/whitespace/closing braces from macro.name)
498 for (const macro of MacrosParser) {497 const identifier = macro.name.replace(/[\s:}]+$/, '').trim();
499 if (options.find(it => it.name === macro.key)) continue;498
500 options.push(new MacroAutoCompleteOption(macro.key, `{{${macro.key}}}`, macro.description || t`No description provided`));499 // Use enhanced macro autocomplete when experimental engine is enabled
501 }500 const options = this.#buildEnhancedMacroOptions(context);
502 const result = new AutoCompleteNameResult(501 const result = new AutoCompleteNameResult(
503 macro.name,502 identifier,
504 macro.start + 2,503 macro.start + 2,
505 options,504 options,
506 false,505 false,
507 ()=>`No matching macros for "{{${result.name}}}"`,
508 ()=>'No macros found.',
509 );506 );
510 return result;507 return result;
511 }508 }
@@ -542,6 +539,44 @@ export class SlashCommandParser {
542 }539 }
543540
544 /**541 /**
542 * Builds enhanced macro autocomplete options from the MacroRegistry.
543 * When typing arguments (after ::), prioritizes the exact macro match.
544 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
545 * @returns {EnhancedMacroAutoCompleteOption[]}
546 */
547 #buildEnhancedMacroOptions(context) {
548 /** @type {EnhancedMacroAutoCompleteOption[]} */
549 const options = [];
550
551 // Get all macros from the registry (excluding hidden aliases)
552 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
553
554 // If we're typing arguments (after ::), only show the context to the matching macro
555 const isTypingArgs = context.currentArgIndex >= 0;
556
557 for (const macro of allMacros) {
558 // Check if this macro matches the typed identifier
559 const isExactMatch = macro.name === context.identifier;
560 const isAliasMatch = macro.aliasOf === context.identifier;
561
562 // Only pass context to the macro that matches the identifier being typed
563 // This ensures argument hints only show for the relevant macro
564 const macroContext = (isExactMatch || isAliasMatch) ? context : null;
565
566 const option = new EnhancedMacroAutoCompleteOption(macro, macroContext);
567
568 // When typing arguments, prioritize exact matches by putting them first
569 if (isTypingArgs && (isExactMatch || isAliasMatch)) {
570 options.unshift(option);
571 } else {
572 options.push(option);
573 }
574 }
575
576 return options;
577 }
578
579 /**
545 * Moves the index <length> number of characters forward and returns the last character taken.580 * Moves the index <length> number of characters forward and returns the last character taken.
546 * @param {number} length Number of characters to take.581 * @param {number} length Number of characters to take.
547 * @param {boolean} keep Whether to add the characters to the kept text.582 * @param {boolean} keep Whether to add the characters to the kept text.
public/scripts/st-context.js+13 -1
@@ -95,12 +95,13 @@ import { tokenizers, getTextTokens, getTokenCount, getTokenCountAsync, getTokeni
95import { ToolManager } from './tool-calling.js';95import { ToolManager } from './tool-calling.js';
96import { accountStorage } from './util/AccountStorage.js';96import { accountStorage } from './util/AccountStorage.js';
97import { timestampToMoment, uuidv4 } from './utils.js';97import { timestampToMoment, uuidv4 } from './utils.js';
98import { getGlobalVariable, getLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js';98import { addGlobalVariable, addLocalVariable, decrementGlobalVariable, decrementLocalVariable, deleteGlobalVariable, deleteLocalVariable, getGlobalVariable, getLocalVariable, incrementGlobalVariable, incrementLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js';
99import { convertCharacterBook, getWorldInfoPrompt, loadWorldInfo, reloadEditor, saveWorldInfo, updateWorldInfoList } from './world-info.js';99import { convertCharacterBook, getWorldInfoPrompt, loadWorldInfo, reloadEditor, saveWorldInfo, updateWorldInfoList } from './world-info.js';
100import { ChatCompletionService, TextCompletionService } from './custom-request.js';100import { ChatCompletionService, TextCompletionService } from './custom-request.js';
101import { ConnectionManagerRequestService } from './extensions/shared.js';101import { ConnectionManagerRequestService } from './extensions/shared.js';
102import { updateReasoningUI, parseReasoningFromString, getReasoningTemplateByName } from './reasoning.js';102import { updateReasoningUI, parseReasoningFromString, getReasoningTemplateByName } from './reasoning.js';
103import { IGNORE_SYMBOL } from './constants.js';103import { IGNORE_SYMBOL } from './constants.js';
104import { macros } from './macros/macro-system.js';
104105
105export function getContext() {106export function getContext() {
106 return {107 return {
@@ -165,7 +166,9 @@ export function getContext() {
165 timestampToMoment,166 timestampToMoment,
166 /** @deprecated Handlebars for extensions are no longer supported. */167 /** @deprecated Handlebars for extensions are no longer supported. */
167 registerHelper: () => { },168 registerHelper: () => { },
169 /** @deprecated Use `macros.register(name, { handler, description })` from scripts/macros/macro-system.js instead. */
168 registerMacro: MacrosParser.registerMacro.bind(MacrosParser),170 registerMacro: MacrosParser.registerMacro.bind(MacrosParser),
171 /** @deprecated Use `macros.registry.unregisterMacro(name)` from scripts/macros/macro-system.js instead. */
169 unregisterMacro: MacrosParser.unregisterMacro.bind(MacrosParser),172 unregisterMacro: MacrosParser.unregisterMacro.bind(MacrosParser),
170 registerFunctionTool: ToolManager.registerFunctionTool.bind(ToolManager),173 registerFunctionTool: ToolManager.registerFunctionTool.bind(ToolManager),
171 unregisterFunctionTool: ToolManager.unregisterFunctionTool.bind(ToolManager),174 unregisterFunctionTool: ToolManager.unregisterFunctionTool.bind(ToolManager),
@@ -221,6 +224,7 @@ export function getContext() {
221 getMediaIndex,224 getMediaIndex,
222 scrollChatToBottom,225 scrollChatToBottom,
223 scrollOnMediaLoad,226 scrollOnMediaLoad,
227 macros,
224 swipe: {228 swipe: {
225 left: swipe_left,229 left: swipe_left,
226 right: swipe_right,230 right: swipe_right,
@@ -235,10 +239,18 @@ export function getContext() {
235 local: {239 local: {
236 get: getLocalVariable,240 get: getLocalVariable,
237 set: setLocalVariable,241 set: setLocalVariable,
242 del: deleteLocalVariable,
243 add: addLocalVariable,
244 inc: incrementLocalVariable,
245 dec: decrementLocalVariable,
238 },246 },
239 global: {247 global: {
240 get: getGlobalVariable,248 get: getGlobalVariable,
241 set: setGlobalVariable,249 set: setGlobalVariable,
250 del: deleteGlobalVariable,
251 add: addGlobalVariable,
252 inc: incrementGlobalVariable,
253 dec: decrementGlobalVariable,
242 },254 },
243 },255 },
244 loadWorldInfo,256 loadWorldInfo,
public/scripts/system-messages.js+21 -1
@@ -4,6 +4,7 @@ import { t } from './i18n.js';
4import { getMessageTimeStamp } from './RossAscends-mods.js';4import { getMessageTimeStamp } from './RossAscends-mods.js';
5import { getSlashCommandsHelp } from './slash-commands.js';5import { getSlashCommandsHelp } from './slash-commands.js';
6import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js';6import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js';
7import { MacroBrowser, getMacrosHelp } from './macros/MacroBrowser.js';
7import { renderTemplateAsync } from './templates.js';8import { renderTemplateAsync } from './templates.js';
89
9/** @type {Record<string, ChatMessage>} */10/** @type {Record<string, ChatMessage>} */
@@ -59,7 +60,7 @@ export async function initSystemMessages() {
59 }),60 }),
60 /** @type {ChatMessage} */61 /** @type {ChatMessage} */
61 macros: lodash.merge(structuredClone(defaultMessage), {62 macros: lodash.merge(structuredClone(defaultMessage), {
62 mes: await renderTemplateAsync('macros'),63 mes: '',
63 }),64 }),
64 /** @type {ChatMessage} */65 /** @type {ChatMessage} */
65 welcome: lodash.merge(structuredClone(defaultMessage), {66 welcome: lodash.merge(structuredClone(defaultMessage), {
@@ -135,6 +136,14 @@ export function getSystemMessageByType(type, text, extra = {}) {
135 newMessage.mes = getSlashCommandsHelp();136 newMessage.mes = getSlashCommandsHelp();
136 }137 }
137138
139 if (type === system_message_types.MACROS) {
140 newMessage.mes = getMacrosHelp();
141 }
142
143 if (!newMessage.extra || typeof newMessage.extra !== 'object') {
144 newMessage.extra = {};
145 }
146
138 newMessage.extra = Object.assign(newMessage.extra, extra);147 newMessage.extra = Object.assign(newMessage.extra, extra);
139 newMessage.extra.type = type;148 newMessage.extra.type = type;
140 return newMessage;149 return newMessage;
@@ -159,4 +168,15 @@ export function sendSystemMessage(type, text, extra = {}) {
159 browser.renderInto(parent);168 browser.renderInto(parent);
160 browser.search.focus();169 browser.search.focus();
161 }170 }
171
172 if (type === system_message_types.MACROS) {
173 const browser = new MacroBrowser();
174 const spinner = document.querySelector('#chat .last_mes .custom-macroHelp');
175 if (spinner) {
176 const parent = spinner.parentElement;
177 spinner.remove();
178 browser.renderInto(parent);
179 browser.searchInput?.focus();
180 }
181 }
162}182}
public/scripts/templates/macros.html+0 -104
@@ -1,104 +0,0 @@
1<div data-i18n="System-wide Replacement Macros (in order of evaluation):">
2 System-wide Replacement Macros (in order of evaluation):
3</div>
4<ul>
5 <li><tt>&lcub;&lcub;pipe&rcub;&rcub;</tt> – <span data-i18n="help_macros_1">only for slash command batching. Replaced with the returned result of the previous command.</span></li>
6 <li><tt>&lcub;&lcub;newline&rcub;&rcub;</tt> – <span data-i18n="help_macros_2">just inserts a newline.</span></li>
7 <li><tt>&lcub;&lcub;trim&rcub;&rcub;</tt> – <span data-i18n="help_macros_3">trims newlines surrounding this macro.</span></li>
8 <li><tt>&lcub;&lcub;noop&rcub;&rcub;</tt> – <span data-i18n="help_macros_4">no operation, just an empty string.</span></li>
9 <li><tt>&lcub;&lcub;original&rcub;&rcub;</tt> – <span data-i18n="help_macros_5">global prompts defined in API settings. Only valid in Advanced Definitions prompt overrides.</span></li>
10 <li><tt>&lcub;&lcub;input&rcub;&rcub;</tt> – <span data-i18n="help_macros_6">the user input</span></li>
11 <li><tt>&lcub;&lcub;lastGenerationType&rcub;&rcub;</tt> - <span>the type of the last queued generation request. Empty if no generations were performed yet or the active chat was switched. Possible values: "normal", "impersonate", "regenerate", "quiet", "swipe", "continue".</span></li>
12 <li><tt>&lcub;&lcub;charPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_7">the Character's Main Prompt override</span></li>
13 <li><tt>&lcub;&lcub;charInstruction&rcub;&rcub;</tt> – <span data-i18n="help_macros_8">the Character's Post-History Instructions override</span></li>
14 <li><tt>&lcub;&lcub;description&rcub;&rcub;</tt> – <span data-i18n="help_macros_9">the Character's Description</span></li>
15 <li><tt>&lcub;&lcub;personality&rcub;&rcub;</tt> – <span data-i18n="help_macros_10">the Character's Personality</span></li>
16 <li><tt>&lcub;&lcub;scenario&rcub;&rcub;</tt> – <span data-i18n="help_macros_11">the Character's Scenario</span></li>
17 <li><tt>&lcub;&lcub;persona&rcub;&rcub;</tt> – <span data-i18n="help_macros_12">your current Persona Description</span></li>
18 <li><tt>&lcub;&lcub;mesExamples&rcub;&rcub;</tt> – <span data-i18n="help_macros_13">the Character's Dialogue Examples</span></li>
19 <li><tt>&lcub;&lcub;mesExamplesRaw&rcub;&rcub;</tt> – <span data-i18n="help_macros_14">unformatted Dialogue Examples</span></li>
20 <li><tt>&lcub;&lcub;summary&rcub;&rcub;</tt> – <span data-i18n="help_macros_summary">the latest chat summary generated by the "Summarize" extension (if available).</span></li>
21 <li><tt>&lcub;&lcub;user&rcub;&rcub;</tt> – <span data-i18n="help_macros_15">your current Persona username</span></li>
22 <li><tt>&lcub;&lcub;char&rcub;&rcub;</tt> – <span data-i18n="help_macros_16">the Character's name</span></li>
23 <li><tt>&lcub;&lcub;version&rcub;&rcub;</tt> – <span data-i18n="help_macros_17">the Character's version number</span></li>
24 <li><tt>&lcub;&lcub;charDepthPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_charDepthPrompt">the Character's @ Depth Note</span></li>
25 <li><tt>&lcub;&lcub;outlet::(name)&rcub;&rcub;</tt> – <span data-i18n="help_macros_outletName">the WI entry content for the outlet with the specified name</span></li>
26 <li><tt>&lcub;&lcub;group&rcub;&rcub;</tt> – <span data-i18n="help_macros_18">a comma-separated list of group member names (including muted) or the character name in solo chats. Alias: &lcub;&lcub;charIfNotGroup&rcub;&rcub;</span></li>
27 <li><tt>&lcub;&lcub;groupNotMuted&rcub;&rcub;</tt> – <span data-i18n="help_groupNotMuted">the same as &lcub;&lcub;group&rcub;&rcub;, but excludes muted members</span></li>
28 <li><tt>&lcub;&lcub;notChar&rcub;&rcub;</tt> – <span data-i18n="help_notChar">a comma-separated list of all participants in the conversation except for the current speaker (&lcub;&lcub;char&rcub;&rcub;). In group chats, this includes muted characters. When not in a generation, the list include all characters.</span></li>
29 <li><tt>&lcub;&lcub;model&rcub;&rcub;</tt> – <span data-i18n="help_macros_19">a text generation model name for the currently selected API. </span><b data-i18n="Can be inaccurate!">Can be inaccurate!</b></li>
30 <li><tt>&lcub;&lcub;lastMessage&rcub;&rcub;</tt> – <span data-i18n="help_macros_20">the text of the latest chat message.</span></li>
31 <li><tt>&lcub;&lcub;lastUserMessage&rcub;&rcub;</tt> – <span data-i18n="help_macros_lastUser">the text of the latest user chat message.</span></li>
32 <li><tt>&lcub;&lcub;lastCharMessage&rcub;&rcub;</tt> – <span data-i18n="help_macros_lastChar">the text of the latest character chat message.</span></li>
33 <li><tt>&lcub;&lcub;lastMessageId&rcub;&rcub;</tt> – <span data-i18n="help_macros_21">index # of the latest chat message. Useful for slash command batching.</span></li>
34 <li><tt>&lcub;&lcub;firstIncludedMessageId&rcub;&rcub;</tt> – <span data-i18n="help_macros_22">the ID of the first message included in the context. Requires generation to be run at least once in the current session. Will only be updated on generation.</span></li>
35 <li><tt>&lcub;&lcub;firstDisplayedMessageId&rcub;&rcub;</tt> – <span data-i18n="help_macros_firstDisplayedMessageId">the ID of the first message loaded into the visible chat.</span></li>
36 <li><tt>&lcub;&lcub;currentSwipeId&rcub;&rcub;</tt> – <span data-i18n="help_macros_23">the 1-based ID of the current swipe in the last chat message. Empty string if the last message is user or prompt-hidden.</span></li>
37 <li><tt>&lcub;&lcub;lastSwipeId&rcub;&rcub;</tt> – <span data-i18n="help_macros_24">the number of swipes in the last chat message. Empty string if the last message is user or prompt-hidden.</span></li>
38 <li><tt>&lcub;&lcub;reverse:(content)&rcub;&rcub;</tt> – <span data-i18n="help_macros_reverse">reverses the content of the macro.</span></li>
39 <li><tt>&lcub;&lcub;// (note)&rcub;&rcub;</tt> – <span data-i18n="help_macros_25">you can leave a note here, and the macro will be replaced with blank content. Not visible for the AI.</span></li>
40 <li><tt>&lcub;&lcub;time&rcub;&rcub;</tt> – <span data-i18n="help_macros_26">the current time</span></li>
41 <li><tt>&lcub;&lcub;date&rcub;&rcub;</tt> – <span data-i18n="help_macros_27">the current date</span></li>
42 <li><tt>&lcub;&lcub;weekday&rcub;&rcub;</tt> – <span data-i18n="help_macros_28">the current weekday</span></li>
43 <li><tt>&lcub;&lcub;isotime&rcub;&rcub;</tt> – <span data-i18n="help_macros_29">the current ISO time (24-hour clock)</span></li>
44 <li><tt>&lcub;&lcub;isodate&rcub;&rcub;</tt> – <span data-i18n="help_macros_30">the current ISO date (YYYY-MM-DD)</span></li>
45 <li><tt>&lcub;&lcub;datetimeformat &hellip;&rcub;&rcub;</tt> – <span data-i18n="help_macros_31">the current date/time in the specified format, e. g. for German date/time: </span><tt>&lcub;&lcub;datetimeformat DD.MM.YYYY HH:mm&rcub;&rcub;</tt></li>
46 <li><tt>&lcub;&lcub;time_UTC±#&rcub;&rcub;</tt> – <span data-i18n="help_macros_32">the current time in the specified UTC time zone offset, e.g. UTC-4 or UTC+2</span></li>
47 <li><tt>&lcub;&lcub;timeDiff::(time1)::(time2)&rcub;&rcub;</tt> – <span data-i18n="help_macros_33">the time difference between time1 and time2. Accepts time and date macros. (Ex: &lcub;&lcub;timeDiff::&lcub;&lcub;isodate&rcub;&rcub; &lcub;&lcub;time&rcub;&rcub;::2024/5/11 12:30:00&rcub;&rcub;)</span></li>
48 <li><tt>&lcub;&lcub;idle_duration&rcub;&rcub;</tt> – <span data-i18n="help_macros_34">the time since the last user message was sent</span></li>
49 <li><tt>&lcub;&lcub;bias "text here"&rcub;&rcub;</tt> – <span data-i18n="help_macros_35">sets a behavioral bias for the AI until the next user input. Quotes around the text are important.</span></li>
50 <li><tt>&lcub;&lcub;roll:(formula)&rcub;&rcub;</tt> – <span data-i18n="help_macros_36">rolls a dice. (ex: </span><tt>&lcub;&lcub;roll:1d6&rcub;&rcub;</tt><span data-i18n="space_ will roll a 6-sided dice and return a number between 1 and 6)"> will roll a 6-sided dice and return a number between 1 and 6)</span></li>
51 <li><tt>&lcub;&lcub;random:(args)&rcub;&rcub;</tt> – <span data-i18n="help_macros_37">returns a random item from the list. (ex: </span><tt>&lcub;&lcub;random:1,2,3,4&rcub;&rcub;</tt><span data-i18n="space_ will return 1 of the 4 numbers at random. Works with text lists too."> will return 1 of the 4 numbers at random. Works with text lists too.</span></li>
52 <li><tt>&lcub;&lcub;random::(arg1)::(arg2)&rcub;&rcub;</tt> – <span data-i18n="help_macros_38">alternative syntax for random that allows to use commas in the list items.</span></li>
53 <li><tt>&lcub;&lcub;pick::(args)&rcub;&rcub;</tt> – <span data-i18n="help_macros_39">picks a random item from the list. Works the same as &lcub;&lcub;random&rcub;&rcub;, with the same possible syntax options, but the pick will stay consistent for this chat once picked and won't be re-rolled on consecutive messages and prompt processing.</span></li>
54 <li><tt>&lcub;&lcub;banned "text here"&rcub;&rcub;</tt> – <span data-i18n="help_macros_40">dynamically add text in the quotes to banned words sequences, if Text Generation WebUI backend used. Do nothing for others backends. Can be used anywhere (Character description, WI, AN, etc.) Quotes around the text are important.</span></li>
55 <li><tt>&lcub;&lcub;isMobile&rcub;&rcub;</tt> – <span data-i18n="help_macros_isMobile">"true" if currently running in a mobile environment, "false" otherwise</span></li>
56</ul>
57<div data-i18n="Instruct Mode and Context Template Macros:">
58 Instruct Mode and Context Template Macros:
59</div>
60<div>
61 <small data-i18n="(enabled in the Advanced Formatting settings)">(enabled in the Advanced Formatting settings)</small>
62</div>
63<ul>
64 <li><tt>&lcub;&lcub;maxPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_41">max allowed prompt length in tokens = (context size - response length)</span></li>
65 <li><tt>&lcub;&lcub;exampleSeparator&rcub;&rcub;</tt> – <span data-i18n="help_macros_42">context template example dialogues separator</span></li>
66 <li><tt>&lcub;&lcub;chatStart&rcub;&rcub;</tt> – <span data-i18n="help_macros_43">context template chat start line</span></li>
67 <li><tt>&lcub;&lcub;systemPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_44">system prompt content if enabled (either character prompt override if allowed, or defaultSystemPrompt)</span></li>
68 <li><tt>&lcub;&lcub;defaultSystemPrompt&rcub;&rcub;</tt> – <span data-i18n="help_macros_45">system prompt content</span></li>
69 <li><tt>&lcub;&lcub;instructStoryStringPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_46">instruct story string prefix sequence</span></li>
70 <li><tt>&lcub;&lcub;instructStoryStringSuffix&rcub;&rcub;</tt> – <span data-i18n="help_macros_47">instruct story string suffix sequence</span></li>
71 <li><tt>&lcub;&lcub;instructUserPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_48">instruct user prefix sequence</span></li>
72 <li><tt>&lcub;&lcub;instructUserSuffix&rcub;&rcub;</tt> – <span data-i18n="help_macros_49">instruct user suffix sequence</span></li>
73 <li><tt>&lcub;&lcub;instructAssistantPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_50">instruct assistant prefix sequence</span></li>
74 <li><tt>&lcub;&lcub;instructAssistantSuffix&rcub;&rcub;</tt> – <span data-i18n="help_macros_51">instruct assistant suffix sequence</span></li>
75 <li><tt>&lcub;&lcub;instructFirstAssistantPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_52">instruct assistant first output sequence</span></li>
76 <li><tt>&lcub;&lcub;instructLastAssistantPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_53">instruct assistant last output sequence</span></li>
77 <li><tt>&lcub;&lcub;instructSystemPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_54">instruct system message prefix sequence</span></li>
78 <li><tt>&lcub;&lcub;instructSystemSuffix&rcub;&rcub;</tt> – <span data-i18n="help_macros_55">instruct system message suffix sequence</span></li>
79 <li><tt>&lcub;&lcub;instructSystemInstructionPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_56">instruct system instruction prefix</span></li>
80 <li><tt>&lcub;&lcub;instructUserFiller&rcub;&rcub;</tt> – <span data-i18n="help_macros_57">instruct first user message filler</span></li>
81 <li><tt>&lcub;&lcub;instructStop&rcub;&rcub;</tt> – <span data-i18n="help_macros_58">instruct stop sequence</span></li>
82 <li><tt>&lcub;&lcub;instructFirstUserPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_first_user">instruct user first input sequence</span></li>
83 <li><tt>&lcub;&lcub;instructLastUserPrefix&rcub;&rcub;</tt> – <span data-i18n="help_macros_last_user">instruct user last input sequence</span></li>
84</ul>
85<div data-i18n="Chat variables Macros:">
86 Chat variables Macros:
87</div>
88<div><small data-i18n="Local variables = unique to the current chat">Local variables = unique to the current chat</small></div>
89<div><small data-i18n="Global variables = works in any chat for any character">Global variables = works in any chat for any character</small></div>
90<div><small data-i18n="Scoped variables = works in STscript">Scoped variables = works in STscript</small></div>
91<ul>
92 <li><tt>&lcub;&lcub;getvar::name&rcub;&rcub;</tt> – <span data-i18n="help_macros_59">replaced with the value of the local variable "name"</span></li>
93 <li><tt>&lcub;&lcub;setvar::name::value&rcub;&rcub;</tt> – <span data-i18n="help_macros_60">replaced with empty string, sets the local variable "name" to "value"</span></li>
94 <li><tt>&lcub;&lcub;addvar::name::increment&rcub;&rcub;</tt> – <span data-i18n="help_macros_61">replaced with empty strings, adds a numeric value of "increment" to the local variable "name"</span></li>
95 <li><tt>&lcub;&lcub;incvar::name&rcub;&rcub;</tt> – <span data-i18n="help_macros_62">replaced with the result of the increment of value of the variable "name" by 1</span></li>
96 <li><tt>&lcub;&lcub;decvar::name&rcub;&rcub;</tt> – <span data-i18n="help_macros_63">replaced with the result of the decrement of value of the variable "name" by 1</span></li>
97 <li><tt>&lcub;&lcub;getglobalvar::name&rcub;&rcub;</tt> – <span data-i18n="help_macros_64">replaced with the value of the global variable "name"</span></li>
98 <li><tt>&lcub;&lcub;setglobalvar::name::value&rcub;&rcub;</tt> – <span data-i18n="help_macros_65">replaced with empty string, sets the global variable "name" to "value"</span></li>
99 <li><tt>&lcub;&lcub;addglobalvar::name::value&rcub;&rcub;</tt> – <span data-i18n="help_macros_66">replaced with empty string, adds a numeric value of "increment" to the global variable "name"</span></li>
100 <li><tt>&lcub;&lcub;incglobalvar::name&rcub;&rcub;</tt> – <span data-i18n="help_macros_67">replaced with the result of the increment of value of the global variable "name" by 1</span></li>
101 <li><tt>&lcub;&lcub;decglobalvar::name&rcub;&rcub;</tt> – <span data-i18n="help_macros_68">replaced with the result of the decrement of value of the global variable "name" by 1</span></li>
102 <li><tt>&lcub;&lcub;var::name&rcub;&rcub;</tt> – <span data-i18n="help_macros_69">replaced with the value of the scoped variable "name"</span></li>
103 <li><tt>&lcub;&lcub;var::name::index&rcub;&rcub;</tt> – <span data-i18n="help_macros_70">replaced with the value of item at index (for arrays / lists or objects / dictionaries) of the scoped variable "name"</span></li>
104</ul>
public/scripts/variables.js+9 -9
@@ -133,7 +133,7 @@ export function setGlobalVariable(name, value, args = {}) {
133 return value;133 return value;
134}134}
135135
136function addLocalVariable(name, value) {136export function addLocalVariable(name, value) {
137 const currentValue = getLocalVariable(name) || 0;137 const currentValue = getLocalVariable(name) || 0;
138 try {138 try {
139 const parsedValue = JSON.parse(currentValue);139 const parsedValue = JSON.parse(currentValue);
@@ -163,7 +163,7 @@ function addLocalVariable(name, value) {
163 return newValue;163 return newValue;
164}164}
165165
166function addGlobalVariable(name, value) {166export function addGlobalVariable(name, value) {
167 const currentValue = getGlobalVariable(name) || 0;167 const currentValue = getGlobalVariable(name) || 0;
168 try {168 try {
169 const parsedValue = JSON.parse(currentValue);169 const parsedValue = JSON.parse(currentValue);
@@ -193,19 +193,19 @@ function addGlobalVariable(name, value) {
193 return newValue;193 return newValue;
194}194}
195195
196function incrementLocalVariable(name) {196export function incrementLocalVariable(name) {
197 return addLocalVariable(name, 1);197 return addLocalVariable(name, 1);
198}198}
199199
200function incrementGlobalVariable(name) {200export function incrementGlobalVariable(name) {
201 return addGlobalVariable(name, 1);201 return addGlobalVariable(name, 1);
202}202}
203203
204function decrementLocalVariable(name) {204export function decrementLocalVariable(name) {
205 return addLocalVariable(name, -1);205 return addLocalVariable(name, -1);
206}206}
207207
208function decrementGlobalVariable(name) {208export function decrementGlobalVariable(name) {
209 return addGlobalVariable(name, -1);209 return addGlobalVariable(name, -1);
210}210}
211211
@@ -408,7 +408,7 @@ async function ifCallback(args, value) {
408 const { a, b, rule } = parseBooleanOperands(args);408 const { a, b, rule } = parseBooleanOperands(args);
409 const result = evalBoolean(rule, a, b);409 const result = evalBoolean(rule, a, b);
410410
411 /**@type {string|SlashCommandClosure} */411 /** @type {string|SlashCommandClosure} */
412 let command;412 let command;
413 if (value) {413 if (value) {
414 if (value[0] instanceof SlashCommandClosure) {414 if (value[0] instanceof SlashCommandClosure) {
@@ -608,7 +608,7 @@ async function executeSubCommands(command, scope = null, parserFlags = null, abo
608 * @param {string} name Variable name to delete608 * @param {string} name Variable name to delete
609 * @returns {string} Empty string609 * @returns {string} Empty string
610 */610 */
611function deleteLocalVariable(name) {611export function deleteLocalVariable(name) {
612 if (!existsLocalVariable(name)) {612 if (!existsLocalVariable(name)) {
613 console.warn(`The local variable "${name}" does not exist.`);613 console.warn(`The local variable "${name}" does not exist.`);
614 return '';614 return '';
@@ -624,7 +624,7 @@ function deleteLocalVariable(name) {
624 * @param {string} name Variable name to delete624 * @param {string} name Variable name to delete
625 * @returns {string} Empty string625 * @returns {string} Empty string
626 */626 */
627function deleteGlobalVariable(name) {627export function deleteGlobalVariable(name) {
628 if (!existsGlobalVariable(name)) {628 if (!existsGlobalVariable(name)) {
629 console.warn(`The global variable "${name}" does not exist.`);629 console.warn(`The global variable "${name}" does not exist.`);
630 return '';630 return '';
tests/frontend/MacroEngine.e2e.js+748 -0
@@ -0,0 +1,748 @@
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4test.describe('MacroEngine', () => {
5 test.beforeEach(testSetup.awaitST);
6
7 test.describe('Basic evaluation', () => {
8 test('should return input unchanged when there are no macros', async ({ page }) => {
9 const input = 'Hello world, no macros here.';
10 const output = await evaluateWithEngine(page, input);
11 expect(output).toBe(input);
12 });
13
14 test('should evaluate a simple macro without arguments', async ({ page }) => {
15 const input = 'Start {{newline}} end.';
16 const output = await evaluateWithEngine(page, input);
17 expect(output).toBe('Start \n end.');
18 });
19
20 test('should evaluate multiple macros in order', async ({ page }) => {
21 const input = 'A {{setvar::test::4}}{{getvar::test}} B {{setvar::test::2}}{{getvar::test}} C';
22 const output = await evaluateWithEngine(page, input);
23 expect(output).toBe('A 4 B 2 C');
24 });
25 });
26
27 test.describe('Unnamed arguments', () => {
28 test('should handle normal double-colon separated unnamed argument', async ({ page }) => {
29 const input = 'Reversed: {{reverse::abc}}!';
30 const output = await evaluateWithEngine(page, input);
31 expect(output).toBe('Reversed: cba!');
32 });
33
34 test('should handle (legacy) colon separated unnamed argument', async ({ page }) => {
35 const input = 'Reversed: {{reverse:abc}}!';
36 const output = await evaluateWithEngine(page, input);
37 expect(output).toBe('Reversed: cba!');
38 });
39
40 test('should handle (legacy) colon separated argument as only one, even with more separators (double colon)', async ({ page }) => {
41 const input = 'Reversed: {{reverse:abc::def}}!';
42 const output = await evaluateWithEngine(page, input);
43 expect(output).toBe('Reversed: fed::cba!');
44 });
45
46 test('should handle (legacy) colon separated argument as only one, even with more separators (single colon)', async ({ page }) => {
47 const input = 'Reversed: {{reverse:abc:def}}!';
48 const output = await evaluateWithEngine(page, input);
49 expect(output).toBe('Reversed: fed:cba!');
50 });
51
52 test('should handle (legacy) whitespace separated unnamed argument', async ({ page }) => {
53 const input = 'Values: {{roll 1d1}}!';
54 const output = await evaluateWithEngine(page, input);
55 expect(output).toBe('Values: 1!');
56 });
57
58 test('should handle (legacy) whitespace separated unnamed argument as only one, even with more separators (space)', async ({ page }) => {
59 const input = 'Values: {{reverse abc def}}!';
60 const output = await evaluateWithEngine(page, input);
61 expect(output).toBe('Values: fed cba!');
62 });
63
64 test('should support multi-line arguments for macros', async ({ page }) => {
65 const input = 'Result: {{reverse::first line\nsecond line}}'; // "\n" becomes a real newline in the macro argument
66 const output = await evaluateWithEngine(page, input);
67
68 const original = 'first line\nsecond line';
69 const expectedReversed = Array.from(original).reverse().join('');
70 expect(output).toBe(`Result: ${expectedReversed}`);
71 });
72 });
73
74 test.describe('Nested macros', () => {
75 test('should resolve nested macros inside arguments inside-out', async ({ page }) => {
76 const input = 'Result: {{setvar::test::0}}{{reverse::{{addvar::test::100}}{{getvar::test}}}}{{setvar::test::0}}';
77 const output = await evaluateWithEngine(page, input);
78 expect(output).toBe('Result: 001');
79 });
80
81 // {{wrap::{{upper::x}}::[::]}} -> '[X]'
82 test('should resolve nested macros across multiple arguments', async ({ page }) => {
83 const input = 'Result: {{setvar::addvname::test}}{{addvar::{{getvar::addvname}}::{{setvar::test::5}}{{getvar::test}}}}{{getvar::test}}';
84 const output = await evaluateWithEngine(page, input);
85 expect(output).toBe('Result: 10');
86 });
87 });
88
89 test.describe('Unknown macros', () => {
90 test('should keep unknown macro syntax but resolve nested macros inside it', async ({ page }) => {
91 const input = 'Test: {{unknown::{{newline}}}}';
92 const output = await evaluateWithEngine(page, input);
93 expect(output).toBe('Test: {{unknown::\n}}');
94 });
95
96 test('should keep surrounding text inside unknown macros intact', async ({ page }) => {
97 const input = 'Test: {{unknown::my {{newline}} example}}';
98 const output = await evaluateWithEngine(page, input);
99 expect(output).toBe('Test: {{unknown::my \n example}}');
100 });
101 });
102
103 test.describe('Comment macro', () => {
104 test('should remove single-line comments with simple body', async ({ page }) => {
105 const input = 'Hello{{// comment}}World';
106 const output = await evaluateWithEngine(page, input);
107 expect(output).toBe('HelloWorld');
108 });
109
110 test('should accept non-word characters immediately after //', async ({ page }) => {
111 const input = 'A{{//!@#$%^&*()_+}}B';
112 const output = await evaluateWithEngine(page, input);
113 expect(output).toBe('AB');
114 });
115
116 test('should ignore additional // sequences inside the comment body', async ({ page }) => {
117 const input = 'X{{//comment with // extra // slashes}}Y';
118 const output = await evaluateWithEngine(page, input);
119 expect(output).toBe('XY');
120 });
121
122 test('should support multi-line comment bodies', async ({ page }) => {
123 const input = 'Start{{// line one\nline two\nline three}}End';
124 const output = await evaluateWithEngine(page, input);
125 expect(output).toBe('StartEnd');
126 });
127 });
128
129 test.describe('Legacy compatibility', () => {
130 test('should strip trim macro and surrounding newlines (legacy behavior)', async ({ page }) => {
131 const input = 'foo\n\n{{trim}}\n\nbar';
132 const output = await evaluateWithEngine(page, input);
133 expect(output).toBe('foobar');
134 });
135
136 test('should handle multiple trim macros in a single string', async ({ page }) => {
137 const input = 'A\n\n{{trim}}\n\nB\n\n{{trim}}\n\nC';
138 const output = await evaluateWithEngine(page, input);
139 expect(output).toBe('ABC');
140 });
141
142 test('should support legacy time macro with positive offset via pre-processing', async ({ page }) => {
143 const input = 'Time: {{time_UTC+2}}';
144 const output = await evaluateWithEngine(page, input);
145
146 // After pre-processing, this should behave like {{time::UTC+2}} and be resolved by the time macro.
147 // We only assert that the placeholder was consumed and some non-empty value was produced.
148 expect(output).not.toBe(input);
149 expect(output.startsWith('Time: ')).toBeTruthy();
150 expect(output.length).toBeGreaterThan('Time: '.length);
151 });
152
153 test('should support legacy time macro with negative offset via pre-processing', async ({ page }) => {
154 const input = 'Time: {{time_UTC-10}}';
155 const output = await evaluateWithEngine(page, input);
156
157 expect(output).not.toBe(input);
158 expect(output.startsWith('Time: ')).toBeTruthy();
159 expect(output.length).toBeGreaterThan('Time: '.length);
160 });
161
162 test('should support legacy <USER> marker via pre-processing', async ({ page }) => {
163 const input = 'Hello <USER>!';
164 const output = await evaluateWithEngine(page, input);
165
166 // In the default test env, name1Override is "User".
167 expect(output).toBe('Hello User!');
168 });
169
170 test('should support legacy <BOT> and <CHAR> markers via pre-processing', async ({ page }) => {
171 const input = 'Bot: <BOT>, Char: <CHAR>.';
172 const output = await evaluateWithEngine(page, input);
173
174 // In the default test env, name2Override is "Character".
175 expect(output).toBe('Bot: Character, Char: Character.');
176 });
177
178 test('should support legacy <GROUP> and <CHARIFNOTGROUP> markers via pre-processing (non-group fallback)', async ({ page }) => {
179 const input = 'Group: <GROUP>, CharIfNotGroup: <CHARIFNOTGROUP>.';
180 const output = await evaluateWithEngine(page, input);
181
182 // Without an active group, both markers fall back to the current character name.
183 expect(output).toBe('Group: Character, CharIfNotGroup: Character.');
184 });
185 });
186
187 test.describe('Bracket handling around macros', () => {
188 test('should allow single opening brace inside macro arguments', async ({ page }) => {
189 const input = 'Test§ {{reverse::my { test}}';
190 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
191
192 // "my { test" reversed becomes "tset { ym"
193 expect(output).toBe('Test§ tset { ym');
194
195 const EXPECT_WARNINGS = false;
196 const EXPECT_ERRORS = false;
197 expect(hasMacroWarnings).toBe(EXPECT_WARNINGS);
198 expect(hasMacroErrors).toBe(EXPECT_ERRORS);
199 });
200
201 test('should allow single closing brace inside macro arguments', async ({ page }) => {
202 const input = 'Test§ {{reverse::my } test}}';
203 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
204
205 // "my } test" reversed becomes "tset } ym"
206 expect(output).toBe('Test§ tset } ym');
207
208 expect(hasMacroWarnings).toBe(false);
209 expect(hasMacroErrors).toBe(false);
210 });
211
212 test('should treat unterminated macro with identifier at end of input as plain text', async ({ page }) => {
213 const input = 'Test {{ hehe';
214 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
215
216 expect(output).toBe(input);
217
218 expect(hasMacroWarnings).toBe(true);
219 expect(hasMacroErrors).toBe(false);
220 });
221
222 test('should treat invalid macro start as plain text when followed by non-identifier characters', async ({ page }) => {
223 const input = 'Test {{§§ hehe';
224 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
225
226 expect(output).toBe(input);
227
228 expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
229 expect(hasMacroErrors).toBe(false);
230 });
231
232 test('should treat unterminated macro in the middle of the string as plain text', async ({ page }) => {
233 const input = 'Before {{ hehe After';
234 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
235
236 expect(output).toBe(input);
237
238 expect(hasMacroWarnings).toBe(true);
239 expect(hasMacroErrors).toBe(false);
240 });
241
242 test('should treat dangling macro start as text and still evaluate subsequent macro', async ({ page }) => {
243 const input = 'Test {{ hehe {{user}}';
244 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
245
246 // Default test env uses name1Override = "User" and name2Override = "Character".
247 expect(output).toBe('Test {{ hehe User');
248
249 expect(hasMacroWarnings).toBe(true);
250 expect(hasMacroErrors).toBe(false);
251 });
252
253 test('should ignore invalid macro start but still evaluate following valid macro', async ({ page }) => {
254 const input = 'Test {{&& hehe {{user}}';
255 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
256
257 // Default test env uses name1Override = "User" and name2Override = "Character".
258 expect(output).toBe('Test {{&& hehe User');
259
260 expect(hasMacroWarnings).toBe(false); // Doesn't even try to recognize this as a macro, doesn't look like one. No warning is fine
261 expect(hasMacroErrors).toBe(false);
262 });
263
264 test('should allow single opening brace immediately before a macro', async ({ page }) => {
265 const input = '{{{char}}';
266 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
267
268 // One literal '{' plus the resolved character name.
269 expect(output).toBe('{Character');
270
271 expect(hasMacroWarnings).toBe(false);
272 expect(hasMacroErrors).toBe(false);
273 });
274
275 test('should allow single closing brace immediately after a macro', async ({ page }) => {
276 const input = '{{char}}}';
277 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
278
279 expect(output).toBe('Character}');
280
281 expect(hasMacroWarnings).toBe(false);
282 expect(hasMacroErrors).toBe(false);
283 });
284
285 test('should allow single braces around a macro', async ({ page }) => {
286 const input = '{{{char}}}';
287 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
288
289 expect(output).toBe('{Character}');
290
291 expect(hasMacroWarnings).toBe(false);
292 expect(hasMacroErrors).toBe(false);
293 });
294
295 test('should allow double opening braces immediately before a macro', async ({ page }) => {
296 const input = '{{{{char}}';
297 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
298
299 expect(output).toBe('{{Character');
300
301 expect(hasMacroWarnings).toBe(false);
302 expect(hasMacroErrors).toBe(false);
303 });
304
305 test('should allow double closing braces immediately after a macro', async ({ page }) => {
306 const input = '{{char}}}}';
307 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
308
309 expect(output).toBe('Character}}');
310
311 expect(hasMacroWarnings).toBe(false);
312 expect(hasMacroErrors).toBe(false);
313 });
314
315 test('should allow double braces around a macro', async ({ page }) => {
316 const input = '{{{{char}}}}';
317 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
318
319 expect(output).toBe('{{Character}}');
320
321 expect(hasMacroWarnings).toBe(false);
322 expect(hasMacroErrors).toBe(false);
323 });
324
325 test('should resolve nested macro inside argument with surrounding braces', async ({ page }) => {
326 const input = 'Result: {{reverse::pre-{ {{user}} }-post}}';
327 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
328
329 // Argument "pre-{ User }-post" reversed becomes "tsop-} resU {-erp".
330 expect(output).toBe('Result: tsop-} resU {-erp');
331
332 expect(hasMacroWarnings).toBe(false);
333 expect(hasMacroErrors).toBe(false);
334 });
335
336 test('should handle adjacent macros with no separator', async ({ page }) => {
337 const input = '{{char}}{{user}}';
338 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
339
340 expect(output).toBe('CharacterUser');
341
342 expect(hasMacroWarnings).toBe(false);
343 expect(hasMacroErrors).toBe(false);
344 });
345
346 test('should handle macros separated only by surrounding braces', async ({ page }) => {
347 const input = '{{char}}{ {{user}} }';
348 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
349
350 expect(output).toBe('Character{ User }');
351
352 expect(hasMacroWarnings).toBe(false);
353 expect(hasMacroErrors).toBe(false);
354 });
355
356 test('should handle Windows newlines with braces near macros', async ({ page }) => {
357 const input = 'Line1 {{char}}\r\n{Line2}';
358 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
359
360 expect(output).toBe('Line1 Character\r\n{Line2}');
361
362 expect(hasMacroWarnings).toBe(false);
363 expect(hasMacroErrors).toBe(false);
364 });
365
366 test('should treat stray closing braces outside macros as plain text', async ({ page }) => {
367 const input = 'Foo }} bar';
368 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
369
370 expect(output).toBe(input);
371
372 expect(hasMacroWarnings).toBe(false);
373 expect(hasMacroErrors).toBe(false);
374 });
375
376 test('should keep stray closing braces and still evaluate following macro', async ({ page }) => {
377 const input = 'Foo }} {{user}}';
378 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
379
380 expect(output).toBe('Foo }} User');
381
382 expect(hasMacroWarnings).toBe(false);
383 expect(hasMacroErrors).toBe(false);
384 });
385
386 test('should handle stray closing braces before macros as plain text', async ({ page }) => {
387 const input = 'Foo {{user}} }}';
388 const { output, hasMacroWarnings, hasMacroErrors } = await evaluateWithEngineAndCaptureMacroLogs(page, input);
389
390 expect(output).toBe('Foo User }}');
391
392 expect(hasMacroWarnings).toBe(false);
393 expect(hasMacroErrors).toBe(false);
394 });
395 });
396
397 test.describe('Arity errors', () => {
398 test('should not resolve newline when called with arguments', async ({ page }) => {
399 /** @type {string[]} */
400 const warnings = [];
401 page.on('console', msg => {
402 if (msg.type() === 'warning') {
403 warnings.push(msg.text());
404 }
405 });
406
407 const input = 'Start {{newline::extra}} end.';
408 const output = await evaluateWithEngine(page, input);
409
410 // Macro text should remain unchanged
411 expect(output).toBe(input);
412
413 // Should have logged an arity warning for newline
414 expect(warnings.some(w => w.includes('Macro "newline"') && w.includes('unnamed arguments'))).toBeTruthy();
415 });
416
417 test('should not resolve reverse when called without arguments', async ({ page }) => {
418 /** @type {string[]} */
419 const warnings = [];
420 page.on('console', msg => {
421 if (msg.type() === 'warning') {
422 warnings.push(msg.text());
423 }
424 });
425
426 const input = 'Result: {{reverse}}';
427 const output = await evaluateWithEngine(page, input);
428
429 expect(output).toBe(input);
430
431 expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
432 });
433
434 test('should not resolve reverse when called with too many arguments', async ({ page }) => {
435 /** @type {string[]} */
436 const warnings = [];
437 page.on('console', msg => {
438 if (msg.type() === 'warning') {
439 warnings.push(msg.text());
440 }
441 });
442
443 const input = 'Result: {{reverse::a::b}}';
444 const output = await evaluateWithEngine(page, input);
445
446 // Macro text should remain unchanged when extra unnamed args are provided
447 expect(output).toBe(input);
448
449 // Should have logged an arity warning for reverse
450 expect(warnings.some(w => w.includes('Macro "reverse"') && w.includes('unnamed arguments'))).toBeTruthy();
451 });
452
453 test('should not resolve list-bounded macro when called outside list bounds', async ({ page }) => {
454 /** @type {string[]} */
455 const warnings = [];
456 page.on('console', msg => {
457 if (msg.type() === 'warning') {
458 warnings.push(msg.text());
459 }
460 });
461
462 // Register a temporary macro with explicit list bounds: exactly 1 required + 1-2 list args
463 await page.evaluate(async () => {
464 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
465 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
466
467 MacroRegistry.unregisterMacro('test-list-bounds');
468 MacroRegistry.registerMacro('test-list-bounds', {
469 unnamedArgs: 1,
470 list: { min: 1, max: 2 },
471 description: 'Test macro for list bounds.',
472 handler: ({ unnamedArgs, list }) => {
473 const all = [...unnamedArgs, ...(list ?? [])];
474 return all.join('|');
475 },
476 });
477 });
478
479 // First macro: too few list args (only required arg)
480 // Second macro: too many list args (required arg + 3 list entries)
481 const input = 'A {{test-list-bounds::base}} B {{test-list-bounds::base::x::y::z}}';
482 const output = await evaluateWithEngine(page, input);
483
484 // Both macros should remain unchanged in the output
485 expect(output).toBe(input);
486
487 const testWarnings = warnings.filter(w => w.includes('Macro "test-list-bounds"') && w.includes('unnamed arguments'));
488 // We expect one warning for each invalid invocation (too few and too many list args)
489 expect(testWarnings.length).toBe(2);
490 });
491
492 test('should resolve nested macros in arguments, even though the outer macro has wrong number of arguments', async ({ page }) => {
493 // Macro {{user ....}} will fail, because it has no args, but {{char}} should still resolve
494 const input = 'Result: {{user Something {{char}}}}';
495 const output = await evaluateWithEngine(page, input);
496 expect(output).toBe('Result: {{user Something Character}}');
497 });
498
499 });
500
501 test.describe('Type validation', () => {
502 test('should not resolve strict typed macro when argument type is invalid', async ({ page }) => {
503 /** @type {string[]} */
504 const warnings = [];
505 page.on('console', msg => {
506 if (msg.type() === 'warning') {
507 warnings.push(msg.text());
508 }
509 });
510
511 await page.evaluate(async () => {
512 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
513 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
514
515 MacroRegistry.unregisterMacro('test-int-strict');
516 MacroRegistry.registerMacro('test-int-strict', {
517 unnamedArgs: [
518 { name: 'value', type: 'integer', description: 'Must be an integer.' },
519 ],
520 strictArgs: true,
521 description: 'Strict integer macro for testing type validation.',
522 handler: ({ unnamedArgs: [value] }) => `#${value}#`,
523 });
524 });
525
526 const input = 'Value: {{test-int-strict::abc}}';
527 const output = await evaluateWithEngine(page, input);
528
529 // Strict typed macro should leave the text unchanged when the argument is invalid
530 expect(output).toBe(input);
531
532 // A runtime type validation warning should be logged
533 expect(warnings.some(w => w.includes('Macro "test-int-strict"') && w.includes('expected type integer'))).toBeTruthy();
534 });
535
536 test('should resolve non-strict typed macro when argument type is invalid but still log warning', async ({ page }) => {
537 /** @type {string[]} */
538 const warnings = [];
539 page.on('console', msg => {
540 if (msg.type() === 'warning') {
541 warnings.push(msg.text());
542 }
543 });
544
545 await page.evaluate(async () => {
546 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
547 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
548
549 MacroRegistry.unregisterMacro('test-int-nonstrict');
550 MacroRegistry.registerMacro('test-int-nonstrict', {
551 unnamedArgs: [
552 { name: 'value', type: 'integer', description: 'Must be an integer.' },
553 ],
554 strictArgs: false,
555 description: 'Non-strict integer macro for testing type validation.',
556 handler: ({ unnamedArgs: [value] }) => `#${value}#`,
557 });
558 });
559
560 const input = 'Value: {{test-int-nonstrict::abc}}';
561 const output = await evaluateWithEngine(page, input);
562
563 // Non-strict typed macro should still execute, even with invalid type
564 expect(output).toBe('Value: #abc#');
565
566 // A runtime type validation warning should still be logged
567 expect(warnings.some(w => w.includes('Macro "test-int-nonstrict"') && w.includes('expected type integer'))).toBeTruthy();
568 });
569 });
570
571 test.describe('Environment', () => {
572 test('should expose original content as env.content to macro handlers', async ({ page }) => {
573 const input = '{{env-content}}';
574 const originalContent = 'This is the full original input string.';
575
576 const output = await page.evaluate(async ({ input, originalContent }) => {
577 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
578 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
579 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
580 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
581 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
582 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
583
584 MacroRegistry.unregisterMacro('env-content');
585 MacroRegistry.registerMacro('env-content', {
586 description: 'Test macro that returns env.content.',
587 handler: ({ env }) => env.content,
588 });
589
590 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
591 const rawEnv = {
592 content: originalContent,
593 };
594 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
595
596 return MacroEngine.evaluate(input, env);
597 }, { input, originalContent });
598
599 expect(output).toBe(originalContent);
600 });
601 });
602
603 test.describe('Deterministic pick macro', () => {
604 test('should return stable results for the same chat and content', async ({ page }) => {
605 // Simulate a consistent chat id hash
606 let originalHash;
607 await page.evaluate(async ([originalHash]) => {
608 /** @type {import('../../public/script.js')} */
609 const { chat_metadata } = await import('./script.js');
610 originalHash = chat_metadata['chat_id_hash'];
611 chat_metadata['chat_id_hash'] = 123456;
612 }, [originalHash]);
613
614 const input = 'Choices: {{pick::red::green::blue}}, {{pick::red::green::blue}}.';
615
616 const output1 = await evaluateWithEngine(page, input);
617 const output2 = await evaluateWithEngine(page, input);
618
619 // Deterministic: same chat and same content should yield identical output.
620 expect(output1).toBe(output2);
621
622 // Sanity check: both picks should resolve to one of the provided options.
623 const match = output1.match(/Choices: ([^,]+), ([^.]+)\./);
624 expect(match).not.toBeNull();
625
626 if (!match) return;
627
628 const first = match[1].trim();
629 const second = match[2].trim();
630 const options = ['red', 'green', 'blue'];
631
632 expect(options.includes(first)).toBeTruthy();
633 expect(options.includes(second)).toBeTruthy();
634
635 // Restore original hash
636 await page.evaluate(async ([originalHash]) => {
637 /** @type {import('../../public/script.js')} */
638 const { chat_metadata } = await import('./script.js');
639 chat_metadata['chat_id_hash'] = originalHash;
640 }, [originalHash]);
641 });
642 });
643
644 test.describe('Dynamic macros', () => {
645 test('should not resolve dynamic macro when called with arguments due to strict arity', async ({ page }) => {
646 /** @type {string[]} */
647 const warnings = [];
648 page.on('console', msg => {
649 if (msg.type() === 'warning') {
650 warnings.push(msg.text());
651 }
652 });
653
654 const input = 'Dyn: {{dyn::extra}}';
655 const output = await page.evaluate(async (input) => {
656 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
657 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
658 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
659 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
660
661 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
662 const rawEnv = {
663 content: input,
664 dynamicMacros: {
665 dyn: () => 'OK',
666 },
667 };
668 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
669
670 return MacroEngine.evaluate(input, env);
671 }, input);
672
673 // Dynamic macro with arguments should not resolve because the
674 // temporary definition is strictArgs: true and minArgs/maxArgs: 0.
675 expect(output).toBe(input);
676
677 // A runtime arity warning for the dynamic macro should be logged
678 expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
679 });
680 });
681});
682
683/**
684 * Evaluates the given input string using the MacroEngine inside the browser
685 * context, ensuring that the core macros are registered.
686 *
687 * @param {import('@playwright/test').Page} page
688 * @param {string} input
689 * @returns {Promise<string>}
690 */
691async function evaluateWithEngine(page, input) {
692 const result = await page.evaluate(async (input) => {
693 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
694 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
695 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
696 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
697
698 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
699 const rawEnv = {
700 content: input,
701 name1Override: 'User',
702 name2Override: 'Character',
703 };
704 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
705
706 const output = await MacroEngine.evaluate(input, env);
707 return output;
708 }, input);
709
710 return result;
711}
712
713/**
714 * Evaluates the given input string while capturing whether any macro-related
715 * warnings or errors were logged to the browser console.
716 *
717 * This is useful for tests that want to assert both the resolved output and
718 * whether the lexer/parser/engine reported issues (e.g. unterminated macros).
719 *
720 * @param {import('@playwright/test').Page} page
721 * @param {string} input
722 * @returns {Promise<{ output: string, hasMacroWarnings: boolean, hasMacroErrors: boolean }>}
723 */
724async function evaluateWithEngineAndCaptureMacroLogs(page, input) {
725 /** @type {boolean} */
726 let hasMacroWarnings = false;
727 /** @type {boolean} */
728 let hasMacroErrors = false;
729
730 /** @param {import('playwright').ConsoleMessage} msg */
731 const handler = (msg) => {
732 const text = msg.text();
733 if (text.includes('[Macro] Warning:')) {
734 hasMacroWarnings = true;
735 }
736 if (text.includes('[Macro] Error:')) {
737 hasMacroErrors = true;
738 }
739 };
740
741 page.on('console', handler);
742 try {
743 const output = await evaluateWithEngine(page, input);
744 return { output, hasMacroWarnings, hasMacroErrors };
745 } finally {
746 page.off('console', handler);
747 }
748}
tests/frontend/MacroEnvBuilder.e2e.js+312 -0
@@ -0,0 +1,312 @@
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4test.describe('MacroEnvBuilder', () => {
5 test.beforeEach(testSetup.awaitST);
6
7 test('builds names from overrides without relying on globals', async ({ page }) => {
8 const result = await page.evaluate(async () => {
9 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
10 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
11
12 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
13 const ctx = {
14 content: 'ignored',
15 name1Override: 'UserOverride',
16 name2Override: 'CharOverride',
17 };
18
19 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
20 return {
21 user: env.names?.user,
22 char: env.names?.char,
23 };
24 });
25
26 expect(result).toEqual({
27 user: 'UserOverride',
28 char: 'CharOverride',
29 });
30 });
31
32 test('falls back to global name1/name2 when overrides are not provided', async ({ page }) => {
33 const result = await page.evaluate(async () => {
34 const script = await import('./script.js');
35 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
36 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
37
38 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
39 const ctx = {
40 content: '',
41 };
42
43 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
44 return {
45 globalUser: script.name1,
46 globalChar: script.name2,
47 envUser: env.names?.user,
48 envChar: env.names?.char,
49 };
50 });
51
52 expect(result.envUser).toBe(result.globalUser);
53 expect(result.envChar).toBe(result.globalChar);
54 });
55
56 test('does not populate character fields when replaceCharacterCard is false', async ({ page }) => {
57 const keys = await page.evaluate(async () => {
58 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
59 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
60
61 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
62 const ctx = {
63 content: '',
64 replaceCharacterCard: false,
65 };
66
67 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
68 return Object.keys(env.character || {});
69 });
70
71 expect(keys).toEqual([]);
72 });
73
74 test('populates character fields when replaceCharacterCard is true', async ({ page }) => {
75 const keys = await page.evaluate(async () => {
76 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
77 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
78
79 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
80 const ctx = {
81 content: '',
82 replaceCharacterCard: true,
83 };
84
85 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
86 return Object.keys(env.character || {});
87 });
88
89 // We do not assert on concrete values, only that the known keys exist
90 expect(keys).toEqual(expect.arrayContaining([
91 'charPrompt',
92 'charInstruction',
93 'description',
94 'personality',
95 'scenario',
96 'persona',
97 'mesExamplesRaw',
98 'version',
99 'charDepthPrompt',
100 'creatorNotes',
101 ]));
102 });
103
104 test('wraps original string into a one-shot helper function', async ({ page }) => {
105 const result = await page.evaluate(async () => {
106 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
107 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
108
109 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
110 const ctx = {
111 content: '',
112 original: 'ORIGINAL_VALUE',
113 };
114
115 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
116 const hasFn = typeof env.functions?.original === 'function';
117 const first = hasFn ? env.functions.original() : null;
118 const second = hasFn ? env.functions.original() : null;
119
120 return { hasFn, first, second };
121 });
122
123 expect(result).toEqual({
124 hasFn: true,
125 first: 'ORIGINAL_VALUE',
126 second: '',
127 });
128 });
129
130 test('does not expose original helper when original is not a string', async ({ page }) => {
131 const hasFn = await page.evaluate(async () => {
132 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
133 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
134
135 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
136 const ctx = {
137 content: '',
138 original: undefined,
139 };
140
141 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
142 return typeof env.functions?.original === 'function';
143 });
144
145 expect(hasFn).toBe(false);
146 });
147
148 test('uses groupOverride string for all group-related name fields', async ({ page }) => {
149 const result = await page.evaluate(async () => {
150 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
151 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
152
153 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
154 const ctx = {
155 content: '',
156 groupOverride: 'Group One, Group Two',
157 };
158
159 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
160 return {
161 group: env.names?.group,
162 groupNotMuted: env.names?.groupNotMuted,
163 notChar: env.names?.notChar,
164 };
165 });
166
167 expect(result).toEqual({
168 group: 'Group One, Group Two',
169 groupNotMuted: 'Group One, Group Two',
170 notChar: 'Group One, Group Two',
171 });
172 });
173
174 test('uses solo-chat semantics when no group is selected', async ({ page }) => {
175 const result = await page.evaluate(async () => {
176 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
177 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
178 const groupChats = await import('./scripts/group-chats.js');
179
180 // Ensure we are in a solo-chat like state for this test
181 if (typeof groupChats.resetSelectedGroup === 'function') {
182 groupChats.resetSelectedGroup();
183 }
184
185 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
186 const ctx = {
187 content: '',
188 name1Override: 'UserSolo',
189 name2Override: 'CharSolo',
190 groupOverride: undefined,
191 };
192
193 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
194 return {
195 group: env.names?.group,
196 groupNotMuted: env.names?.groupNotMuted,
197 notChar: env.names?.notChar,
198 };
199 });
200
201 expect(result).toEqual({
202 group: 'CharSolo',
203 groupNotMuted: 'CharSolo',
204 notChar: 'UserSolo',
205 });
206 });
207
208 test('merges dynamicMacros properties into env.dynamicMacros', async ({ page }) => {
209 const dynamicMacros = await page.evaluate(async () => {
210 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
211 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
212
213 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
214 const ctx = {
215 content: '',
216 dynamicMacros: {
217 simple: 'value',
218 number: 42,
219 nested: { foo: 'bar' },
220 },
221 };
222
223 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
224 return env.dynamicMacros;
225 });
226
227 expect(dynamicMacros.simple).toBe('value');
228 expect(dynamicMacros.number).toBe(42);
229 expect(dynamicMacros.nested).toEqual({ foo: 'bar' });
230 });
231
232 test('sets system.model field from getGeneratingModel helper', async ({ page }) => {
233 const model = await page.evaluate(async () => {
234 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
235 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
236
237 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
238 const ctx = {
239 content: '',
240 };
241
242 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
243 return env.system?.model;
244 });
245
246 expect(typeof model === 'string' || model === undefined).toBe(true);
247 });
248
249 test('applies providers in the expected order buckets', async ({ page }) => {
250 const order = await page.evaluate(async () => {
251 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
252 const { MacroEnvBuilder, env_provider_order } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
253
254 MacroEnvBuilder.registerProvider((env) => {
255 env.extra.order = [...(env.extra.order || []), 'EARLY'];
256 }, env_provider_order.EARLY);
257
258 MacroEnvBuilder.registerProvider((env) => {
259 env.extra.order = [...(env.extra.order || []), 'LATE'];
260 }, env_provider_order.LATE);
261
262 MacroEnvBuilder.registerProvider((env) => {
263 env.extra.order = [...(env.extra.order || []), 'NORMAL'];
264 }, env_provider_order.NORMAL);
265
266 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
267 const ctx = {
268 content: '',
269 };
270
271 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
272 return env.extra.order;
273 });
274
275 // We only guarantee relative ordering between the buckets we added,
276 // not that there are no other entries from other providers.
277 const earlyIndex = order.indexOf('EARLY');
278 const normalIndex = order.indexOf('NORMAL');
279 const lateIndex = order.indexOf('LATE');
280
281 expect(earlyIndex).toBeGreaterThanOrEqual(0);
282 expect(normalIndex).toBeGreaterThan(earlyIndex);
283 expect(lateIndex).toBeGreaterThan(normalIndex);
284 });
285
286 test('ignores provider errors without breaking env construction', async ({ page }) => {
287 const result = await page.evaluate(async () => {
288 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
289 const { MacroEnvBuilder, env_provider_order } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
290
291 MacroEnvBuilder.registerProvider(() => {
292 throw new Error('intentional test error');
293 }, env_provider_order.NORMAL);
294
295 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
296 const ctx = {
297 content: '',
298 name1Override: 'User',
299 dynamicMacros: { marker: 'value' },
300 };
301
302 const env = MacroEnvBuilder.buildFromRawEnv(ctx);
303 return {
304 namesUser: env.names?.user,
305 hasDynamicMacro: env.dynamicMacros?.marker === 'value',
306 };
307 });
308
309 expect(result.hasDynamicMacro).toBe(true);
310 expect(result.namesUser).toBe('User');
311 });
312});
tests/frontend/MacroLexer.e2e.js+1136 -0
@@ -0,0 +1,1136 @@
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4/** @typedef {import('chevrotain').ILexingResult} ILexingResult */
5/** @typedef {import('chevrotain').ILexingError} ILexingError */
6/** @typedef {{type: string, text: string}} TestableToken */
7
8test.describe('MacroLexer', () => {
9 // Currently this test suits runs without ST context. Enable, if ever needed
10 test.beforeEach(testSetup.goST);
11
12 test.describe('General Macro', () => {
13 // {{user}}
14 test('should handle macro only', async ({ page }) => {
15 const input = '{{user}}';
16 const tokens = await runLexerGetTokens(page, input);
17
18 const expectedTokens = [
19 { type: 'Macro.Start', text: '{{' },
20 { type: 'Macro.Identifier', text: 'user' },
21 { type: 'Macro.End', text: '}}' },
22 ];
23
24 expect(tokens).toEqual(expectedTokens);
25 });
26 // {{}}
27 test('should handle empty macro', async ({ page }) => {
28 const input = '{{}}';
29 const tokens = await runLexerGetTokens(page, input);
30
31 const expectedTokens = [
32 { type: 'Macro.Start', text: '{{' },
33 { type: 'Macro.End', text: '}}' },
34 ];
35
36 expect(tokens).toEqual(expectedTokens);
37 });
38 // {{ user }}
39 test('should handle macro with leading and trailing whitespace inside', async ({ page }) => {
40 const input = '{{ user }}';
41 const tokens = await runLexerGetTokens(page, input);
42
43 const expectedTokens = [
44 { type: 'Macro.Start', text: '{{' },
45 { type: 'Macro.Identifier', text: 'user' },
46 { type: 'Macro.End', text: '}}' },
47 ];
48
49 expect(tokens).toEqual(expectedTokens);
50 });
51 // {{macro1}}{{macro2}}
52 test('should handle multiple sequential macros', async ({ page }) => {
53 const input = '{{macro1}}{{macro2}}';
54 const tokens = await runLexerGetTokens(page, input);
55
56 const expectedTokens = [
57 { type: 'Macro.Start', text: '{{' },
58 { type: 'Macro.Identifier', text: 'macro1' },
59 { type: 'Macro.End', text: '}}' },
60 { type: 'Macro.Start', text: '{{' },
61 { type: 'Macro.Identifier', text: 'macro2' },
62 { type: 'Macro.End', text: '}}' },
63 ];
64
65 expect(tokens).toEqual(expectedTokens);
66 });
67 });
68
69 test.describe('Macro Nesting', () => {
70 // {{outerMacro {{innerMacro}}}}
71 test('should handle nested macros', async ({ page }) => {
72 const input = '{{outerMacro {{innerMacro}}}}';
73 const tokens = await runLexerGetTokens(page, input);
74
75 const expectedTokens = [
76 { type: 'Macro.Start', text: '{{' },
77 { type: 'Macro.Identifier', text: 'outerMacro' },
78 { type: 'Macro.Start', text: '{{' },
79 { type: 'Macro.Identifier', text: 'innerMacro' },
80 { type: 'Macro.End', text: '}}' },
81 { type: 'Macro.End', text: '}}' },
82 ];
83
84 expect(tokens).toEqual(expectedTokens);
85 });
86 // {{doStuff "inner {{nested}} string"}}
87 test('should handle macros with nested quotation marks', async ({ page }) => {
88 const input = '{{doStuff "inner {{nested}} string"}}';
89 const tokens = await runLexerGetTokens(page, input);
90
91 const expectedTokens = [
92 { type: 'Macro.Start', text: '{{' },
93 { type: 'Macro.Identifier', text: 'doStuff' },
94 { type: 'Args.Quote', text: '"' },
95 { type: 'Identifier', text: 'inner' },
96 { type: 'Macro.Start', text: '{{' },
97 { type: 'Macro.Identifier', text: 'nested' },
98 { type: 'Macro.End', text: '}}' },
99 { type: 'Identifier', text: 'string' },
100 { type: 'Args.Quote', text: '"' },
101 { type: 'Macro.End', text: '}}' },
102 ];
103
104 expect(tokens).toEqual(expectedTokens);
105 });
106 });
107
108 test.describe('Macro Identifier', () => {
109 // {{ a }}
110 test('should allow one-character macro identifiers', async ({ page }) => {
111 const input = '{{ a }}';
112 const tokens = await runLexerGetTokens(page, input);
113
114 const expectedTokens = [
115 { type: 'Macro.Start', text: '{{' },
116 { type: 'Macro.Identifier', text: 'a' },
117 { type: 'Macro.End', text: '}}' },
118 ];
119
120 expect(tokens).toEqual(expectedTokens);
121 });
122 // {{ some macro }}
123 test('should only capture the first identifier as macro identifier when there are whitespaces between two valid identifiers', async ({ page }) => {
124 const input = '{{ some macro }}';
125 const tokens = await runLexerGetTokens(page, input);
126
127 const expectedTokens = [
128 { type: 'Macro.Start', text: '{{' },
129 { type: 'Macro.Identifier', text: 'some' },
130 { type: 'Identifier', text: 'macro' },
131 { type: 'Macro.End', text: '}}' },
132 ];
133
134 expect(tokens).toEqual(expectedTokens);
135 });
136 // {{my2cents}}
137 test('should allow numerics inside the macro identifier', async ({ page }) => {
138 const input = '{{my2cents}}';
139 const tokens = await runLexerGetTokens(page, input);
140
141 const expectedTokens = [
142 { type: 'Macro.Start', text: '{{' },
143 { type: 'Macro.Identifier', text: 'my2cents' },
144 { type: 'Macro.End', text: '}}' },
145 ];
146
147 expect(tokens).toEqual(expectedTokens);
148 });
149 // {{SCREAM}}
150 test('should allow capslock macro', async ({ page }) => {
151 const input = '{{SCREAM}}';
152 const tokens = await runLexerGetTokens(page, input);
153
154 const expectedTokens = [
155 { type: 'Macro.Start', text: '{{' },
156 { type: 'Macro.Identifier', text: 'SCREAM' },
157 { type: 'Macro.End', text: '}}' },
158 ];
159
160 expect(tokens).toEqual(expectedTokens);
161 });
162 // {{some-longer-macro}}
163 test('should allow dashes in macro identifiers', async ({ page }) => {
164 const input = '{{some-longer-macro}}';
165 const tokens = await runLexerGetTokens(page, input);
166
167 const expectedTokens = [
168 { type: 'Macro.Start', text: '{{' },
169 { type: 'Macro.Identifier', text: 'some-longer-macro' },
170 { type: 'Macro.End', text: '}}' },
171 ];
172
173 expect(tokens).toEqual(expectedTokens);
174 });
175 // {{legacy_macro}}
176 test('should allow underscores as legacy in macro identifiers', async ({ page }) => {
177 const input = '{{legacy_macro}}';
178 const tokens = await runLexerGetTokens(page, input);
179
180 const expectedTokens = [
181 { type: 'Macro.Start', text: '{{' },
182 { type: 'Macro.Identifier', text: 'legacy_macro' },
183 { type: 'Macro.End', text: '}}' },
184 ];
185
186 expect(tokens).toEqual(expectedTokens);
187 });
188
189 test.describe('Error Cases (Macro Identifier)', () => {
190 // {{macro!@#%}}
191 test('[Error] should not lex special characters as part of the macro identifier', async ({ page }) => {
192 const input = '{{macro!@#%}}';
193 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
194
195 const expectedErrors = [
196 { message: 'unexpected character: ->!<- at offset: 7, skipped 4 characters.' },
197 ];
198
199 expect(errors).toMatchObject(expectedErrors);
200
201 const expectedTokens = [
202 { type: 'Macro.Start', text: '{{' },
203 { type: 'Macro.Identifier', text: 'macro' },
204 // Do not lex the wrong characters
205 { type: 'Macro.End', text: '}}' },
206 ];
207
208 expect(tokens).toEqual(expectedTokens);
209 });
210 // {{ma!@#%ro}}
211 test('[Error] should not parse invalid chars in macro identifier as valid macro identifier', async ({ page }) => {
212 const input = '{{ma!@#%ro}}';
213 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
214
215 const expectedErrors = [
216 { message: 'unexpected character: ->!<- at offset: 4, skipped 6 characters.' },
217 ];
218
219 expect(errors).toMatchObject(expectedErrors);
220
221 const expectedTokens = [
222 { type: 'Macro.Start', text: '{{' },
223 { type: 'Macro.Identifier', text: 'ma' },
224 // Do not lex the wrong characters
225 { type: 'Macro.End', text: '}}' },
226 ];
227
228 expect(tokens).toEqual(expectedTokens);
229 });
230 });
231 });
232
233 test.describe('Macro Arguments', () => {
234 // {{setvar::myVar::This is Sparta!}}
235 test('should tokenize macros with double colons arguments correctly', async ({ page }) => {
236 const input = '{{setvar::myVar::This is Sparta!}}';
237 const tokens = await runLexerGetTokens(page, input);
238
239 const expectedTokens = [
240 { type: 'Macro.Start', text: '{{' },
241 { type: 'Macro.Identifier', text: 'setvar' },
242 { type: 'Args.DoubleColon', text: '::' },
243 { type: 'Identifier', text: 'myVar' },
244 { type: 'Args.DoubleColon', text: '::' },
245 { type: 'Identifier', text: 'This' },
246 { type: 'Identifier', text: 'is' },
247 { type: 'Identifier', text: 'Sparta' },
248 { type: 'Unknown', text: '!' },
249 { type: 'Macro.End', text: '}}' },
250 ];
251
252 expect(tokens).toEqual(expectedTokens);
253 });
254 // {{doStuff key=MyValue another=AnotherValue}}
255 test('should handle named arguments with key=value syntax', async ({ page }) => {
256 const input = '{{doStuff key=MyValue another=AnotherValue}}';
257 const tokens = await runLexerGetTokens(page, input);
258
259 const expectedTokens = [
260 { type: 'Macro.Start', text: '{{' },
261 { type: 'Macro.Identifier', text: 'doStuff' },
262 { type: 'Identifier', text: 'key' },
263 { type: 'Args.Equals', text: '=' },
264 { type: 'Identifier', text: 'MyValue' },
265 { type: 'Identifier', text: 'another' },
266 { type: 'Args.Equals', text: '=' },
267 { type: 'Identifier', text: 'AnotherValue' },
268 { type: 'Macro.End', text: '}}' },
269 ];
270
271 expect(tokens).toEqual(expectedTokens);
272 });
273 // {{getvar key="My variable"}}
274 test('should handle named arguments with quotation marks', async ({ page }) => {
275 const input = '{{getvar key="My variable"}}';
276 const tokens = await runLexerGetTokens(page, input);
277
278 const expectedTokens = [
279 { type: 'Macro.Start', text: '{{' },
280 { type: 'Macro.Identifier', text: 'getvar' },
281 { type: 'Identifier', text: 'key' },
282 { type: 'Args.Equals', text: '=' },
283 { type: 'Args.Quote', text: '"' },
284 { type: 'Identifier', text: 'My' },
285 { type: 'Identifier', text: 'variable' },
286 { type: 'Args.Quote', text: '"' },
287 { type: 'Macro.End', text: '}}' },
288 ];
289
290 expect(tokens).toEqual(expectedTokens);
291 });
292 // {{getvar KEY=big}}
293 test('should handle capslock argument name identifiers', async ({ page }) => {
294 const input = '{{getvar KEY=big}}';
295 const tokens = await runLexerGetTokens(page, input);
296
297 const expectedTokens = [
298 { type: 'Macro.Start', text: '{{' },
299 { type: 'Macro.Identifier', text: 'getvar' },
300 { type: 'Identifier', text: 'KEY' },
301 { type: 'Args.Equals', text: '=' },
302 { type: 'Identifier', text: 'big' },
303 { type: 'Macro.End', text: '}}' },
304 ];
305
306 expect(tokens).toEqual(expectedTokens);
307 });
308 // {{dostuff longer-key=value}}
309 test('should handle argument name identifiers with dashes', async ({ page }) => {
310 const input = '{{dostuff longer-key=value}}';
311 const tokens = await runLexerGetTokens(page, input);
312
313 const expectedTokens = [
314 { type: 'Macro.Start', text: '{{' },
315 { type: 'Macro.Identifier', text: 'dostuff' },
316 { type: 'Identifier', text: 'longer-key' },
317 { type: 'Args.Equals', text: '=' },
318 { type: 'Identifier', text: 'value' },
319 { type: 'Macro.End', text: '}}' },
320 ];
321
322 expect(tokens).toEqual(expectedTokens);
323 });
324 // {{macro legacy_key=blah}}
325 test('should handle legacy argument name identifiers', async ({ page }) => {
326 const input = '{{macro legacy_key=blah}}';
327 const tokens = await runLexerGetTokens(page, input);
328
329 const expectedTokens = [
330 { type: 'Macro.Start', text: '{{' },
331 { type: 'Macro.Identifier', text: 'macro' },
332 { type: 'Identifier', text: 'legacy_key' },
333 { type: 'Args.Equals', text: '=' },
334 { type: 'Identifier', text: 'blah' },
335 { type: 'Macro.End', text: '}}' },
336 ];
337
338 expect(tokens).toEqual(expectedTokens);
339 });
340 // {{roll:1d4}}
341 test('should handle argument with legacy one colon syntax to start the arguments', async ({ page }) => {
342 const input = '{{roll:1d4}}';
343 const tokens = await runLexerGetTokens(page, input);
344
345 const expectedTokens = [
346 { type: 'Macro.Start', text: '{{' },
347 { type: 'Macro.Identifier', text: 'roll' },
348 { type: 'Args.Colon', text: ':' },
349 { type: 'Unknown', text: '1' },
350 { type: 'Identifier', text: 'd4' },
351 { type: 'Macro.End', text: '}}' },
352 ];
353
354 expect(tokens).toEqual(expectedTokens);
355 });
356 // {{random "this" "and that" "and some more"}}
357 test('should handle multiple unnamed arguments in quotation marks', async ({ page }) => {
358 const input = '{{random "this" "and that" "and some more"}}';
359 const tokens = await runLexerGetTokens(page, input);
360
361 const expectedTokens = [
362 { type: 'Macro.Start', text: '{{' },
363 { type: 'Macro.Identifier', text: 'random' },
364 { type: 'Args.Quote', text: '"' },
365 { type: 'Identifier', text: 'this' },
366 { type: 'Args.Quote', text: '"' },
367 { type: 'Args.Quote', text: '"' },
368 { type: 'Identifier', text: 'and' },
369 { type: 'Identifier', text: 'that' },
370 { type: 'Args.Quote', text: '"' },
371 { type: 'Args.Quote', text: '"' },
372 { type: 'Identifier', text: 'and' },
373 { type: 'Identifier', text: 'some' },
374 { type: 'Identifier', text: 'more' },
375 { type: 'Args.Quote', text: '"' },
376 { type: 'Macro.End', text: '}}' },
377 ];
378
379 expect(tokens).toEqual(expectedTokens);
380 });
381 // {{doStuff key="My Spaced Value" otherKey=SingleKey}}
382 test('should handle named arguments with mixed style', async ({ page }) => {
383 const input = '{{doStuff key="My Spaced Value" otherKey=SingleKey}}';
384 const tokens = await runLexerGetTokens(page, input);
385
386 const expectedTokens = [
387 { type: 'Macro.Start', text: '{{' },
388 { type: 'Macro.Identifier', text: 'doStuff' },
389 { type: 'Identifier', text: 'key' },
390 { type: 'Args.Equals', text: '=' },
391 { type: 'Args.Quote', text: '"' },
392 { type: 'Identifier', text: 'My' },
393 { type: 'Identifier', text: 'Spaced' },
394 { type: 'Identifier', text: 'Value' },
395 { type: 'Args.Quote', text: '"' },
396 { type: 'Identifier', text: 'otherKey' },
397 { type: 'Args.Equals', text: '=' },
398 { type: 'Identifier', text: 'SingleKey' },
399 { type: 'Macro.End', text: '}}' },
400 ];
401
402 expect(tokens).toEqual(expectedTokens);
403 });
404 // {{doStuff key=}}
405 test('should handle macros with empty named arguments', async ({ page }) => {
406 const input = '{{doStuff key=}}';
407 const tokens = await runLexerGetTokens(page, input);
408
409 const expectedTokens = [
410 { type: 'Macro.Start', text: '{{' },
411 { type: 'Macro.Identifier', text: 'doStuff' },
412 { type: 'Identifier', text: 'key' },
413 { type: 'Args.Equals', text: '=' },
414 { type: 'Macro.End', text: '}}' },
415 ];
416
417 expect(tokens).toEqual(expectedTokens);
418 });
419 // {{random "" ""}}
420 test('should handle empty unnamed arguments if quoted', async ({ page }) => {
421 const input = '{{random "" ""}}';
422 const tokens = await runLexerGetTokens(page, input);
423
424 const expectedTokens = [
425 { type: 'Macro.Start', text: '{{' },
426 { type: 'Macro.Identifier', text: 'random' },
427 { type: 'Args.Quote', text: '"' },
428 { type: 'Args.Quote', text: '"' },
429 { type: 'Args.Quote', text: '"' },
430 { type: 'Args.Quote', text: '"' },
431 { type: 'Macro.End', text: '}}' },
432 ];
433
434 expect(tokens).toEqual(expectedTokens);
435 });
436 // {{doStuff special chars #!@&*()}}
437 test('should handle macros with special characters in arguments', async ({ page }) => {
438 const input = '{{doStuff special chars #!@&*()}}';
439 const tokens = await runLexerGetTokens(page, input);
440
441 const expectedTokens = [
442 { type: 'Macro.Start', text: '{{' },
443 { type: 'Macro.Identifier', text: 'doStuff' },
444 { type: 'Identifier', text: 'special' },
445 { type: 'Identifier', text: 'chars' },
446 { type: 'Unknown', text: '#' },
447 { type: 'Unknown', text: '!' },
448 { type: 'Unknown', text: '@' },
449 { type: 'Unknown', text: '&' },
450 { type: 'Unknown', text: '*' },
451 { type: 'Unknown', text: '(' },
452 { type: 'Unknown', text: ')' },
453 { type: 'Macro.End', text: '}}' },
454 ];
455
456 expect(tokens).toEqual(expectedTokens);
457 });
458 // {{longMacro arg1="value1" arg2="value2" arg3="value3"}}
459 test('should handle long macros with multiple arguments', async ({ page }) => {
460 const input = '{{longMacro arg1="value1" arg2="value2" arg3="value3"}}';
461 const tokens = await runLexerGetTokens(page, input);
462
463 const expectedTokens = [
464 { type: 'Macro.Start', text: '{{' },
465 { type: 'Macro.Identifier', text: 'longMacro' },
466 { type: 'Identifier', text: 'arg1' },
467 { type: 'Args.Equals', text: '=' },
468 { type: 'Args.Quote', text: '"' },
469 { type: 'Identifier', text: 'value1' },
470 { type: 'Args.Quote', text: '"' },
471 { type: 'Identifier', text: 'arg2' },
472 { type: 'Args.Equals', text: '=' },
473 { type: 'Args.Quote', text: '"' },
474 { type: 'Identifier', text: 'value2' },
475 { type: 'Args.Quote', text: '"' },
476 { type: 'Identifier', text: 'arg3' },
477 { type: 'Args.Equals', text: '=' },
478 { type: 'Args.Quote', text: '"' },
479 { type: 'Identifier', text: 'value3' },
480 { type: 'Args.Quote', text: '"' },
481 { type: 'Macro.End', text: '}}' },
482 ];
483
484 expect(tokens).toEqual(expectedTokens);
485 });
486 // {{complexMacro "text with {{nested}} content" key=val}}
487 test('should handle macros with complex argument patterns', async ({ page }) => {
488 const input = '{{complexMacro "text with {{nested}} content" key=val}}';
489 const tokens = await runLexerGetTokens(page, input);
490
491 const expectedTokens = [
492 { type: 'Macro.Start', text: '{{' },
493 { type: 'Macro.Identifier', text: 'complexMacro' },
494 { type: 'Args.Quote', text: '"' },
495 { type: 'Identifier', text: 'text' },
496 { type: 'Identifier', text: 'with' },
497 { type: 'Macro.Start', text: '{{' },
498 { type: 'Macro.Identifier', text: 'nested' },
499 { type: 'Macro.End', text: '}}' },
500 { type: 'Identifier', text: 'content' },
501 { type: 'Args.Quote', text: '"' },
502 { type: 'Identifier', text: 'key' },
503 { type: 'Args.Equals', text: '=' },
504 { type: 'Identifier', text: 'val' },
505 { type: 'Macro.End', text: '}}' },
506 ];
507
508 expect(tokens).toEqual(expectedTokens);
509 });
510 // TODO: test invalid argument name identifiers
511 });
512
513 test.describe('Macro Execution Modifiers', () => {
514 // {{!immediate}}
515 test('should support ! flag', async ({ page }) => {
516 const input = '{{!immediate}}';
517 const tokens = await runLexerGetTokens(page, input);
518
519 const expectedTokens = [
520 { type: 'Macro.Start', text: '{{' },
521 { type: 'Macro.Flag', text: '!' },
522 { type: 'Macro.Identifier', text: 'immediate' },
523 { type: 'Macro.End', text: '}}' },
524 ];
525
526 expect(tokens).toEqual(expectedTokens);
527 });
528 // {{?lazy}}
529 test('should support ? flag', async ({ page }) => {
530 const input = '{{?lazy}}';
531 const tokens = await runLexerGetTokens(page, input);
532
533 const expectedTokens = [
534 { type: 'Macro.Start', text: '{{' },
535 { type: 'Macro.Flag', text: '?' },
536 { type: 'Macro.Identifier', text: 'lazy' },
537 { type: 'Macro.End', text: '}}' },
538 ];
539
540 expect(tokens).toEqual(expectedTokens);
541 });
542 // {{~reevaluate}}
543 test('should support ~ flag', async ({ page }) => {
544 const input = '{{~reevaluate}}';
545 const tokens = await runLexerGetTokens(page, input);
546
547 const expectedTokens = [
548 { type: 'Macro.Start', text: '{{' },
549 { type: 'Macro.Flag', text: '~' },
550 { type: 'Macro.Identifier', text: 'reevaluate' },
551 { type: 'Macro.End', text: '}}' },
552 ];
553
554 expect(tokens).toEqual(expectedTokens);
555 });
556 // {{/if}}
557 test('should support / flag', async ({ page }) => {
558 const input = '{{/if}}';
559 const tokens = await runLexerGetTokens(page, input);
560
561 const expectedTokens = [
562 { type: 'Macro.Start', text: '{{' },
563 { type: 'Macro.Flag', text: '/' },
564 { type: 'Macro.Identifier', text: 'if' },
565 { type: 'Macro.End', text: '}}' },
566 ];
567
568 expect(tokens).toEqual(expectedTokens);
569 });
570 // {{.variable}}
571 test('should support . flag', async ({ page }) => {
572 const input = '{{.variable}}';
573 const tokens = await runLexerGetTokens(page, input);
574
575 const expectedTokens = [
576 { type: 'Macro.Start', text: '{{' },
577 { type: 'Macro.Flag', text: '.' },
578 { type: 'Macro.Identifier', text: 'variable' },
579 { type: 'Macro.End', text: '}}' },
580 ];
581
582 expect(tokens).toEqual(expectedTokens);
583 });
584 // {{$variable}}
585 test('should support alias $ flag', async ({ page }) => {
586 const input = '{{$variable}}';
587 const tokens = await runLexerGetTokens(page, input);
588
589 const expectedTokens = [
590 { type: 'Macro.Start', text: '{{' },
591 { type: 'Macro.Flag', text: '$' },
592 { type: 'Macro.Identifier', text: 'variable' },
593 { type: 'Macro.End', text: '}}' },
594 ];
595
596 expect(tokens).toEqual(expectedTokens);
597 });
598 // {{#legacy}}
599 test('should support legacy # flag', async ({ page }) => {
600 const input = '{{#legacy}}';
601 const tokens = await runLexerGetTokens(page, input);
602
603 const expectedTokens = [
604 { type: 'Macro.Start', text: '{{' },
605 { type: 'Macro.Flag', text: '#' },
606 { type: 'Macro.Identifier', text: 'legacy' },
607 { type: 'Macro.End', text: '}}' },
608 ];
609
610 expect(tokens).toEqual(expectedTokens);
611 });
612 // {{ ! identifier }}
613 test('should allow whitespaces around flags', async ({ page }) => {
614 const input = '{{ ! identifier }}';
615 const tokens = await runLexerGetTokens(page, input);
616
617 const expectedTokens = [
618 { type: 'Macro.Start', text: '{{' },
619 { type: 'Macro.Flag', text: '!' },
620 { type: 'Macro.Identifier', text: 'identifier' },
621 { type: 'Macro.End', text: '}}' },
622 ];
623
624 expect(tokens).toEqual(expectedTokens);
625 });
626 // {{ ?~lateragain }}
627 test('should support multiple flags', async ({ page }) => {
628 const input = '{{ ?~lateragain }}';
629 const tokens = await runLexerGetTokens(page, input);
630
631 const expectedTokens = [
632 { type: 'Macro.Start', text: '{{' },
633 { type: 'Macro.Flag', text: '?' },
634 { type: 'Macro.Flag', text: '~' },
635 { type: 'Macro.Identifier', text: 'lateragain' },
636 { type: 'Macro.End', text: '}}' },
637 ];
638
639 expect(tokens).toEqual(expectedTokens);
640 });
641 // {{ ! .importantvariable }}
642 test('should support multiple flags with whitespace', async ({ page }) => {
643 const input = '{{ !.importantvariable }}';
644 const tokens = await runLexerGetTokens(page, input);
645
646 const expectedTokens = [
647 { type: 'Macro.Start', text: '{{' },
648 { type: 'Macro.Flag', text: '!' },
649 { type: 'Macro.Flag', text: '.' },
650 { type: 'Macro.Identifier', text: 'importantvariable' },
651 { type: 'Macro.End', text: '}}' },
652 ];
653
654 expect(tokens).toEqual(expectedTokens);
655 });
656 // {{ a shaaark }}
657 test('should not capture single letter as flag, but as macro identifiers', async ({ page }) => {
658 const input = '{{ a shaaark }}';
659 const tokens = await runLexerGetTokens(page, input);
660
661 const expectedTokens = [
662 { type: 'Macro.Start', text: '{{' },
663 { type: 'Macro.Identifier', text: 'a' },
664 { type: 'Identifier', text: 'shaaark' },
665 { type: 'Macro.End', text: '}}' },
666 ];
667
668 expect(tokens).toEqual(expectedTokens);
669 });
670
671 test.describe('Error Cases (Macro Execution Modifiers)', () => {
672 // {{ @unknown }}
673 test('[Error] should not capture unknown special characters as flag', async ({ page }) => {
674 const input = '{{ @unknown }}';
675 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
676
677 const expectedErrors = [
678 { message: 'unexpected character: ->@<- at offset: 3, skipped 1 characters.' },
679 ];
680
681 expect(errors).toMatchObject(expectedErrors);
682
683 const expectedTokens = [
684 { type: 'Macro.Start', text: '{{' },
685 // Do not capture '@' as anything, as it's a lexer error
686 { type: 'Macro.Identifier', text: 'unknown' },
687 { type: 'Macro.End', text: '}}' },
688 ];
689
690 expect(tokens).toEqual(expectedTokens);
691 });
692 // {{ 2 cents }}
693 test('[Error] should not capture numbers as flag - they are also invalid macro identifiers', async ({ page }) => {
694 const input = '{{ 2 cents }}';
695 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
696
697 const expectedErrors = [
698 { message: 'unexpected character: ->2<- at offset: 3, skipped 1 characters.' },
699 ];
700 expect(errors).toMatchObject(expectedErrors);
701
702 const expectedTokens = [
703 { type: 'Macro.Start', text: '{{' },
704 // Do not capture '2' as anything, as it's a lexer error
705 { type: 'Macro.Identifier', text: 'cents' },
706 { type: 'Macro.End', text: '}}' },
707 ];
708
709 expect(tokens).toEqual(expectedTokens);
710 });
711 });
712 });
713
714 test.describe('Macro Output Modifiers', () => {
715 // {{macro | outputModifier}}
716 test('should support output modifier without arguments', async ({ page }) => {
717 const input = '{{macro | outputModifier}}';
718 const tokens = await runLexerGetTokens(page, input);
719
720 const expectedTokens = [
721 { type: 'Macro.Start', text: '{{' },
722 { type: 'Macro.Identifier', text: 'macro' },
723 { type: 'Filter.Pipe', text: '|' },
724 { type: 'Filter.Identifier', text: 'outputModifier' },
725 { type: 'Macro.End', text: '}}' },
726 ];
727
728 expect(tokens).toEqual(expectedTokens);
729 });
730 // {{macro | outputModifier arg1=val1 arg2=val2}}
731 test('should support output modifier with named arguments', async ({ page }) => {
732 const input = '{{macro | outputModifier arg1=val1 arg2=val2}}';
733 const tokens = await runLexerGetTokens(page, input);
734
735 const expectedTokens = [
736 { type: 'Macro.Start', text: '{{' },
737 { type: 'Macro.Identifier', text: 'macro' },
738 { type: 'Filter.Pipe', text: '|' },
739 { type: 'Filter.Identifier', text: 'outputModifier' },
740 { type: 'Identifier', text: 'arg1' },
741 { type: 'Args.Equals', text: '=' },
742 { type: 'Identifier', text: 'val1' },
743 { type: 'Identifier', text: 'arg2' },
744 { type: 'Args.Equals', text: '=' },
745 { type: 'Identifier', text: 'val2' },
746 { type: 'Macro.End', text: '}}' },
747 ];
748
749 expect(tokens).toEqual(expectedTokens);
750 });
751 // {{macro | outputModifier "unnamed1" "unnamed2"}}
752 test('should support output modifier with unnamed arguments', async ({ page }) => {
753 const input = '{{macro | outputModifier "unnamed1" "unnamed2"}}';
754 const tokens = await runLexerGetTokens(page, input);
755
756 const expectedTokens = [
757 { type: 'Macro.Start', text: '{{' },
758 { type: 'Macro.Identifier', text: 'macro' },
759 { type: 'Filter.Pipe', text: '|' },
760 { type: 'Filter.Identifier', text: 'outputModifier' },
761 { type: 'Args.Quote', text: '"' },
762 { type: 'Identifier', text: 'unnamed1' },
763 { type: 'Args.Quote', text: '"' },
764 { type: 'Args.Quote', text: '"' },
765 { type: 'Identifier', text: 'unnamed2' },
766 { type: 'Args.Quote', text: '"' },
767 { type: 'Macro.End', text: '}}' },
768 ];
769
770 expect(tokens).toEqual(expectedTokens);
771 });
772 // {{macro arg1=val1 | outputModifier arg2=val2 "unnamed1"}}
773 test('should support macro arguments before output modifier', async ({ page }) => {
774 const input = '{{macro arg1=val1 | outputModifier arg2=val2 "unnamed1"}}';
775 const tokens = await runLexerGetTokens(page, input);
776
777 const expectedTokens = [
778 { type: 'Macro.Start', text: '{{' },
779 { type: 'Macro.Identifier', text: 'macro' },
780 { type: 'Identifier', text: 'arg1' },
781 { type: 'Args.Equals', text: '=' },
782 { type: 'Identifier', text: 'val1' },
783 { type: 'Filter.Pipe', text: '|' },
784 { type: 'Filter.Identifier', text: 'outputModifier' },
785 { type: 'Identifier', text: 'arg2' },
786 { type: 'Args.Equals', text: '=' },
787 { type: 'Identifier', text: 'val2' },
788 { type: 'Args.Quote', text: '"' },
789 { type: 'Identifier', text: 'unnamed1' },
790 { type: 'Args.Quote', text: '"' },
791 { type: 'Macro.End', text: '}}' },
792 ];
793
794 expect(tokens).toEqual(expectedTokens);
795 });
796 // {{macro | outputModifier1 | outputModifier2}}
797 test('should support chaining multiple output modifiers', async ({ page }) => {
798 const input = '{{macro | outputModifier1 | outputModifier2}}';
799 const tokens = await runLexerGetTokens(page, input);
800
801 const expectedTokens = [
802 { type: 'Macro.Start', text: '{{' },
803 { type: 'Macro.Identifier', text: 'macro' },
804 { type: 'Filter.Pipe', text: '|' },
805 { type: 'Filter.Identifier', text: 'outputModifier1' },
806 { type: 'Filter.Pipe', text: '|' },
807 { type: 'Filter.Identifier', text: 'outputModifier2' },
808 { type: 'Macro.End', text: '}}' },
809 ];
810
811 expect(tokens).toEqual(expectedTokens);
812 });
813 // {{macro | outputModifier1 arg1=val1 | outputModifier2 arg2=val2}}
814 test('should support chaining multiple output modifiers with arguments', async ({ page }) => {
815 const input = '{{macro | outputModifier1 arg1=val1 | outputModifier2 arg2=val2}}';
816 const tokens = await runLexerGetTokens(page, input);
817
818 const expectedTokens = [
819 { type: 'Macro.Start', text: '{{' },
820 { type: 'Macro.Identifier', text: 'macro' },
821 { type: 'Filter.Pipe', text: '|' },
822 { type: 'Filter.Identifier', text: 'outputModifier1' },
823 { type: 'Identifier', text: 'arg1' },
824 { type: 'Args.Equals', text: '=' },
825 { type: 'Identifier', text: 'val1' },
826 { type: 'Filter.Pipe', text: '|' },
827 { type: 'Filter.Identifier', text: 'outputModifier2' },
828 { type: 'Identifier', text: 'arg2' },
829 { type: 'Args.Equals', text: '=' },
830 { type: 'Identifier', text: 'val2' },
831 { type: 'Macro.End', text: '}}' },
832 ];
833
834 expect(tokens).toEqual(expectedTokens);
835 });
836 // {{macro|outputModifier}}
837 test('should support output modifiers without whitespace', async ({ page }) => {
838 const input = '{{macro|outputModifier}}';
839 const tokens = await runLexerGetTokens(page, input);
840
841 const expectedTokens = [
842 { type: 'Macro.Start', text: '{{' },
843 { type: 'Macro.Identifier', text: 'macro' },
844 { type: 'Filter.Pipe', text: '|' },
845 { type: 'Filter.Identifier', text: 'outputModifier' },
846 { type: 'Macro.End', text: '}}' },
847 ];
848
849 expect(tokens).toEqual(expectedTokens);
850 });
851 // {{ macro test escaped \| pipe }}
852 test('should support escaped pipes, not treating them as output modifiers', async ({ page }) => {
853 const input = '{{ macro test escaped \\| pipe }}';
854 const tokens = await runLexerGetTokens(page, input);
855
856 const expectedTokens = [
857 { type: 'Macro.Start', text: '{{' },
858 { type: 'Macro.Identifier', text: 'macro' },
859 { type: 'Identifier', text: 'test' },
860 { type: 'Identifier', text: 'escaped' },
861 { type: 'Filter.EscapedPipe', text: '\\|' },
862 { type: 'Identifier', text: 'pipe' },
863 { type: 'Macro.End', text: '}}' },
864 ];
865
866 expect(tokens).toEqual(expectedTokens);
867 });
868
869 test.describe('Error Cases (Macro Output Modifiers)', () => {
870 // {{|macro}}
871 test('[Error] should not capture when starting the macro with a pipe', async ({ page }) => {
872 const input = '{{|macro}}';
873 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
874
875 const expectedErrors = [
876 { message: 'unexpected character: ->|<- at offset: 2, skipped 1 characters.' },
877 ];
878
879 expect(errors).toMatchObject(expectedErrors);
880
881 const expectedTokens = [
882 { type: 'Macro.Start', text: '{{' },
883 { type: 'Macro.Identifier', text: 'macro' },
884 { type: 'Macro.End', text: '}}' },
885 ];
886
887 expect(tokens).toEqual(expectedTokens);
888 });
889 // {{macro | Iam$peci@l}}
890 test('[Error] should not allow special characters inside output modifier identifier', async ({ page }) => {
891 const input = '{{macro | Iam$peci@l}}';
892 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
893
894 const expectedErrors = [
895 { message: 'unexpected character: ->$<- at offset: 13, skipped 7 characters.' },
896 ];
897
898 expect(errors).toMatchObject(expectedErrors);
899
900 const expectedTokens = [
901 { type: 'Macro.Start', text: '{{' },
902 { type: 'Macro.Identifier', text: 'macro' },
903 { type: 'Filter.Pipe', text: '|' },
904 { type: 'Filter.Identifier', text: 'Iam' },
905 { type: 'Macro.End', text: '}}' },
906 ];
907
908 expect(tokens).toEqual(expectedTokens);
909 });
910 // {{macro | !cannotBeImportant }}
911 test('[Error] should not allow output modifiers to have execution modifiers', async ({ page }) => {
912 const input = '{{macro | !cannotBeImportant }}';
913 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
914
915 const expectedErrors = [
916 { message: 'unexpected character: ->!<- at offset: 10, skipped 1 characters.' },
917 ];
918
919 expect(errors).toMatchObject(expectedErrors);
920
921 const expectedTokens = [
922 { type: 'Macro.Start', text: '{{' },
923 { type: 'Macro.Identifier', text: 'macro' },
924 { type: 'Filter.Pipe', text: '|' },
925 { type: 'Filter.Identifier', text: 'cannotBeImportant' },
926 { type: 'Macro.End', text: '}}' },
927 ];
928
929 expect(tokens).toEqual(expectedTokens);
930 });
931 // {{macro | 2invalidIdentifier}}
932 test('[Error] should not allow invalid identifier starting with a number', async ({ page }) => {
933 const input = '{{macro | 2invalidIdentifier}}';
934 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
935
936 const expectedErrors = [
937 { message: 'unexpected character: ->2<- at offset: 10, skipped 1 characters.' },
938 ];
939
940 expect(errors).toMatchObject(expectedErrors);
941
942 const expectedTokens = [
943 { type: 'Macro.Start', text: '{{' },
944 { type: 'Macro.Identifier', text: 'macro' },
945 { type: 'Filter.Pipe', text: '|' },
946 { type: 'Filter.Identifier', text: 'invalidIdentifier' },
947 { type: 'Macro.End', text: '}}' },
948 ];
949
950 expect(tokens).toEqual(expectedTokens);
951 });
952 // {{macro || outputModifier}}
953 test('[Error] should not allow double pipe used without an identifier', async ({ page }) => {
954 const input = '{{macro || outputModifier}}';
955 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
956
957 const expectedErrors = [
958 { message: 'unexpected character: ->|<- at offset: 9, skipped 1 characters.' },
959 ];
960
961 expect(errors).toMatchObject(expectedErrors);
962
963 const expectedTokens = [
964 { type: 'Macro.Start', text: '{{' },
965 { type: 'Macro.Identifier', text: 'macro' },
966 { type: 'Filter.Pipe', text: '|' },
967 { type: 'Filter.Identifier', text: 'outputModifier' },
968 { type: 'Macro.End', text: '}}' },
969 ];
970
971 expect(tokens).toEqual(expectedTokens);
972 });
973 });
974 });
975
976 test.describe('Macro While Typing..', () => {
977 // {{unclosed_macro word and more. Done.
978 test('should allow unclosed macros, but tries to parse it as a macro', async ({ page }) => {
979 const input = '{{unclosed_macro word and more. Done.';
980 const tokens = await runLexerGetTokens(page, input);
981
982 const expectedTokens = [
983 { type: 'Macro.Start', text: '{{' },
984 { type: 'Macro.Identifier', text: 'unclosed_macro' },
985 { type: 'Identifier', text: 'word' },
986 { type: 'Identifier', text: 'and' },
987 { type: 'Identifier', text: 'more' },
988 { type: 'Unknown', text: '.' },
989 { type: 'Identifier', text: 'Done' },
990 { type: 'Unknown', text: '.' },
991 ];
992
993 expect(tokens).toEqual(expectedTokens);
994 });
995 });
996
997 test.describe('Macro and Plaintext', () => {
998 // Hello, {{user}}!
999 test('should handle basic macro tokenization', async ({ page }) => {
1000 const input = 'Hello, {{user}}!';
1001 const tokens = await runLexerGetTokens(page, input);
1002
1003 const expectedTokens = [
1004 { type: 'Plaintext', text: 'Hello, ' },
1005 { type: 'Macro.Start', text: '{{' },
1006 { type: 'Macro.Identifier', text: 'user' },
1007 { type: 'Macro.End', text: '}}' },
1008 { type: 'Plaintext', text: '!' },
1009 ];
1010
1011 // Compare the actual result with expected tokens
1012 expect(tokens).toEqual(expectedTokens);
1013 });
1014 // Just some text here.
1015 test('should tokenize plaintext only', async ({ page }) => {
1016 const input = 'Just some text here.';
1017 const tokens = await runLexerGetTokens(page, input);
1018
1019 const expectedTokens = [
1020 { type: 'Plaintext', text: 'Just some text here.' },
1021 ];
1022
1023 expect(tokens).toEqual(expectedTokens);
1024 });
1025 });
1026
1027 test.describe('"Error" Cases in Macro Lexing', () => {
1028 // this is an unopened_macro}} and will be done
1029 test('should treat unopened macros as simple plaintext', async ({ page }) => {
1030 const input = 'this is an unopened_macro}} and will be done';
1031 const tokens = await runLexerGetTokens(page, input);
1032
1033 const expectedTokens = [
1034 { type: 'Plaintext', text: 'this is an unopened_macro}} and will be done' },
1035 ];
1036
1037 expect(tokens).toEqual(expectedTokens);
1038 });
1039 // { { not a macro } }
1040 test('should treat opening/closing with whitspaces between brackets not as macros', async ({ page }) => {
1041 const input = '{ { not a macro } }';
1042 const tokens = await runLexerGetTokens(page, input);
1043
1044 const expectedTokens = [
1045 { type: 'Plaintext', text: '{ { not a macro } }' },
1046 ];
1047
1048 expect(tokens).toEqual(expectedTokens);
1049 });
1050 // invalid {{ 000 }} followed by correct {{ macro }}
1051 test('should handle valid macro correctly after an invalid macro', async ({ page }) => {
1052 const input = 'invalid {{ 000 }} followed by correct {{ macro }}';
1053 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
1054
1055 const expectedErrors = [
1056 { message: 'unexpected character: ->0<- at offset: 11, skipped 3 characters.' },
1057 ];
1058
1059 expect(errors).toMatchObject(expectedErrors);
1060
1061 const expectedTokens = [
1062 { type: 'Plaintext', text: 'invalid ' },
1063 { type: 'Macro.Start', text: '{{' },
1064 // Do not capture '000' as anything, as it's a lexer error
1065 { type: 'Macro.End', text: '}}' },
1066 { type: 'Plaintext', text: ' followed by correct ' },
1067 { type: 'Macro.Start', text: '{{' },
1068 { type: 'Macro.Identifier', text: 'macro' },
1069 { type: 'Macro.End', text: '}}' },
1070 ];
1071
1072 expect(tokens).toEqual(expectedTokens);
1073 });
1074 });
1075});
1076
1077/**
1078 * Asynchronously runs the MacroLexer on the given input and returns the tokens.
1079 *
1080 * Lexer errors will throw an Error. To test and validate lexer errors, use `runLexerGetTokensAndErrors`.
1081 *
1082 * @param {import('@playwright/test').Page} page - The Playwright page object.
1083 * @param {string} input - The input string to be tokenized.
1084 * @returns {Promise<TestableToken[]>} A promise that resolves to an array of tokens.
1085 */
1086async function runLexerGetTokens(page, input) {
1087 const { tokens, errors } = await runLexerGetTokensAndErrors(page, input);
1088
1089 // Make sure that lexer errors get correctly marked as errors during testing, even if the resulting tokens might work.
1090 // If we don't test for errors, the test should fail.
1091 if (errors.length > 0) {
1092 throw new Error('Lexer errors found\n' + errors.map(x => x.message).join('\n'));
1093 }
1094
1095 return tokens;
1096}
1097
1098
1099/**
1100 * Asynchronously runs the MacroLexer on the given input and returns the tokens and errors.
1101 *
1102 * Use `runLexerGetTokens` if you don't want to explicitly test against lexer errors.
1103 *
1104 * @param {import('@playwright/test').Page} page - The Playwright page object.
1105 * @param {string} input - The input string to be tokenized.
1106 * @returns {Promise<{tokens: TestableToken[], errors: LexerError[]}>} A promise that resolves to an object containing an array of tokens and an array of lexer errors.
1107 */
1108async function runLexerGetTokensAndErrors(page, input) {
1109 const result = await page.evaluate(async (input) => {
1110 /** @type {import('../../public/scripts/macros/engine/MacroLexer.js')} */
1111 const { MacroLexer } = await import('./scripts/macros/engine/MacroLexer.js');
1112
1113 const result = MacroLexer.tokenize(input);
1114 return result;
1115 }, input);
1116
1117 return simplifyTokens(result);
1118}
1119
1120/**
1121 * Simplify the lexer tokens result into an easily testable format.
1122 *
1123 * @param {ILexingResult} result The result from the lexer
1124 * @returns {{tokens: TestableToken[], errors: ILexingError[]}} The tokens
1125 */
1126function simplifyTokens(result) {
1127 const errors = result.errors;
1128 const tokens = result.tokens
1129 // Extract relevant properties from tokens for comparison
1130 .map(token => ({
1131 type: token.tokenType.name,
1132 text: token.image,
1133 }));
1134
1135 return { tokens, errors };
1136}
tests/frontend/MacroParser.e2e.js+678 -0
@@ -0,0 +1,678 @@
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4/** @typedef {import('chevrotain').CstNode} CstNode */
5/** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */
6
7/** @typedef {{[tokenName: string]: (string|string[]|TestableCstNode|TestableCstNode[])}} TestableCstNode */
8/** @typedef {{name: string, message: string}} TestableRecognitionException */
9
10const DEFAULT_FLATTEN_KEYS = [
11 'arguments.Args.DoubleColon',
12];
13const DEFAULT_IGNORE_KEYS = [
14
15];
16
17test.describe('MacroParser', () => {
18 // Currently this test suits runs without ST context. Enable, if ever needed
19 test.beforeEach(testSetup.goST);
20
21 test.describe('General Macro', () => {
22 // {{user}}
23 test('should parse a simple macro', async ({ page }) => {
24 const input = '{{user}}';
25 const macroCst = await runParser(page, input);
26
27 const expectedCst = {
28 'Macro.Start': '{{',
29 'Macro.identifier': 'user',
30 'Macro.End': '}}',
31 };
32
33 expect(macroCst).toEqual(expectedCst);
34 });
35 // {{ user }}
36 test('should generally handle whitespaces', async ({ page }) => {
37 const input = '{{ user }}';
38 const macroCst = await runParser(page, input);
39
40 const expectedCst = {
41 'Macro.Start': '{{',
42 'Macro.identifier': 'user',
43 'Macro.End': '}}',
44 };
45
46 expect(macroCst).toEqual(expectedCst);
47 });
48
49 test.describe('Error Cases (General Macro)', () => {
50 // {{}}
51 test('[Error] should throw an error for empty macro', async ({ page }) => {
52 const input = '{{}}';
53 const { macroCst, errors } = await runParserAndGetErrors(page, input);
54
55 const expectedErrors = [
56 { name: 'NoViableAltException' },
57 ];
58 const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '}}'/gs;
59
60 expect(macroCst).toBeUndefined();
61 expect(errors).toMatchObject(expectedErrors);
62 expect(errors[0].message).toMatch(expectedMessage);
63 });
64 // {{§!#&blah}}
65 test('[Error] should throw an error for invalid identifier', async ({ page }) => {
66 const input = '{{§!#&blah}}';
67 const { macroCst, errors } = await runParserAndGetErrors(page, input);
68
69 const expectedErrors = [
70 { name: 'NoViableAltException' },
71 ];
72 const expectedMessage = /Expecting: one of these possible Token sequences:(.*?)\[Macro\.Identifier\](.*?)but found: '!'/gs;
73
74 expect(macroCst).toBeUndefined();
75 expect(errors).toMatchObject(expectedErrors);
76 expect(errors[0].message).toMatch(expectedMessage);
77 });
78 // {{user
79 test('[Error] should throw an error for incomplete macro', async ({ page }) => {
80 const input = '{{user';
81 const { macroCst, errors } = await runParserAndGetErrors(page, input);
82
83 const expectedErrors = [
84 { name: 'MismatchedTokenException', message: 'Expecting token of type --> Macro.End <-- but found --> \'\' <--' },
85 ];
86
87 expect(macroCst).toBeUndefined();
88 expect(errors).toEqual(expectedErrors);
89 });
90
91 // something{{user}}
92 test('[Error] for testing purposes, macros need to start at the beginning of the string', async ({ page }) => {
93 const input = 'something{{user}}';
94 const { macroCst, errors } = await runParserAndGetErrors(page, input);
95
96 const expectedErrors = [
97 { name: 'MismatchedTokenException', message: 'Expecting token of type --> Macro.Start <-- but found --> \'something\' <--' },
98 ];
99
100 expect(macroCst).toBeUndefined();
101 expect(errors).toEqual(expectedErrors);
102 });
103 });
104 });
105
106 test.describe('Arguments Handling', () => {
107 // {{getvar::myvar}}
108 test('should parse macros with double-colon argument', async ({ page }) => {
109 const input = '{{getvar::myvar}}';
110 const macroCst = await runParser(page, input, {
111 flattenKeys: ['arguments.argument'],
112 });
113 expect(macroCst).toEqual({
114 'Macro.Start': '{{',
115 'Macro.identifier': 'getvar',
116 'arguments': {
117 'separator': '::',
118 'argument': 'myvar',
119 },
120 'Macro.End': '}}',
121 });
122 });
123
124 // {{roll:3d20}}
125 test('should parse macros with single colon argument', async ({ page }) => {
126 const input = '{{roll:3d20}}';
127 const macroCst = await runParser(page, input, {
128 flattenKeys: ['arguments.argument'],
129 });
130 expect(macroCst).toEqual({
131 'Macro.Start': '{{',
132 'Macro.identifier': 'roll',
133 'arguments': {
134 'separator': ':',
135 'argument': '3d20',
136 },
137 'Macro.End': '}}',
138 });
139 });
140
141 // {{setvar::myvar::value}}
142 test('should parse macros with multiple double-colon arguments', async ({ page }) => {
143 const input = '{{setvar::myvar::value}}';
144 const macroCst = await runParser(page, input, {
145 flattenKeys: ['arguments.argument'],
146 ignoreKeys: ['arguments.Args.DoubleColon'],
147 });
148 expect(macroCst).toEqual({
149 'Macro.Start': '{{',
150 'Macro.identifier': 'setvar',
151 'arguments': {
152 'separator': '::',
153 'argument': ['myvar', 'value'],
154 },
155 'Macro.End': '}}',
156 });
157 });
158
159 // {{something:: spaced }}
160 test('should strip spaces around arguments', async ({ page }) => {
161 const input = '{{something:: spaced }}';
162 const macroCst = await runParser(page, input, {
163 flattenKeys: ['arguments.argument'],
164 ignoreKeys: ['arguments.separator', 'arguments.Args.DoubleColon'],
165 });
166 expect(macroCst).toEqual({
167 'Macro.Start': '{{',
168 'Macro.identifier': 'something',
169 'arguments': { 'argument': 'spaced' },
170 'Macro.End': '}}',
171 });
172 });
173
174 // {{something::with:single:colons}}
175 test('should treat single colons as part of the argument with double-colon separator', async ({ page }) => {
176 const input = '{{something::with:single:colons}}';
177 const macroCst = await runParser(page, input, {
178 flattenKeys: ['arguments.argument'],
179 ignoreKeys: ['arguments.Args.DoubleColon'],
180 });
181 expect(macroCst).toEqual({
182 'Macro.Start': '{{',
183 'Macro.identifier': 'something',
184 'arguments': {
185 'separator': '::',
186 'argument': 'with:single:colons',
187 },
188 'Macro.End': '}}',
189 });
190 });
191
192 // {{legacy:something:else}}
193 test('should treat single colons as part of the argument even with colon separator', async ({ page }) => {
194 const input = '{{legacy:something:else}}';
195 const macroCst = await runParser(page, input, {
196 flattenKeys: ['arguments.argument'],
197 ignoreKeys: ['arguments.separator', 'arguments.Args.Colon'],
198 });
199 expect(macroCst).toEqual({
200 'Macro.Start': '{{',
201 'Macro.identifier': 'legacy',
202 'arguments': { 'argument': 'something:else' },
203 'Macro.End': '}}',
204 });
205 });
206
207 // {{something::}}
208 test('should parse double-colon with an empty argument value', async ({ page }) => {
209 const input = '{{something::}}';
210 const macroCst = await runParser(page, input, {
211 flattenKeys: ['arguments.argument'],
212 });
213
214 expect(macroCst).toEqual({
215 'Macro.Start': '{{',
216 'Macro.identifier': 'something',
217 'arguments': {
218 'separator': '::',
219 'argument': '',
220 },
221 'Macro.End': '}}',
222 });
223 });
224
225 });
226
227 test.describe('Legacy Macros', () => {
228 // {{roll 1d5}}
229 test('should parse legacy roll macro with whitespace separator', async ({ page }) => {
230 const input = '{{roll 1d5}}';
231 const macroCst = await runParser(page, input, {
232 flattenKeys: ['arguments.argument'],
233 });
234
235 expect(macroCst).toEqual({
236 'Macro.Start': '{{',
237 'Macro.identifier': 'roll',
238 'arguments': { 'argument': '1d5' },
239 'Macro.End': '}}',
240 });
241 });
242
243 // {{roll:2d20}}
244 test('should parse legacy roll macro with explicit colon separator', async ({ page }) => {
245 const input = '{{roll:2d20}}';
246 const macroCst = await runParser(page, input, {
247 flattenKeys: ['arguments.argument'],
248 });
249
250 expect(macroCst).toEqual({
251 'Macro.Start': '{{',
252 'Macro.identifier': 'roll',
253 'arguments': {
254 'separator': ':',
255 'argument': '2d20',
256 },
257 'Macro.End': '}}',
258 });
259 });
260
261 // {{roll 20}}
262 test('should parse legacy roll macro with numeric argument', async ({ page }) => {
263 const input = '{{roll 20}}';
264 const macroCst = await runParser(page, input, {
265 flattenKeys: ['arguments.argument'],
266 });
267
268 expect(macroCst).toEqual({
269 'Macro.Start': '{{',
270 'Macro.identifier': 'roll',
271 'arguments': { 'argument': '20' },
272 'Macro.End': '}}',
273 });
274 });
275
276 // {{reverse:something}}
277 test('should parse reverse legacy macro with colon argument', async ({ page }) => {
278 const input = '{{reverse:something}}';
279 const macroCst = await runParser(page, input, {
280 flattenKeys: ['arguments.argument'],
281 });
282
283 expect(macroCst).toEqual({
284 'Macro.Start': '{{',
285 'Macro.identifier': 'reverse',
286 'arguments': {
287 'separator': ':',
288 'argument': 'something',
289 },
290 'Macro.End': '}}',
291 });
292 });
293
294 // {{reverse:this contains::double::colons}}
295 test('should parse legacy single colon argument that allows double colons inside the argument', async ({ page }) => {
296 const input = '{{reverse:this contains::double::colons}}';
297 const macroCst = await runParser(page, input, {
298 flattenKeys: ['arguments.argument'],
299 });
300
301 expect(macroCst).toEqual({
302 'Macro.Start': '{{',
303 'Macro.identifier': 'reverse',
304 'arguments': {
305 'separator': ':',
306 'argument': 'this contains::double::colons',
307 },
308 'Macro.End': '}}',
309 });
310 });
311
312 // {{//comment-style macro}}
313 // TODO: Comment like // is not a valid identifier, needs to be an exception (until we maybe add flags)
314 test('should parse legacy comment macro', async ({ page }) => {
315 const input = '{{//comment-style macro}}';
316 const macroCst = await runParser(page, input, {
317 flattenKeys: ['arguments.argument'],
318 });
319
320 expect(macroCst).toEqual({
321 'Macro.Start': '{{',
322 'Macro.identifier': '//',
323 'arguments': { 'argument': 'comment-style macro' },
324 'Macro.End': '}}',
325 });
326 });
327
328 // {{datetimeformat HH:mm}}
329 test('should parse legacy datetime format macro', async ({ page }) => {
330 const input = '{{datetimeformat HH:mm}}';
331 const macroCst = await runParser(page, input, {
332 flattenKeys: ['arguments.argument'],
333 });
334
335 expect(macroCst).toEqual({
336 'Macro.Start': '{{',
337 'Macro.identifier': 'datetimeformat',
338 'arguments': { 'argument': 'HH:mm' },
339 'Macro.End': '}}',
340 });
341 });
342
343 // Note: Legacy time macros like {{time_UTC+2}} are now handled by the MacroEngine
344 // pre-processing pipeline instead of the parser. See MacroEngine.e2e tests for coverage.
345
346 // {{banned "abannedword"}}
347 test('should parse legacy banned macro with quoted argument', async ({ page }) => {
348 const input = '{{banned "abannedword"}}';
349 const macroCst = await runParser(page, input, {
350 flattenKeys: ['arguments.argument'],
351 });
352
353 expect(macroCst).toEqual({
354 'Macro.Start': '{{',
355 'Macro.identifier': 'banned',
356 'arguments': { 'argument': '"abannedword"' },
357 'Macro.End': '}}',
358 });
359 });
360
361 // {{banned ""}}
362 test('should parse legacy macro with empty quoted argument', async ({ page }) => {
363 const input = '{{banned ""}}';
364 const macroCst = await runParser(page, input, {
365 flattenKeys: ['arguments.argument'],
366 });
367
368 expect(macroCst).toEqual({
369 'Macro.Start': '{{',
370 'Macro.identifier': 'banned',
371 'arguments': { 'argument': '""' },
372 'Macro.End': '}}',
373 });
374 });
375
376 // {{setvar::myvar::}}
377 test('should allow legacy setvar with empty value argument', async ({ page }) => {
378 const input = '{{setvar::myvar::}}';
379 const macroCst = await runParser(page, input, {
380 flattenKeys: ['arguments.argument'],
381 });
382
383 expect(macroCst).toEqual({
384 'Macro.Start': '{{',
385 'Macro.identifier': 'setvar',
386 'arguments': {
387 'separator': '::',
388 'argument': ['myvar', ''],
389 },
390 'Macro.End': '}}',
391 });
392 });
393
394 });
395
396 test.describe('Comment Macros', () => {
397 // {{//comment}}
398 test('should parse comment macro without whitespace', async ({ page }) => {
399 const input = '{{//comment}}';
400 const macroCst = await runParser(page, input, {
401 flattenKeys: ['arguments.argument'],
402 });
403 expect(macroCst).toEqual({
404 'Macro.Start': '{{',
405 'Macro.identifier': '//',
406 'Macro.End': '}}',
407 'arguments': {
408 'argument': 'comment',
409 },
410 });
411 });
412
413 // {{// comment}}
414 test('should parse comment macro with whitespace', async ({ page }) => {
415 const input = '{{// comment}}';
416 const macroCst = await runParser(page, input, {
417 flattenKeys: ['arguments.argument'],
418 });
419 expect(macroCst).toEqual({
420 'Macro.Start': '{{',
421 'Macro.identifier': '//',
422 'Macro.End': '}}',
423 'arguments': {
424 'argument': 'comment',
425 },
426 });
427 });
428
429
430 // {{//!@#$%^&*()_+}}
431 test('should parse comment macro with special characters', async ({ page }) => {
432 const input = '{{//!@#$%^&*()_+}}';
433 const macroCst = await runParser(page, input, {
434 flattenKeys: ['arguments.argument'],
435 });
436 expect(macroCst).toEqual({
437 'Macro.Start': '{{',
438 'Macro.identifier': '//',
439 'Macro.End': '}}',
440 'arguments': {
441 'argument': '!@#$%^&*()_+',
442 },
443 });
444 });
445
446
447 // {{//!@flags}}
448 test('should parse comment macro starting with flags', async ({ page }) => {
449 const input = '{{//!@flags}}';
450 const macroCst = await runParser(page, input, {
451 flattenKeys: ['arguments.argument'],
452 });
453 expect(macroCst).toEqual({
454 'Macro.Start': '{{',
455 'Macro.identifier': '//',
456 'Macro.End': '}}',
457 'arguments': {
458 'argument': '!@flags',
459 },
460 });
461 });
462
463 // {{// This is a multiline comment.
464 // This is the second line
465 // }}
466 test('should parse multiline comments', async ({ page }) => {
467 const input = `{{// This is a multiline comment.
468This is the second line
469}}`;
470 const macroCst = await runParser(page, input, {
471 flattenKeys: ['arguments.argument'],
472 });
473 expect(macroCst).toEqual({
474 'Macro.Start': '{{',
475 'Macro.identifier': '//',
476 'Macro.End': '}}',
477 'arguments': {
478 'argument': 'This is a multiline comment.\nThis is the second line',
479 },
480 });
481 });
482
483
484 });
485
486 test.describe('Nested Macros', () => {
487 // {{outer::word {{inner}}}}
488 test('should parse nested macros inside arguments', async ({ page }) => {
489 const input = '{{outer::word {{inner}}}}';
490 const macroCst = await runParser(page, input, {});
491 expect(macroCst).toEqual({
492 'Macro.Start': '{{',
493 'Macro.identifier': 'outer',
494 'arguments': {
495 'argument': {
496 'Identifier': 'word',
497 'macro': {
498 'Macro.Start': '{{',
499 'Macro.identifier': 'inner',
500 'Macro.End': '}}',
501 },
502 },
503 'separator': '::',
504 },
505 'Macro.End': '}}',
506 });
507 });
508
509 // {{outer::word {{inner1}}{{inner2}}}}
510 test('should parse two nested macros next to each other inside an argument', async ({ page }) => {
511 const input = '{{outer::word {{inner1}}{{inner2}}}}';
512 const macroCst = await runParser(page, input, {});
513 expect(macroCst).toEqual({
514 'Macro.Start': '{{',
515 'Macro.identifier': 'outer',
516 'arguments': {
517 'argument': {
518 'Identifier': 'word',
519 'macro': [
520 {
521 'Macro.Start': '{{',
522 'Macro.identifier': 'inner1',
523 'Macro.End': '}}',
524 },
525 {
526 'Macro.Start': '{{',
527 'Macro.identifier': 'inner2',
528 'Macro.End': '}}',
529 },
530 ],
531 },
532 'separator': '::',
533 },
534 'Macro.End': '}}',
535 });
536 });
537
538 test.describe('Error Cases (Nested Macros)', () => {
539 // {{{{macroindentifier}}::value}}
540 test('[Error] should throw when there is a nested macro instead of an identifier', async ({ page }) => {
541 const input = '{{{{macroindentifier}}::value}}';
542 const { macroCst, errors } = await runParserAndGetErrors(page, input);
543
544 expect(macroCst).toBeUndefined();
545 expect(errors).toHaveLength(1); // error doesn't really matter. Just don't parse it pls.
546 });
547
548 // {{inside{{macro}}me}}
549 test('[Error] should throw when there is a macro inside an identifier', async ({ page }) => {
550 const input = '{{inside{{macro}}me}}';
551 const { macroCst, errors } = await runParserAndGetErrors(page, input);
552
553 expect(macroCst).toBeUndefined();
554 expect(errors).toHaveLength(1); // error doesn't really matter. Just don't parse it pls.
555 });
556
557 });
558 });
559});
560
561/**
562 * Runs the input through the MacroParser and returns the result.
563 *
564 * @param {import('@playwright/test').Page} page - The Playwright page object.
565 * @param {string} input - The input string to be parsed.
566 * @param {Object} [options={}] Optional arguments
567 * @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
568 * @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
569 * @returns {Promise<TestableCstNode>} A promise that resolves to the result of the MacroParser.
570 */
571async function runParser(page, input, options = {}) {
572 const { cst, errors } = await runParserAndGetErrors(page, input, options);
573
574 // Make sure that parser errors get correctly marked as errors during testing, even if the resulting structure might work.
575 // If we don't test for errors, the test should fail.
576 if (errors.length > 0) {
577 throw new Error('Parser errors found\n' + errors.map(x => x.message).join('\n'));
578 }
579
580 return cst;
581}
582
583/**
584 * Runs the input through the MacroParser and returns the syntax tree result and any parser errors.
585 *
586 * Use `runParser` if you don't want to explicitly test against parser errors.
587 *
588 * @param {import('@playwright/test').Page} page - The Playwright page object.
589 * @param {string} input - The input string to be parsed.
590 * @param {Object} [options={}] Optional arguments
591 * @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
592 * @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
593 * @returns {Promise<{cst: TestableCstNode, errors: TestableRecognitionException[]}>} A promise that resolves to the result of the MacroParser and error list.
594 */
595async function runParserAndGetErrors(page, input, options = {}) {
596 const params = { input, options };
597 const { result } = await page.evaluate(async ({ input, options }) => {
598 /** @type {import('../../public/scripts/macros/engine/MacroParser.js')} */
599 const { MacroParser } = await import('./scripts/macros/engine/MacroParser.js');
600 const result = MacroParser.test(input);
601 return { result };
602 }, params);
603 return { cst: simplifyCstNode(result.cst, input, options), errors: simplifyErrors(result.errors) };
604}
605
606/**
607 * Simplify the parser syntax tree result into an easily testable format.
608 *
609 * @param {CstNode} result The result from the parser
610 * @param {Object} [options={}] Optional arguments
611 * @param {string[]} [options.flattenKeys=[]] Optional array of dot-separated keys to flatten
612 * @param {string[]} [options.ignoreKeys=[]] Optional array of dot-separated keys to ignore
613 * @returns {TestableCstNode} The testable syntax tree
614 */
615function simplifyCstNode(cst, input, { flattenKeys = [], ignoreKeys = [], ignoreDefaultFlattenKeys = false, ignoreDefaultIgnoreKeys = false } = {}) {
616 if (!ignoreDefaultFlattenKeys) flattenKeys = [...flattenKeys, ...DEFAULT_FLATTEN_KEYS];
617 if (!ignoreDefaultIgnoreKeys) ignoreKeys = [...ignoreKeys, ...DEFAULT_IGNORE_KEYS];
618
619 /** @returns {TestableCstNode} @param {CstNode} node @param {string[]} path */
620 function simplifyNode(node, path = []) {
621 if (!node) return node;
622 if (Array.isArray(node)) {
623 // Single-element arrays are converted to a single string
624 if (node.length === 1) {
625 return node[0].image || simplifyNode(node[0], path.concat('[]'));
626 }
627 // For multiple elements, return an array of simplified nodes
628 return node.map(child => simplifyNode(child, path.concat('[]')));
629 }
630 if (node.children) {
631 const simplifiedChildren = {};
632 for (const key in node.children) {
633 function simplifyChildNode(childNode, path) {
634 if (Array.isArray(childNode)) {
635 // Single-element arrays are converted to a single string
636 if (childNode.length === 1) {
637 return simplifyChildNode(childNode[0], path.concat('[]'));
638 }
639 return childNode.map(child => simplifyChildNode(child, path.concat('[]')));
640 }
641
642 const flattenKey = path.filter(x => x !== '[]').join('.');
643 if (ignoreKeys.includes(flattenKey)) {
644 return null;
645 } else if (flattenKeys.includes(flattenKey)) {
646 if (!childNode.location) return null;
647 const startOffset = childNode.location.startOffset;
648 const endOffset = childNode.location.endOffset;
649 return input.slice(startOffset, endOffset + 1);
650 } else {
651 return simplifyNode(childNode, path);
652 }
653 }
654
655 const simplifiedValue = simplifyChildNode(node.children[key], path.concat(key));
656 if (simplifiedValue !== null) simplifiedChildren[key] = simplifiedValue;
657 }
658 if (Object.values(simplifiedChildren).length === 0) return null;
659 return simplifiedChildren;
660 }
661 return node.image;
662 }
663
664 return simplifyNode(cst);
665}
666
667/**
668 * Simplifies a recognition exceptions into an easily testable format.
669 *
670 * @param {IRecognitionException[]} errors - The error list containing exceptions to be simplified.
671 * @return {TestableRecognitionException[]} - The simplified error list
672 */
673function simplifyErrors(errors) {
674 return errors.map(exception => ({
675 name: exception.name,
676 message: exception.message,
677 }));
678}
tests/frontend/MacroRegistry.e2e.js+153 -0
@@ -0,0 +1,153 @@
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4test.describe('MacroRegistry', () => {
5 // Currently this test suits runs without ST context. Enable, if ever needed
6 test.beforeEach(testSetup.awaitST);
7
8 test.describe('valid', () => {
9 test('should register a macro with valid options', async ({ page }) => {
10 const result = await page.evaluate(async () => {
11 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
12 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
13
14 MacroRegistry.unregisterMacro('test-valid');
15 MacroRegistry.registerMacro('test-valid', {
16 unnamedArgs: 2,
17 list: { min: 1, max: 3 },
18 strictArgs: false,
19 description: 'Test macro for validation.',
20 handler: ({ args }) => args.join(','),
21 });
22
23 const def = MacroRegistry.getMacro('test-valid');
24 return {
25 name: def?.name,
26 minArgs: def?.minArgs,
27 maxArgs: def?.maxArgs,
28 list: def?.list,
29 strictArgs: def?.strictArgs,
30 description: def?.description,
31 };
32 });
33
34 expect(result).toEqual({
35 name: 'test-valid',
36 minArgs: 2,
37 maxArgs: 2,
38 list: { min: 1, max: 3 },
39 strictArgs: false,
40 description: 'Test macro for validation.',
41 });
42 });
43 });
44
45 test.describe('reject', () => {
46 test('should reject invalid macro name', async ({ page }) => {
47 await expect(page.evaluate(async () => {
48 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
49 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
50 // Empty name
51 MacroRegistry.registerMacro(' ', {
52 handler: () => '',
53 });
54 })).rejects.toThrow(/Macro name must be a non-empty string/);
55 });
56
57 test('should reject invalid options object', async ({ page }) => {
58 await expect(page.evaluate(async () => {
59 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
60 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
61 // Options must be object
62 // @ts-expect-error intentionally wrong
63 MacroRegistry.registerMacro('invalid-options', null);
64 })).rejects.toThrow(/options must be a non-null object/);
65 });
66
67 test('should reject invalid handler', async ({ page }) => {
68 await expect(page.evaluate(async () => {
69 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
70 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
71 // Handler must be function
72 // @ts-expect-error intentionally wrong
73 MacroRegistry.registerMacro('no-handler', { handler: null });
74 })).rejects.toThrow(/options\.handler must be a function/);
75 });
76
77 test('should reject invalid unnamedArgs', async ({ page }) => {
78 await expect(page.evaluate(async () => {
79 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
80 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
81 // unnamedArgs must be non-negative integer
82 MacroRegistry.registerMacro('bad-required', {
83 // @ts-expect-error intentionally wrong
84 unnamedArgs: -1,
85 handler: () => '',
86 });
87 })).rejects.toThrow(/options\.unnamedArgs must be a non-negative integer/);
88 });
89
90 test('should reject invalid strictArgs', async ({ page }) => {
91 await expect(page.evaluate(async () => {
92 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
93 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
94 // strictArgs must be boolean
95 MacroRegistry.registerMacro('bad-strict', {
96 // @ts-expect-error intentionally wrong
97 strictArgs: 'yes',
98 handler: () => '',
99 });
100 })).rejects.toThrow(/options\.strictArgs must be a boolean/);
101 });
102
103 test('should reject invalid list configuration', async ({ page }) => {
104 await expect(page.evaluate(async () => {
105 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
106 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
107 // list must be boolean or object
108 MacroRegistry.registerMacro('bad-list-type', {
109 // @ts-expect-error intentionally wrong
110 list: 'invalid',
111 handler: () => '',
112 });
113 })).rejects.toThrow(/options\.list must be a boolean or an object/);
114 });
115
116 test('should reject invalid list.min', async ({ page }) => {
117 await expect(page.evaluate(async () => {
118 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
119 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
120 // list.min must be non-negative
121 MacroRegistry.registerMacro('bad-list-min', {
122 list: { min: -1 },
123 handler: () => '',
124 });
125 })).rejects.toThrow(/options\.list\.min must be a non-negative integer/);
126 });
127
128 test('should reject invalid list.max', async ({ page }) => {
129 await expect(page.evaluate(async () => {
130 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
131 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
132 // list.max must be >= min
133 MacroRegistry.registerMacro('bad-list-max', {
134 list: { min: 2, max: 1 },
135 handler: () => '',
136 });
137 })).rejects.toThrow(/options\.list\.max must be greater than or equal to options\.list\.min/);
138 });
139
140 test('should reject invalid description', async ({ page }) => {
141 await expect(page.evaluate(async () => {
142 /** @type {import('../../public/scripts/macros/engine/MacroRegistry.js')} */
143 const { MacroRegistry } = await import('./scripts/macros/engine/MacroRegistry.js');
144 // description must be string
145 MacroRegistry.registerMacro('bad-desc', {
146 // @ts-expect-error intentionally wrong
147 description: 123,
148 handler: () => '',
149 });
150 })).rejects.toThrow(/options\.description must be a string/);
151 });
152 });
153});
tests/frontend/MacrosParser.e2e.js+50 -0
@@ -0,0 +1,50 @@
1import { test, expect } from '@playwright/test';
2import { testSetup } from './frontent-test-utils.js';
3
4// Tests for the deprecated MacrosParser shim to ensure it continues to work
5// both with the legacy regex macro system (feature flag disabled) and with
6// the new macro engine (feature flag enabled).
7
8test.describe('MacrosParser (legacy shim)', () => {
9 test.beforeEach(testSetup.awaitST);
10
11 test('should resolve macros via legacy evaluateMacros when experimental engine is disabled', async ({ page }) => {
12 const output = await page.evaluate(async () => {
13 const { MacrosParser, evaluateMacros } = await import('./scripts/macros.js');
14 const { power_user } = await import('./scripts/power-user.js');
15
16 power_user.experimental_macro_engine = false;
17
18 MacrosParser.registerMacro('legacyParserTest', 'LEGACY_OK', 'Legacy parser test');
19
20 const env = {};
21 const result = evaluateMacros('Value: {{legacyParserTest}}.', env, (x) => x);
22
23 MacrosParser.unregisterMacro('legacyParserTest');
24
25 return result;
26 });
27
28 expect(output).toBe('Value: LEGACY_OK.');
29 });
30
31 test('should resolve macros via new engine when experimental engine is enabled', async ({ page }) => {
32 const output = await page.evaluate(async () => {
33 const { MacrosParser } = await import('./scripts/macros.js');
34 const { substituteParams } = await import('./script.js');
35 const { power_user } = await import('./scripts/power-user.js');
36
37 power_user.experimental_macro_engine = true;
38
39 MacrosParser.registerMacro('engineParserTest', 'ENGINE_OK', 'Engine parser test');
40
41 const result = substituteParams('Value: {{engineParserTest}}.', {});
42
43 MacrosParser.unregisterMacro('engineParserTest');
44
45 return result;
46 });
47
48 expect(output).toBe('Value: ENGINE_OK.');
49 });
50});