diff --git a/aiserver.py b/aiserver.py index de013688..1bfbda85 100644 --- a/aiserver.py +++ b/aiserver.py @@ -2163,6 +2163,9 @@ def patch_transformers(): scores: torch.FloatTensor, **kwargs, ) -> bool: + if not koboldai_vars.inference_config.do_core: + return False + koboldai_vars.generated_tkns += 1 if ( @@ -5006,7 +5009,7 @@ def calcsubmit(txt): # Send it! ikrequest(subtxt) -def core_generate(text: list, min: int, max: int, found_entries: set): +def core_generate(text: list, min: int, max: int, found_entries: set, is_core: bool = False): # This generation function is tangled with koboldai_vars intentionally. It # is meant for the story and nothing else. @@ -5065,6 +5068,7 @@ def core_generate(text: list, min: int, max: int, found_entries: set): batch_count=numseqs, # Real max length is handled by CoreStopper. bypass_hf_maxlength=True, + is_core=True, ) genout = result.encoded @@ -5192,9 +5196,11 @@ def raw_generate( do_dynamic_wi: bool = False, batch_count: int = 1, bypass_hf_maxlength: bool = False, - generation_settings: Optional[dict] = None + generation_settings: Optional[dict] = None, + is_core: bool = False ) -> GenerationResult: + koboldai_vars.inference_config.do_core = is_core gen_settings = GenerationSettings(*(generation_settings or {})) model_functions = { @@ -6140,6 +6146,18 @@ def applyoutputformatting(txt): if(koboldai_vars.singleline or koboldai_vars.chatmode): txt = utils.singlelineprocessing(txt, koboldai_vars) + for sub in koboldai_vars.substitutions: + if not sub["enabled"]: + continue + i = 0 + while sub["trueTarget"] in txt or sub["target"] in txt: + i += 1 + if i > 1000: + logger.error("[substitutions] Infinite recursion :^(") + break + txt = txt.replace(sub["trueTarget"], sub["substitution"]) + txt = txt.replace(sub["target"], sub["substitution"]) + return txt #==================================================================# @@ -8501,6 +8519,11 @@ def UI_2_phrase_bias_update(biases): koboldai_vars.biases = biases +@socketio.on("substitution_update") +@logger.catch +def UI_2_substitutions_update(substitutions): + koboldai_vars.substitutions = substitutions + #==================================================================# # Event triggered to rely a message diff --git a/koboldai_settings.py b/koboldai_settings.py index 273c87f8..31e8241a 100644 --- a/koboldai_settings.py +++ b/koboldai_settings.py @@ -1,5 +1,6 @@ from dataclasses import dataclass import os, re, time, threading, json, pickle, base64, copy, tqdm, datetime, sys +from typing import Union from io import BytesIO from flask import has_request_context, session from flask_socketio import SocketIO, join_room, leave_room @@ -180,6 +181,18 @@ class koboldai_vars(object): def reset_model(self): self._model_settings.reset_for_model_load() + def get_token_representation(self, text: Union[str, list, None]) -> list: + if not self.tokenizer or not text: + return [] + + if isinstance(text, str): + encoded = self.tokenizer.encode(text) + else: + encoded = text + + # TODO: This might be ineffecient, should we cache some of this? + return [[token, self.tokenizer.decode(token)] for token in encoded] + def calc_ai_text(self, submitted_text="", return_text=False): #start_time = time.time() if self.alt_gen: @@ -198,7 +211,7 @@ class koboldai_vars(object): # TODO: We may want to replace the "text" variable with a list-type # class of context blocks, the class having a __str__ function. if self.sp_length > 0: - context.append({"type": "soft_prompt", "text": f"<{self.sp_length} tokens of Soft Prompt.>", "tokens": self.sp_length}) + context.append({"type": "soft_prompt", "text": f"<{self.sp_length} tokens of Soft Prompt.>", "tokens": [-1] * self.sp_length}) # Header is never used? # if koboldai_vars.model not in ("Colab", "API", "OAI") and self.tokenizer._koboldai_header: # context.append({"type": "header", "text": f"{len(self.tokenizer._koboldai_header}) @@ -208,11 +221,16 @@ class koboldai_vars(object): #Add memory memory_length = self.max_memory_length if self.memory_length > self.max_memory_length else self.memory_length memory_text = self.memory + memory_encoded = None if memory_length+used_tokens <= token_budget: - if self.tokenizer is not None and self.memory_length > self.max_memory_length: - memory_text = self.tokenizer.decode(self.tokenizer.encode(self.memory)[-self.max_memory_length-1:]) + if self.tokenizer is not None and self.memory_length > self.max_memory_length: + memory_encoded = self.tokenizer.encode(self.memory)[-self.max_memory_length-1:] + memory_text = self.tokenizer.decode(memory_encoded) + + if not memory_encoded and self.tokenizer: + memory_encoded = self.tokenizer.encode(memory_text) - context.append({"type": "memory", "text": memory_text, "tokens": memory_length}) + context.append({"type": "memory", "text": memory_text, "tokens": self.get_token_representation(memory_encoded)}) text += memory_text #Add constant world info entries to memory @@ -223,7 +241,11 @@ class koboldai_vars(object): used_world_info.append(wi['uid']) self.worldinfo_v2.set_world_info_used(wi['uid']) wi_text = wi['content'] - context.append({"type": "world_info", "text": wi_text, "tokens": wi['token_length']}) + context.append({ + "type": "world_info", + "text": wi_text, + "tokens": self.get_token_representation(wi_text), + }) text += wi_text @@ -268,7 +290,7 @@ class koboldai_vars(object): used_tokens+=0 if wi['token_length'] is None else wi['token_length'] used_world_info.append(wi['uid']) wi_text = wi['content'] - context.append({"type": "world_info", "text": wi_text, "tokens": wi['token_length']}) + context.append({"type": "world_info", "text": wi_text, "tokens": self.get_token_representation(wi_text)}) text += wi_text self.worldinfo_v2.set_world_info_used(wi['uid']) @@ -288,31 +310,50 @@ class koboldai_vars(object): game_context = [] authors_note_final = self.authornotetemplate.replace("<|>", self.authornote) used_all_tokens = False + for action in range(len(self.actions)): self.actions.set_action_in_ai(action, used=False) + for i in range(len(action_text_split)-1, -1, -1): if action_text_split[i][3] or action_text_split[i][1] == [-1]: #We've hit an item we've already included or items that are only prompt. Stop for action in action_text_split[i][1]: if action >= 0: self.actions.set_action_in_ai(action) - break; + break + if len(action_text_split) - i - 1 == self.andepth and self.authornote != "": game_text = "{}{}".format(authors_note_final, game_text) - game_context.insert(0, {"type": "authors_note", "text": authors_note_final, "tokens": self.authornote_length}) - length = 0 if self.tokenizer is None else len(self.tokenizer.encode(action_text_split[i][0])) + game_context.insert(0, {"type": "authors_note", "text": authors_note_final, "tokens": self.get_token_representation(authors_note_final)}) + + encoded_action = [] if not self.tokenizer else self.tokenizer.encode(action_text_split[i][0]) + length = len(encoded_action) + if length+used_tokens <= token_budget and not used_all_tokens: used_tokens += length selected_text = action_text_split[i][0] action_text_split[i][3] = True game_text = "{}{}".format(selected_text, game_text) + if action_text_split[i][1] == [self.actions.action_count+1]: - game_context.insert(0, {"type": "submit", "text": selected_text, "tokens": length, "action_ids": action_text_split[i][1]}) + game_context.insert(0, { + "type": "submit", + "text": selected_text, + "tokens": self.get_token_representation(encoded_action), + "action_ids": action_text_split[i][1] + }) else: - game_context.insert(0, {"type": "action", "text": selected_text, "tokens": length, "action_ids": action_text_split[i][1]}) + game_context.insert(0, { + "type": "action", + "text": selected_text, + "tokens": self.get_token_representation(encoded_action), + "action_ids": action_text_split[i][1] + }) + for action in action_text_split[i][1]: if action >= 0: self.actions.set_action_in_ai(action) + #Now we need to check for used world info entries for wi in self.worldinfo_v2: if wi['uid'] not in used_world_info: @@ -336,12 +377,13 @@ class koboldai_vars(object): used_tokens+=0 if wi['token_length'] is None else wi['token_length'] used_world_info.append(wi['uid']) wi_text = wi["content"] + encoded_wi = self.tokenizer.encode(wi_text) if method == 1: text = "{}{}".format(wi_text, game_text) - context.insert(0, {"type": "world_info", "text": wi_text, "tokens": wi['token_length']}) + context.insert(0, {"type": "world_info", "text": wi_text, "tokens": self.get_token_representation(encoded_wi)}) else: game_text = "{}{}".format(wi_text, game_text) - game_context.insert(0, {"type": "world_info", "text": wi_text, "tokens": wi['token_length']}) + game_context.insert(0, {"type": "world_info", "text": wi_text, "tokens": self.get_token_representation(encoded_wi)}) self.worldinfo_v2.set_world_info_used(wi['uid']) else: used_all_tokens = True @@ -350,11 +392,11 @@ class koboldai_vars(object): #if we don't have enough actions to get to author's note depth then we just add it right before the game text if len(action_text_split) < self.andepth and self.authornote != "": game_text = "{}{}".format(authors_note_final, game_text) - game_context.insert(0, {"type": "authors_note", "text": authors_note_final, "tokens": authornote_length}) + game_context.insert(0, {"type": "authors_note", "text": authors_note_final, "tokens": self.get_token_representation(authors_note_final)}) if self.useprompt: text += prompt_text - context.append({"type": "prompt", "text": prompt_text, "tokens": prompt_length}) + context.append({"type": "prompt", "text": prompt_text, "tokens": self.get_token_representation(prompt_text)}) elif not used_all_tokens: prompt_length = 0 prompt_text = "" @@ -392,12 +434,12 @@ class koboldai_vars(object): used_tokens+=0 if wi['token_length'] is None else wi['token_length'] used_world_info.append(wi['uid']) wi_text = wi['content'] - context.append({"type": "world_info", "text": wi_text, "tokens": wi['token_length']}) + context.append({"type": "world_info", "text": wi_text, "tokens": self.get_token_representation(wi_text)}) text += wi_text self.worldinfo_v2.set_world_info_used(wi['uid']) text += prompt_text - context.append({"type": "prompt", "text": prompt_text, "tokens": prompt_length}) + context.append({"type": "prompt", "text": prompt_text, "tokens": self.get_token_representation(prompt_text)}) self.prompt_in_ai = True else: self.prompt_in_ai = False @@ -723,6 +765,14 @@ class story_settings(settings): self.revisions = [] self.picture = "" #base64 of the image shown for the story self.picture_prompt = "" #Prompt used to create picture + self.substitutions = [ + {"target": "--", "substitution": "–", "enabled": False}, + {"target": "---", "substitution": "—", "enabled": False}, + {"target": "...", "substitution": "…", "enabled": False}, + # {"target": "(c)", "substitution": "©", "enabled": False}, + # {"target": "(r)", "substitution": "®", "enabled": False}, + # {"target": "(tm)", "substitution": "™", "enabled": False}, + ] #must be at bottom self.no_save = False #Temporary disable save (doesn't save with the file) @@ -1001,6 +1051,7 @@ class system_settings(settings): do_dynamic_wi: bool = False # Genamt stopping is mostly tied to Dynamic WI stop_at_genamt: bool = False + do_core: bool = True self.inference_config = _inference_config() self._koboldai_var = koboldai_var diff --git a/static/koboldai.css b/static/koboldai.css index 4e3a3593..d16d34ad 100644 --- a/static/koboldai.css +++ b/static/koboldai.css @@ -1860,6 +1860,10 @@ body { height: 100%; flex-grow: 1; padding: 0px 10px; + + /* HACK: This is a visually ugly hack to avoid cutting of token tooltips on + the first line. */ + padding-top: 15px; } .context-symbol { @@ -1874,10 +1878,30 @@ body { font-family: monospace; } -.context-block:hover { +.context-token { + position: relative; + background-color: inherit; +} + +.context-token:hover { outline: 1px solid gray; } +.context-token:hover::after { + content: attr(token-id); + position: absolute; + + top: -120%; + left: 50%; + transform: translateX(-50%); + + padding: 0px 2px; + background-color: rgba(0, 0, 0, 0.6); + + pointer-events: none; + z-index: 9999999; +} + .context-sp {background-color: var(--context_colors_soft_prompt);} .context-prompt {background-color: var(--context_colors_prompt);} .context-wi {background-color: var(--context_colors_world_info);} @@ -2259,6 +2283,80 @@ body { margin-right: 5px; } +/* Substitutions */ +#Substitutions > .helpicon { + margin-left: 5px; + align-self: auto; +} + +#substitution-header { + display: flex; + width: 100%; + justify-content: space-around; +} + +#substitution-container { + width: 100%; +} + +.substitution-card, #new-sub-card { + display: flex; + + column-gap: 5px; + height: 30px; + width: 100%; + padding: 1px 0px; +} + +.substitution-card > .card-section { + display: flex; +} + +.substitution-card > .card-left > .material-icons-outlined { + margin-left: -5px; + margin-right: 5px; + color: gray; +} + +.substitution-card > .card-section > input { + flex-grow: 1; + min-width: 0; +} + +.substitution-card > * { + min-width: 0; +} + +.substitution-card input { + padding-left: 2px; + border-color: var(--setting_background); +} + +#new-sub-card { + display: flex; + justify-content: center; + background-color: var(--setting_background); + border-radius: 2px; + padding: 1px; + margin: 3px 0px; +} + +.true-t { + display: none; +} + +.true-t + label::before { + content: "edit_off"; + color: gray; + margin-left: 3px; +} + +.true-t:checked + label::before { + content: "edit"; + color: white +} + + /*---------------------------------- Global ------------------------------------------------*/ .hidden { display: none; @@ -2553,30 +2651,33 @@ input[type='range'] { /*Tooltip based on attribute*/ [tooltip] { - cursor: pointer; - display: inline-block; - line-height: 1; - position: relative; + cursor: pointer; + display: inline-block; + line-height: 1; + position: relative; } + [tooltip]::after { - background-color: rgba(51, 51, 51, 0.9); - border-radius: 0.3rem; - color: #fff; - content: attr(tooltip); - font-size: 1rem; - font-size: 85%; - font-weight: normal; - line-height: 1.15rem; - opacity: 0; - padding: 0.25rem 0.5rem; - position: absolute; - text-align: center; - text-transform: none; - transition: opacity 0.2s; - visibility: hidden; - white-space: nowrap; - z-index: 1; + background-color: rgba(51, 51, 51, 0.9); + border-radius: 0.3rem; + color: #fff; + content: attr(tooltip); + font-size: 1rem; + font-size: 85%; + font-weight: normal; + line-height: 1.15rem; + opacity: 0; + padding: 0.25rem 0.5rem; + position: absolute; + text-align: center; + text-transform: none; + transition: opacity 0.2s; + visibility: hidden; + white-space: nowrap; + z-index: 9999; + pointer-events: none; } + @media (max-width: 767px) { [tooltip].tooltip::before { display: none; diff --git a/static/koboldai.js b/static/koboldai.js index 3045d568..82e5460d 100644 --- a/static/koboldai.js +++ b/static/koboldai.js @@ -33,6 +33,7 @@ socket.on("request_prompt_config", configurePrompt); socket.on("log_message", function(data){process_log_message(data);}); socket.on("debug_message", function(data){console.log(data);}); socket.on("scratchpad_response", recieveScratchpadResponse); +socket.on("scratchpad_response", recieveScratchpadResponse); //socket.onAny(function(event_name, data) {console.log({"event": event_name, "class": data.classname, "data": data});}); var presets = {}; @@ -596,6 +597,9 @@ function var_changed(data) { //Special Case for phrase biasing } else if ((data.classname == 'story') && (data.name == 'biases')) { do_biases(data); + //Special Case for substitutions + } else if ((data.classname == 'story') && (data.name == 'substitutions')) { + load_substitutions(data.value); //Special Case for sample_order } else if ((data.classname == 'model') && (data.name == 'sampler_order')) { for (const [index, item] of data.value.entries()) { @@ -2841,6 +2845,22 @@ function update_bias_slider_value(slider) { slider.parentElement.parentElement.querySelector(".bias_slider_cur").textContent = slider.value; } +function distortColor(rgb) { + // rgb are 0..255, NOT NORMALIZED!!!!!! + const brightnessTamperAmplitude = 0.1; + const psuedoHue = 12; + + let brightnessDistortion = Math.random() * (255 * brightnessTamperAmplitude); + rgb = rgb.map(x => x + brightnessDistortion); + + // Cheap hack to imitate hue rotation + rgb = rgb.map(x => x += (Math.random() * psuedoHue * 2) - psuedoHue); + + // Clamp and round + rgb = rgb.map(x => Math.round(Math.max(0, Math.min(255, x)))); + return rgb; +} + function update_context(data) { $(".context-block").remove(); @@ -2858,7 +2878,6 @@ function update_context(data) { } for (const entry of data) { - //console.log(entry); let contextClass = "context-" + ({ soft_prompt: "sp", prompt: "prompt", @@ -2869,14 +2888,27 @@ function update_context(data) { submit: 'submit' }[entry.type]); - let el = document.createElement("span"); - el.classList.add("context-block"); - el.classList.add(contextClass); - el.innerText = entry.text; - el.title = entry.tokens + " tokens"; + let el = $e( + "span", + $el("#context-container"), + {classes: ["context-block", contextClass]} + ); - el.innerHTML = el.innerHTML.replaceAll("
", 'keyboard_return'); + let rgb = window.getComputedStyle(el)["background-color"].match(/(\d+), (\d+), (\d+)/).slice(1, 4).map(Number); + for (const [tokenId, token] of entry.tokens) { + let tokenColor = distortColor(rgb); + tokenColor = "#" + (tokenColor.map((x) => x.toString(16)).join("")); + + let tokenEl = $e("span", el, { + classes: ["context-token"], + "token-id": tokenId === -1 ? "Soft" : tokenId, + innerText: token, + "style.backgroundColor": tokenColor, + }); + + tokenEl.innerHTML = tokenEl.innerHTML.replaceAll("
", 'keyboard_return'); + } document.getElementById("context-container").appendChild(el); switch (entry.type) { @@ -3833,6 +3865,43 @@ async function loadKoboldData(data, filename) { } } +function readLoreCard(file) { + // "naidata" + const magicNumber = new Uint8Array([0x6e, 0x61, 0x69, 0x64, 0x61, 0x74, 0x61]); + + let filename = file.name; + let reader = new FileReader(); + reader.readAsArrayBuffer(file); + + reader.addEventListener("load", function() { + let bin = new Uint8Array(reader.result); + + // naidata is prefixed with magic number + let offset = bin.findIndex(function(item, possibleIndex, array) { + for (let i=0;i 2000) { + alert("Some Substitution shenanigans are afoot; please send the developers your substitutions!"); + throw Error("Substitution shenanigans!") + return; + } + + let escape = true; + for (const c of substitutions) { + if (c.target === bareBonesTarget) continue; + if (!c.enabled) continue; + + if (whatWeGot.includes(c.target)) { + whatWeGot = whatWeGot.replaceAll(c.target, c.substitution); + escape = false; + break; + } + } + + if (escape) break; + } + + return whatWeGot; + } + + function getSubstitutionIndex(cardElement) { + for (const i in substitutions) { + if (substitutions[i].card === cardElement) { + return i + } + } + + throw Error("Didn't find substitution!"); + } + + function getDuplicateCards(target) { + let duplicates = []; + + for (const c of substitutions) { + if (c.target === target) duplicates.push(c.card); + } + + console.log(duplicates) + return duplicates.length > 1 ? duplicates : []; + } + + function makeCard(c) { + // How do we differentiate -- and ---? Convert stuff! + + let card = $e("div", substitutionContainer, {classes: ["substitution-card"]}); + let leftContainer = $e("div", card, {classes: ["card-section", "card-left"]}); + let deleteIcon = $e("span", leftContainer, {classes: ["material-icons-outlined", "cursor"], innerText: "clear"}); + let targetInput = $e("input", leftContainer, {classes: ["target"], value: c.target}); + let rightContainer = $e("div", card, {classes: ["card-section"]}); + let substitutionInput = $e("input", rightContainer, {classes: ["target"], value: c.substitution}); + + // HACK + let checkboxId = "sbcb" + Math.round(Math.random() * 9999).toString(); + + let enabledCheckbox = $e("input", rightContainer, {id: checkboxId, classes: ["true-t"], type: "checkbox", checked: c.enabled}); + let initCheckTooltip = c.enabled ? "Enabled" : "Disabled"; + + // HACK: We don't use in-house tooltip as it's cut off by container :( + let enabledVisual = $e("label", rightContainer, {for: checkboxId, "title": initCheckTooltip, classes: ["material-icons-outlined"]}); + + targetInput.addEventListener("change", function() { + let card = this.parentElement.parentElement; + let i = getSubstitutionIndex(card); + + substitutions[i].target = this.value; + + // Don't do a full rebake + substitutions[i].trueTarget = getTrueTarget(this.value); + + for (const duplicateCard of getDuplicateCards(this.value)) { + if (duplicateCard === card) continue; + console.log("DUPE", duplicateCard) + substitutions.splice(getSubstitutionIndex(duplicateCard), 1); + duplicateCard.remove(); + } + + rebuildCharMap(); + updateSubstitutions(); + }); + + substitutionInput.addEventListener("change", function() { + let card = this.parentElement.parentElement; + let i = getSubstitutionIndex(card); + + substitutions[i].substitution = this.value; + // No rebaking at all is needed, that all hinges on target value; not edited here. + updateSubstitutions(); + }); + + deleteIcon.addEventListener("click", function() { + let card = this.parentElement.parentElement; + + // Find and remove from substitution array + substitutions.splice(getSubstitutionIndex(card), 1); + updateSubstitutions(); + rebakeSubstitutions(); + card.remove(); + }); + + enabledCheckbox.addEventListener("change", function() { + let card = this.parentElement.parentElement; + let i = getSubstitutionIndex(card); + console.log(this.checked) + + substitutions[i].enabled = this.checked; + enabledVisual.setAttribute("title", this.checked ? "Enabled" : "Disabled") + rebakeSubstitutions(); + updateSubstitutions(); + }); + + return card; + } + + function updateSubstitutions() { + let subs = substitutions.map(x => ({target: x.target, substitution: x.substitution, trueTarget: x.trueTarget, enabled: x.enabled})); + socket.emit("substitution_update", subs); + } + + function rebakeSubstitutions() { + for (const c of substitutions) { + c.trueTarget = getTrueTarget(c.target); + } + rebuildCharMap(); + } + + function rebuildCharMap() { + charMap = []; + for (const c of substitutions) { + if (!c.enabled) continue; + for (const char of c.target) { + if (!charMap.includes(char)) charMap.push(char) + } + } + } + + const newCardButton = $el("#new-sub-card"); + newCardButton.addEventListener("click", function() { + let c = {target: "", substitution: "", enabled: true} + substitutions.push(c); + c.card = makeCard(c); + newCardButton.scrollIntoView(); + }); + + // Event handler on input + // TODO: Apply to all of gametext + const inputText = $el("#input_text"); + inputText.addEventListener("keydown", function(event) { + if (event.ctrlKey) return; + if (event.ctrlKey) return; + if (!charMap.includes(event.key)) return; + + let caretPosition = inputText.selectionStart; + // We don't have to worry about special keys due to charMap (hopefully) + let futureValue = inputText.value.slice(0, caretPosition) + event.key + inputText.value.slice(caretPosition); + + for (const c of substitutions) { + if (!c.target) continue; + if (!c.enabled) continue; + + let t = c.trueTarget; + let preCaretPosition = caretPosition - t.length + 1; + let bit = futureValue.slice(caretPosition - t.length + 1, caretPosition + 1) + + if (bit === t) { + // We're doing it!!!! + event.preventDefault(); + + // Assemble the new text value + let before = inputText.value.slice(0, caretPosition - t.length + 1); + let after = inputText.value.slice(caretPosition); + let newText = before + c.substitution + after; + + inputText.value = newText; + + // Move cursor back after setting text + let sLength = c.substitution.length; + inputText.selectionStart = preCaretPosition + sLength; + inputText.selectionEnd = preCaretPosition + sLength; + + break; + } + } + }); + + let firstLoad = true; + + function load_substitutions(miniSubs) { + // HACK: Does the same "replace all on load" thing that WI does; tab + // support is broken and overall that kinda sucks. Would be nice to + // make a robust system for syncing multiple entries. + + console.log("load", miniSubs) + + $(".substitution-card").remove(); + // we only get target, trueTarget, and such + for (const c of miniSubs) { + if (!c.trueTarget) c.trueTarget = getTrueTarget(c.target); + //if (!c.enabled) c.enabled = false; + c.card = makeCard(c); + } + substitutions = miniSubs; + rebuildCharMap(); + + // We build trueTarget on the client, and it's not initalized on the server because I'm lazy. + // May want to do that on the server in the future. + if (firstLoad) updateSubstitutions(); + firstLoad = false; + } + + return [load_substitutions]; +})(); + /* -- Shortcuts -- */ document.addEventListener("keydown", function(event) { diff --git a/templates/settings flyout.html b/templates/settings flyout.html index 7fcabc70..b210a4a6 100644 --- a/templates/settings flyout.html +++ b/templates/settings flyout.html @@ -412,6 +412,27 @@ + +
+

expand_more Substitutions

+
+ +
+ Automatically replaces phrases that you or the AI insert. + + help_icon +
+ Replace With +
+
+ +
+ + add + +
+
+