Compare commits

..
6 Commits
11 changed files with 349 additions and 57 deletions
+2
View File
@@ -41,6 +41,7 @@ When you type in a text input area, tags that partially match the text are displ
- Tags are color-coded by category. The color-coding rules are the same as Danbooru.
- Tags that have already been entered are displayed grayed out.
- You can display Danbooru and e621 tags at the same time. You can also change the priority from the settings.
- Supports autocomplete for Lora and Embedding inputs. You can enable/disable this feature in the settings.
## Related Tags
@@ -132,6 +133,7 @@ When the browser is reloaded, you can check the list of loaded CSV files in the
- **Enable Autocomplete**: Enable/disable the autocomplete feature.
- **Max suggestions**: Maximum number of autocomplete suggestions to display.
- **Enable Loras and Embeddings**: Display Lora and Embedding in the suggestions.
- **Use Fast Search**: Switch autocomplete suggestions search to fast processing (see [About Fast Search for Autocomplete](#about-fast-search-for-autocomplete) for details).
### Related Tags
+2
View File
@@ -39,6 +39,7 @@
- タグのカテゴリ毎に色分けされます。色分けのルールは Danbooru と同じです
- 入力済みのタグはグレーアウトで表示されます
- Danbooruとe621のタグを同時に表示出来ます。設定から優先順位を変更できます
- LoraとEmbeddingの入力補完に対応しています。設定から有効・無効を切り替えられます
## 関連タグ
@@ -130,6 +131,7 @@ worst_quality,5,9999999,
- **Enable Autocomplete**: オートコンプリート機能の有効化/無効化
- **Max Suggestions**: オートコンプリート候補の最大表示件数
- **Enable Loras and Embeddings**: LoraとEmbeddingを候補に表示する
- **Use Fast Search**: オートコンプリート候補の検索を高速な処理に切り替える(詳細は [オートコンプリートの高速検索について](#オートコンプリートの高速検索について) を確認してください)
### 関連タグ
+22 -1
View File
@@ -1,7 +1,10 @@
import os
import json
import os
import folder_paths
import server
from aiohttp import web
from . import downloader as dl
# Get the absolute path to the 'data' directory
@@ -227,3 +230,21 @@ async def get_last_check_time(_request):
except (IOError, json.JSONDecodeError) as e:
print(f"[Autocomplete-Plus] Error reading csv_meta.json: {e}")
return web.json_response({"last_check_time": None, "error": str(e)}, status=500)
@server.PromptServer.instance.routes.get("/autocomplete-plus/embeddings")
async def get_embeddings(request):
"""
Returns a list of embedding files.
"""
embeddings = folder_paths.get_filename_list("embeddings")
return web.json_response(list(map(lambda a: os.path.splitext(a)[0], embeddings)))
@server.PromptServer.instance.routes.get("/autocomplete-plus/loras")
async def get_loras(request):
"""
Returns a list of lora files.
"""
loras = folder_paths.get_filename_list("loras")
return web.json_response(list(map(lambda a: os.path.splitext(a)[0], loras)))
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-autocomplete-plus"
description = "Autocomplete and Related Tag display for ComfyUI"
version = "1.3.1"
version = "1.4.0"
license = {file = "LICENSE"}
dependencies = ["",]
+81 -18
View File
@@ -1,10 +1,11 @@
import {
import {
createFlexSearchDocument,
createFlexSearchDocumentForModel,
__test__
} from "../../web/js/searchengine.js";
} from "../../web/js/searchengine.js";
const { createTagEncoder, createCJKEncoder } = __test__;
const { createTagEncoder, createCJKEncoder, createModelEncoder } = __test__;
function parseCSVLine(line) {
const result = [];
@@ -60,34 +61,44 @@ sanshoku_dango,0,2061,"三色団子,三色团子,花見団子,花见团子"
year:1999,0,1999,
d.d.,0,1999,
copyright_(series),2,1298,"copyright,コピーライト (シリーズ),コピーライト名,コピーライト,著作"
__wildcard__,0,0,
`;
const ControlCSV = `
__wildcard__,0,1000,
<lora:my_lora1:1.0>,0,1000,
Embedding: my_embedding,0,1000,
const ModelCSV = `
<lora:my_lora1>,0,0,
<lora:日本語Lora_v1>,0,0,
embedding: my_embedding,0,0,
`;
const mockCSV = [
commonCSV, cjkAliasCSV, specialCharCSV, ControlCSV
commonCSV, cjkAliasCSV, specialCharCSV
].map(csv => csv.trim()).join('\n');
let mockTags;
let tagEncoder, cjkEncoder;
let document;
let mockTags, mockModelTags;
let tagEncoder, cjkEncoder, modelEncoder;
let document, modelDocument;
let performSearch = function (query, limit = 100) {
const results = document.search(query, {
const ids1 = document.search(query, {
field: ["tag", "alias"],
limit: limit,
limit: limit,
suggest: false,
merge: true,
});
}).map(r => r.id);
const ids = results.map(r => r.id);
const result1 = mockTags.filter(tag => ids1.includes(tag.id)).map(tag => tag.tag);
return mockTags.filter(tag => ids.includes(tag.id)).map(tag => tag.tag);
const ids2 = modelDocument.search(query, {
field: ["tag", "alias"],
limit: limit,
suggest: false,
merge: true,
}).map(r => r.id);
const result2 = mockModelTags.filter(tag => ids2.includes(tag.id)).map(tag => tag.tag);
return [...result1, ...result2];
}
beforeEach(() => {
@@ -102,6 +113,15 @@ Embedding: my_embedding,0,1000,
document = createFlexSearchDocument();
mockTags.forEach(data => document.add(data));
mockModelTags = ModelCSV.split('\n').map((line, id) => {
const [tag, category, count, alias] = parseCSVLine(line);
return { id, tag, category: parseInt(category), count: parseInt(count), alias };
});
modelEncoder = createModelEncoder();
modelDocument = createFlexSearchDocumentForModel();
mockModelTags.forEach(data => modelDocument.add(data));
});
describe('Encoder', () => {
@@ -134,6 +154,31 @@ Embedding: my_embedding,0,1000,
const encoded = tagEncoder.encode('one_two_');
expect(encoded).toEqual(['one', 'two']);
});
test('should properly encode embedding notation', () => {
let encoded = modelEncoder.encode('embedding:path/to/my_embed1');
expect(encoded).toEqual(['embedding:', 'path', 'to', 'my', 'embed1']);
expect(
modelEncoder.encode('embedding:path\\to\\my-embed1')
).toEqual(['embedding:', 'path', 'to', 'my', 'embed1']);
expect(
modelEncoder.encode('embedding:path\\to\\this is my embed. my-negative01 (v1)__by me')
).toEqual(['embedding:', 'path', 'to', 'this', 'is', 'my', 'embed', 'my', 'negative01', 'v1', 'by', 'me']);
});
test('should properly encode lora notation', () => {
expect(
modelEncoder.encode('<lora:path/to/my_lora1>')
).toEqual(['lora:', 'path', 'to', 'my', 'lora1']);
expect(
modelEncoder.encode('<lora:path\\to\\my-lora1>')
).toEqual(['lora:', 'path', 'to', 'my', 'lora1']);
expect(
modelEncoder.encode('<lora:path\\to\\this is my lora. my-style01 (v1)__by me>')
).toEqual(['lora:', 'path', 'to', 'this', 'is', 'my', 'lora', 'my', 'style01', 'v1', 'by', 'me']);
});
});
describe('Basic Search', () => {
@@ -298,9 +343,27 @@ Embedding: my_embedding,0,1000,
test('should match to lora tag', () => {
const tag = '<lora';
const results = performSearch(tag);
expect(results.length).toEqual(2);
expect(results).toContain("<lora:my_lora1>");
expect(results).toContain("<lora:日本語Lora_v1>");
});
test('should match to lora tag2', () => {
const tag = 'lora:';
const results = performSearch(tag);
expect(results.length).toEqual(2);
expect(results).toContain("<lora:my_lora1>");
expect(results).toContain("<lora:日本語Lora_v1>");
});
test('should match to lora that contain CJK characters', () => {
const word = 'lora: 日本語';
const results = performSearch(word);
expect(results.length).toEqual(1);
expect(results).toContain("<lora:my_lora1:1.0>");
expect(results).toContain("<lora:日本語Lora_v1>");
});
});
+24 -14
View File
@@ -1,4 +1,5 @@
import {
ModelTagSource,
TagCategory,
TagData,
autoCompleteData,
@@ -9,6 +10,7 @@ import {
hiraToKata,
kataToHira,
formatCountHumanReadable,
escapeHtml,
isContainsLetterOrNumber,
normalizeTagToInsert,
normalizeTagToSearch,
@@ -89,7 +91,7 @@ function searchCompletionCandidates(textareaElement) {
}
// Generate Hiragana/Katakana variations if applicable
const queryVariations = new Set([partialTag, normalizeTagToSearch(partialTag)]);
const queryVariations = new Set([partialTag.toLowerCase(), normalizeTagToSearch(partialTag).toLowerCase()]);
const kataQuery = hiraToKata(partialTag);
if (kataQuery !== partialTag) {
queryVariations.add(kataQuery);
@@ -128,15 +130,14 @@ function sequentialSearch(partialTag, queryVariations) {
let matchedAlias = null;
// Check primary tag against all variations for exact/partial match
const tagMatch = matchWord(tagData.tag, queryVariations);
const tagMatch = matchWord(tagData.tag.toLowerCase(), queryVariations);
matched = tagMatch.matched;
isExactMatch = tagMatch.isExactMatch;
// If primary tag didn't match, check aliases against all variations
if (!matched && tagData.alias && Array.isArray(tagData.alias) && tagData.alias.length > 0) {
for (const alias of tagData.alias) {
const lowerAlias = alias.toLowerCase();
const aliasMatch = matchWord(lowerAlias, queryVariations);
const aliasMatch = matchWord(alias.toLowerCase(), queryVariations);
if (aliasMatch.matched) {
matched = true;
isExactMatch = aliasMatch.isExactMatch;
@@ -299,9 +300,9 @@ function getCurrentPartialTag(inputElement) {
* Inserts the selected tag into the textarea, replacing the partial tag,
* making the change undoable.
* @param {HTMLTextAreaElement} inputElement
* @param {string} tagToInsert The raw tag string to insert.
* @param {TagData} tagDataToInsert The raw tag string to insert.
*/
function insertTagToTextArea(inputElement, tagToInsert) {
function insertTagToTextArea(inputElement, tagDataToInsert) {
const text = inputElement.value;
const cursorPos = inputElement.selectionStart;
@@ -309,7 +310,13 @@ function insertTagToTextArea(inputElement, tagToInsert) {
const replaceStart = Math.min(cursorPos, tagStart);
let replaceEnd = cursorPos;
const normalizedTag = normalizeTagToInsert(tagToInsert);
let normalizedTag;
if(Object.values(ModelTagSource).includes(tagDataToInsert.source)){
// If the tag is from a model tag source, don't want to normalize it
normalizedTag = tagDataToInsert.tag;
}else{
normalizedTag = normalizeTagToInsert(tagDataToInsert.tag);
}
const currentTagAfterCursor = text.substring(cursorPos, tagEnd).trimEnd();
if (normalizedTag.lastIndexOf(currentTagAfterCursor) !== -1) {
@@ -378,7 +385,7 @@ class AutocompleteUI {
this.tagsList.addEventListener('mousedown', (e) => {
const row = e.target.closest('.autocomplete-plus-item');
if (row && row.dataset.tag) {
this.#insertTag(row.dataset.tag);
this.#insertTag(row.dataset);
e.preventDefault(); // Prevent focus loss from input
e.stopPropagation();
}
@@ -443,10 +450,12 @@ class AutocompleteUI {
this.#highlightItem();
}
/** Selects the currently highlighted item */
/** Selects the currently highlighted item
* @returns {TagData|null} The selected tag data.
*/
getSelectedTag() {
if (this.selectedIndex >= 0 && this.selectedIndex < this.candidates.length) {
return this.candidates[this.selectedIndex].tag;
return this.candidates[this.selectedIndex];
}
return null; // No valid selection
@@ -492,9 +501,10 @@ class AutocompleteUI {
if (settingValues.tagSourceIconPosition == 'hidden') {
tagName.textContent = tagData.tag;
} else {
const escapedTag = escapeHtml(tagData.tag);
tagName.innerHTML = settingValues.tagSourceIconPosition == 'left'
? `${tagSourceIconHtml} ${tagData.tag}`
: `${tagData.tag} ${tagSourceIconHtml}`;
? `${tagSourceIconHtml} ${escapedTag}`
: `${escapedTag} ${tagSourceIconHtml}`;
}
// grayout tag name if it already exists
@@ -635,10 +645,10 @@ class AutocompleteUI {
/**
* Handles the selection of an item
* @param {string} selectedTag The tag to insert.
* @param {TagData} selectedTag The tag to insert.
*/
#insertTag(selectedTag) {
if (!this.target || !selectedTag || selectedTag.length <= 0) {
if (!this.target || !selectedTag) {
this.hide();
return;
}
+129 -14
View File
@@ -1,14 +1,20 @@
import { settingValues, updateMaxTagLength } from "./settings.js";
import { createFlexSearchDocument } from "./searchengine.js";
import { createFlexSearchDocument, createFlexSearchDocumentForModel } from "./searchengine.js";
// --- Constants ---
// Tag data sources
// Tag sources for booru-like tag data.
export const TagSource = {
Danbooru: 'danbooru',
E621: 'e621',
}
// Tag sources for model based tag data.
export const ModelTagSource = {
Embeddings: 'embeddings',
Lora: 'lora'
}
export const TagCategory = {
'danbooru': [
'general',
@@ -28,6 +34,12 @@ export const TagCategory = {
'invalid',
'meta',
'lore',
],
'embeddings': [
'embeddings'
],
'lora': [
'lora'
]
}
@@ -40,19 +52,19 @@ export class TagData {
/**
* Create a tag data object
* @param {string} tag - The tag name
* @param {string[]} [alias=[]] - Array of aliases for the tag
* @param {string} [category='general'] - Category of the tag
* @param {number} [category] - Category index of the tag
* @param {number} [count=0] - Frequency count/popularity of the tag
* @param {string} [source=TagSources.Danbooru] - The source of the tag data
* @param {string[]} [alias=[]] - Array of aliases for the tag
* @param {string} [source=TagSource.Danbooru] - The source of the tag data
*/
constructor(tag, alias = [], category = 'general', count = 0, source = TagSource.Danbooru) {
constructor(tag, category, count = 0, alias = [], source = TagSource.Danbooru) {
/** @type {string} */
this.tag = tag;
/** @type {string[]} */
this.alias = alias;
/** @type {string} */
/** @type {number} */
this.category = category;
/** @type {number} */
@@ -98,24 +110,32 @@ export const autoCompleteData = {};
const TAGS_CSV_HEADER = 'tag,category,count,alias';
const TAGS_CSV_HEADER_COLUMNS = TAGS_CSV_HEADER.split(',');
const TAG_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('tag');
const ALIAS_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('alias');
const CATEGORY_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('category');
const COUNT_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('count');
const ALIAS_INDEX = TAGS_CSV_HEADER_COLUMNS.indexOf('alias');
// --- Helder Functions ---
/**
* Get the available tag sources in priority order based on the current settings.
* @returns {string[]} Array of available tag sources in priority order
*/
export function getEnabledTagSourceInPriorityOrder() {
return Object.values(TagSource)
let enabledTagSources = Object.values(TagSource)
.filter((s) => {
return settingValues.tagSource === s || settingValues.tagSource === 'all';
})
.toSorted((a, b) => {
return a === settingValues.primaryTagSource ? -1 : 1;
});
// Append Loras and Embeddings if enabled
if (settingValues.enableModels) {
enabledTagSources = [...enabledTagSources, ...Object.values(ModelTagSource)];
}
return enabledTagSources;
}
// --- Data Loading Functions ---
@@ -159,7 +179,7 @@ async function loadTags(csvUrl, siteName) {
const aliases = aliasStr ? aliasStr.split(',').map(a => a.trim()).filter(a => a.length > 0) : [];
// Create a TagData instance instead of a plain object
const tagData = new TagData(tag, aliases, category, count, siteName);
const tagData = new TagData(tag, category, count, aliases, siteName);
updateMaxTagLength(tag.length);
@@ -202,7 +222,14 @@ async function buildFlexSearchIndex(siteName) {
return;
}
const document = createFlexSearchDocument();
let document = null;
if (Object.values(TagSource).includes(siteName)) {
document = createFlexSearchDocument();
} else if (Object.values(ModelTagSource).includes(siteName)) {
document = createFlexSearchDocumentForModel();
} else {
throw new Error(`[Autocomplete-Plus] Invalid site name: ${siteName}`);
}
let startIdx = 0;
const startTime = performance.now();
@@ -341,7 +368,11 @@ function parseCSVLine(line) {
return result;
}
export async function fetchCsvList() {
/**
* Fetch the list of CSV files from the API endpoint
* @returns {Promise<void>}
*/
async function fetchCsvList() {
try {
const response = await fetch('/autocomplete-plus/csv');
if (!response.ok) {
@@ -357,9 +388,8 @@ export async function fetchCsvList() {
/**
* Initializes the autocomplete data by fetching the list of CSV files and loading them.
* This function is called when the extension is initialized.
*/
export async function initializeData(csvListData, source) {
async function initializeDataFromCSV(csvListData, source) {
if (autoCompleteData.hasOwnProperty(source) === false) {
autoCompleteData[source] = new AutocompleteData();
}
@@ -444,3 +474,88 @@ export async function initializeData(csvListData, source) {
autoCompleteData[source].isInitializing = false;
}
}
/**
* Load Embeddings data from the API endpoint
* @returns {Promise<void>}
*/
async function loadEmbeddings() {
try {
const response = await fetch('/autocomplete-plus/embeddings', { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const embeddings = await response.json();
const source = ModelTagSource.Embeddings;
if (autoCompleteData.hasOwnProperty(source) === false) {
autoCompleteData[source] = new AutocompleteData();
}
embeddings.forEach(embedding => {
if (!autoCompleteData[source].tagMap.has(embedding)) {
const tagData = new TagData(`embedding:${embedding}`, 0, 0, [], source);
autoCompleteData[source].sortedTags.push(tagData);
autoCompleteData[source].tagMap.set(embedding, tagData);
updateMaxTagLength(embedding.length);
}
});
await buildFlexSearchIndex(ModelTagSource.Embeddings);
console.log(`[Autocomplete-Plus] Loaded ${embeddings.length} Embeddings`);
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch Embeddings data:`, error);
}
}
/**
* Load LoRA data from the API endpoint
* @returns {Promise<void>}
*/
async function loadLoras() {
try {
const response = await fetch('/autocomplete-plus/loras', { cache: "no-store" });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const loraNames = await response.json();
const source = ModelTagSource.Lora;
if (autoCompleteData.hasOwnProperty(source) === false) {
autoCompleteData[source] = new AutocompleteData();
}
loraNames.forEach(loraName => {
if (!autoCompleteData[source].tagMap.has(loraName)) {
const tagData = new TagData(`<lora:${loraName}>`, 0, 0, [], source);
autoCompleteData[source].sortedTags.push(tagData);
autoCompleteData[source].tagMap.set(loraName, tagData);
updateMaxTagLength(loraName.length);
}
});
await buildFlexSearchIndex(ModelTagSource.Lora);
console.log(`[Autocomplete-Plus] Loaded ${loraNames.length} LoRA models`);
} catch (error) {
console.error(`[Autocomplete-Plus] Failed to fetch LoRA data:`, error);
}
}
/**
* Load all data sources asynchronously.
*/
export async function loadDataAsync() {
return Promise.all([
fetchCsvList().then((csvList) => {
Object.values(TagSource).forEach((source) => {
initializeDataFromCSV(csvList, source);
});
}),
loadEmbeddings(),
loadLoras(),
]);
}
+13 -6
View File
@@ -3,7 +3,7 @@ import { $el } from "/scripts/ui.js";
import { ComfyWidgets } from "/scripts/widgets.js";
import { settingValues } from "./settings.js";
import { loadCSS } from "./utils.js";
import { TagSource, fetchCsvList, initializeData } from "./data.js";
import { TagSource, loadDataAsync } from "./data.js";
import { AutocompleteEventHandler } from "./autocomplete.js";
import { RelatedTagsEventHandler } from "./related-tags.js";
@@ -240,11 +240,7 @@ app.registerExtension({
let rootPath = import.meta.url.replace("js/main.js", "");
loadCSS(rootPath + "css/autocomplete-plus.css"); // Load CSS for autocomplete
fetchCsvList().then((csvList) => {
Object.values(TagSource).forEach((source) => {
initializeData(csvList, source);
});
});
await loadDataAsync();
},
// One the Settings Screen, displays reverse order in same category
@@ -297,6 +293,17 @@ app.registerExtension({
settingValues.useFastSearch = newVal;
}
},
{
id: id + ".enable_models",
name: "Enable Loras and Embeddings",
tooltip: "Enable Lora and Embedding suggestions",
type: "boolean",
defaultValue: true,
category: [name, "Autocompletion", "Enable Loras and Embeddings"],
onChange: (newVal, oldVal) => {
settingValues.enableModels = newVal;
}
},
{
id: id + ".max_suggestions",
name: "Max suggestions",
+50 -3
View File
@@ -35,6 +35,23 @@ function createCJKEncoder() {
});
}
/**
* Creates an encoder optimized for processing Embedding or Lora notation.
* @returns {Encoder} FlexSearch encoder
*/
function createModelEncoder() {
return new Encoder({
normalize: true,
dedupe: false,
numeric: true,
cache: true,
prepare: function (str) {
return str.replace(/^<|>$/g, '').split(/(lora:|embedding:|[^\u0000-\u007f]+)/g).filter(Boolean).join(" ").trim();
},
split: /(?<=lora:.*|embedding:.*)[_./\(\)\-\s\\]+/
});
}
/**
* Creates a FlexSearch Document instance optimized for tag searching.
* Configures separate encoders for English tags and CJK aliases with appropriate tokenization.
@@ -45,8 +62,8 @@ export function createFlexSearchDocument() {
const cjkEncoder = createCJKEncoder();
// Custom encoding function for alias field that handles mixed language content
const encodeAlias = function (term) {
return term.split(",")
const encodeAlias = function (word) {
return word.split(",")
.flatMap(str => {
if (/[^\u0000-\u007f]/.test(str)) {
// Contains non-ASCII characters (CJK text)
@@ -81,6 +98,36 @@ export function createFlexSearchDocument() {
return document;
}
/**
* Creates a FlexSearch Document instance optimized for lora or embedding searching.
* @returns {Document} Configured FlexSearch document
*/
export function createFlexSearchDocumentForModel() {
const modelEncoder = createModelEncoder();
// Configure the FlexSearch document with optimized indexing settings
// Note: alias field is not indexed for lora or embedding search
const document = new Document({
tokenize: "full", // Allow partial matching from both ends
encoder: modelEncoder,
document: {
id: "id",
index: [
{
field: "tag",
},
{
field: "alias",
}
]
}
});
return document;
}
// Export functions for testing when in test environment
const isTestEnvironment = typeof process !== 'undefined' && process.env.NODE_ENV === 'test';
export const __test__ = isTestEnvironment ? { createTagEncoder, createCJKEncoder } : undefined;
export const __test__ = isTestEnvironment ? { createTagEncoder, createCJKEncoder, createModelEncoder } : undefined;
+1
View File
@@ -7,6 +7,7 @@ export const settingValues = {
// Autocomplete feature settings
enabled: true,
maxSuggestions: 10,
enableModels: true, // Enable Lora and Embedding suggestions
useFastSearch: false,
// Related tags feature settings
+24
View File
@@ -109,6 +109,30 @@ export function formatCountHumanReadable(num) {
return (num / si[i].value).toFixed(1).replace(rx, "$1") + si[i].symbol;
}
/**
* Escapes HTML special characters in a string.
* @param {string} str The input string.
* @returns {string} The escaped string.
*/
export function escapeHtml(str) {
if (typeof str !== 'string') {
return str;
}
const escapeMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'`': '&#x60;',
'/': '&#x2F;'
};
return str.replace(/[&<>"'`/]/g, match =>
escapeMap[match]);
}
/**
* Escapes parentheses in a string for use in prompts.
* Replaces '(' with '\(' and ')' with '\)'.