Based on the following code, can you add an option that allows the creation of another Bot Character Description box from the click of a button?

ai = {import:ai-text-plugin} // the plugin that actually generates the text
upload = {import:upload-plugin} // for uploading data for share links
tabbedCommentsPlugin = {import:tabbed-comments-plugin-v1}
commentsPlugin = {import:comments-plugin} // for feedback button
fullscreenButton = {import:fullscreen-button-plugin}
literal = {import:literal-plugin} // this plugin allows people to use square/curly brackets (which are usually special characters in perchance) in their bot/user names, writing instructions, etc. without causing errors

botName = [botNameEl.value.trim() || "Bot"]
userName = [userNameEl.value.trim() || "Anon"]  

page 
  title = AI Chat // you can change this title to whatever you want, and same with everything else here
  subtitle = (Psst. <a href="https://perchance.org/ai-character-chat" target="_blank" style="font-weight:bold; color:#2bbb00;">Click here</a> if you'd like to try out a different character chat interface.)
  intro = This is a demonstration of what's possible with Perchance's new <a href="/ai-text-plugin" target="_blank">AI Text Plugin</a>, which complements the <a href="/ai-text-to-image-generator" target="_blank" style="font-weight:bold;">image generation</a> feature. Select an example character above, or manually create one below. You can also try <a href="https://perchance.org/ai-story-generator" target="_blank" style="font-weight:bold;">writing a story</a> or <a href="https://perchance.org/ai-rpg" target="_blank" style="font-weight:bold;">going on an adventure</a> or <a href="https://perchance.org/ai-generated-hierarchical-world" target="_blank" style="font-weight:bold;">exploring a new planet</a>. There's also <a href="https://perchance.org/ai-character-chat" target="_blank" style="font-weight:bold; color:#2bbb00;">a chat interface with image generation</a>.



// You can edit the instructions below and then click the save button to create your own AI Chat page with custom instructions. 
// Note that we tell it to write the next 10 messages, but we stop it (using newline as one of the stopSequences) after the first one. This is just a little trick we play on the AI to reduce the chance of it trying to "wrap up" the story.
instruction
  $output = [this.joinItems("\n")] // between [literal(botName)] and [literal(userName)]. 
  Please write the next 10 messages for the following chat/RP. Most messages should be a medium-length paragraph, including thoughts, actions, and dialogue. Create an engaging, captivating, and genuinely fascinating story. So good that you can't stop reading. Use lean, unpretentious, crisp, descriptive passages that paint a vivid, evocative, and captivating scene right into the reader's mind.
  [""]
  # Reminders:
  - You can use *asterisks* to start and end actions in typical roleplay style. Most messages should be detailed and descriptive, including dialogue, actions, and thoughts. Utilize all five senses for character experiences.
  - This story never ends. You must keep the story going forever. Drive the story forward, introducing new arcs and events when narratively appropriate. Introduce both external and internal conflicts to drive the story forward and keep things interesting and dynamic. Escalate tensions gradually, creating a sense of anticipation.
  - Aim for superb narrative pacing, and deep worldbuilding. Reveal the world/characters/plot gradually through character interactions and experiences. Allow the reader to discover its intricacies organically (instead of using exposition dumps).
  - Each message should be contained within a single paragraph. Add a blank line between each message. Balance moments of tension and relaxation to maintain reader engagement. Vary sentence and paragraph length to control the rhythm of the roleplay, switching from shorter, punchier sentences to longer, more descriptive ones as appropriate to create interesting variation in pace and structure. //[chatLogsEl.value.length < 7000 ? ` Each message should be one medium-sized paragraph. Descriptive, engaging, clean, authentic, and captivating prose.` : ""]
  - Avoid unnecessary and unoriginal repetition of previous messages.
  - Bring characters to life by portraying their unique traits, thoughts, emotions, appearances, and speech patterns realistically. Consider the situation, motivations, and potential consequences. Ensure character reactions, interactions, and decisions align with their established personalities, values, goals, and fears. Use subtle gestures, distinctive quirks, and colloquialisms to create enriched, lifelike scenes. Allow characters' motivations and personalities to evolve authentically throughout the story, creating genuine character arcs.
  - Aggression, graphic violence, character toxicity, and vulgarity are allowed in scenarios with mature themes. Portray conflict realistically - i.e. without shallow resolutions or excessive positivity bias.
  - The overall goal is to create a genuinely fascinating and engaging roleplay/story. So good that you can't stop reading. Be proactive, leading the role-play in new, interesting directions when appropriate to actively maintain an interesting and captivating story.
  - Develop the story in a manner that a skilled author and engaging storyteller would. Craft conversations that reveal character, advance the plot, and feel natural. Use subtext and unique speech patterns to differentiate characters and convey information indirectly.
  - Narrator messages should be longer than normal messages.
  // - IMPORTANT: Focus on the present moment, and explore it further. Never rush to finish a scene. Take it slow and explore the present moment with vivid, grounded, and captivating explorations of the current situation. Show, don't tell.
  [""]
  # Here's [literal(botName)]'s description/personality:
  ---
  <<<BOT_DESCRIPTION_PLACEHOLDER>>>
  ---
  [""]
  # Here's [literal(userName)]'s description/personality:
  ---
  <<<USER_DESCRIPTION_PLACEHOLDER>>>
  ---
  [""]
  # Here's the initial scenario and world info:
  ---
  <<<SCENARIO_PLACEHOLDER>>>
  ---
  [""]
  # Here's what has happened so far:
  ---
  <<<CHAT_LOGS_PLACEHOLDER>>>
  ---
  [""]
  Your task is to write the next 10 messages in this chat/roleplay between [literal(userName)] and [literal(botName)]. There should be a blank new line between messages.
  [this.nextMessageDraftOrInstruction ? `IMPORTANT: Message #${numMessagesToPutInStartWith+1} MUST be based on this idea/instruction: `+literal(this.nextMessageDraftOrInstruction).replace(/\n+/g, ". ") : "@eraseableLine"]
  [whatHappensNextEl.value.trim() ? `IMPORTANT: Rougly speaking, the reader wants this to happen next: **${literal(whatHappensNextEl.value.trim().replace(/\n+/g, ". "))}** You MUST **creatively interpret** these instructions (not repeat verbatim) - be creative! Let it play out in a fascinating and edge-of-your-seat kind of way.` : "@eraseableLine"]
  [writingInstructionsEl.value.trim() ? `The reader also gave these more general instructions: `+literal(writingInstructionsEl.value.trim().replace(/\n+/g, ". ")) : "@eraseableLine"]
  Write the next 10 messages. Most messages should be a medium-length paragraph, including thoughts, actions, and dialogue. // - remember to make them interesting, authentic, descriptive, natural, engaging, and creative.




scenarioGenerationPrompt
  I'm going to get you to write a creative, interesting chat/roleplay scenario using the following keywords/prompt/ideas as inspiration: [window.scenarioInspiration || "(None provided. Just be creative!)"]
  The scenario should be based on these two characters:
  # Character 1: [literal(botNameEl.value.trim())]
  [literal(botDescriptionEl.value.trim().replaceAll("{{user}}", userName).replaceAll("{{char}}", botName))]
  \n
  # Character 2: [literal(userNameEl.value.trim())]
  [literal(userDescriptionEl.value.trim().replaceAll("{{user}}", userName).replaceAll("{{char}}", botName))]
  \n
  Your task is to write an interesting, creative, engaging chat/roleplay scenario between the above two characters. As mentioned above, it should be based on these keywords/ideas/topics/instructions: [window.scenarioInspiration || "(None provided. Just be creative!)"]
  The scenario should be a single SHORT paragraph that sets up the beginning of the chat/roleplay so it goes in an interesting and fun direction.
  Be creative! Just provide a one-paragraph "spark" to get the chat/roleplay going.
  $output = [this.joinItems("\n")]
  



charactersAndScenarioGenerationPrompt
  Your task is to come up with an interesting and captivating chat/RP scenario with two characters. Ensure that what you write is subtle, authentic, interesting, descriptive, and something that the roleplay participants will have a lot of fun with. Avoid boring cliches.{| When possible, AVOID cliche character names like Luna, Lila, Orion, Elara, Raven, Evelyn, Castellanos, Whisper, Marquez, Leo, Alejandro, etc. - these are tacky and overused.^4}
  ##introInspirationPlaceholder##
  [""]
  You must use this EXACT template for your response:
  ---
  CHARACTER 1 NAME: (name of *first* character mentioned in above instructions)
  CHARACTER 1 DESCRIPTION: (a one-paragraph backstory, description, personality, idiosyncrasies/mannerisms, etc. of the *first* character)
  CHARACTER 2 NAME: (the second character's given name or nickname)
  CHARACTER 2 DESCRIPTION: (a one-paragraph backstory, description, personality, idiosyncrasies/mannerisms, etc. of the second character)
  STARTING SCENARIO: (a short one-paragraph roleplay starter - i.e. a starting scenario for the above two characters that is interesting, creative and engaging. This paragraph is the "spark" to get the chat/roleplay going - i.e. it "sets the scene".)
  GENRE: (the genre of the story/roleplay) // not actually used - added so we have an easy stop sequence
  ---
  Follow the above template EXACTLY, replacing the parentheticals with actual content.
  ##outroInspirationPlaceholder##
  $output = [this.joinItems("\n")]
  
  

getLastMessage() =>
  let lastMessage = chatLogsEl.value.trim().split(/\n{2,}/).pop();
  let name = lastMessage?.split(":")[0].trim();
  let content = lastMessage?.split(":").slice(1).join(":").trim();
  if(name.length > 50 && content === "") {
    // it's probably a "narration" paragraph without the "Narrator:" at the start
    content = name;
    name = "Narrator:";
  }
  return {name, content};

lastMessageIsEmpty() =>
  return getLastMessage().content === "";

getNextTurnName() =>
  if(chatLogsEl.value.trim() === "") {
    return botName;
  }
  // we need to work out who's turn it is next, so we can set the startWith to "<name>: " where <name> is the character who is going to speak next.
  let {name, content} = getLastMessage();
  if(content === "") {
    return name; // if the message content is empty, then we assume the user wants to generate a message with that name
  } else {
    let nextName;
    if(name === botName) { nextName = userName; } else { nextName = botName; }
    return nextName;
  }

numMessagesToPutInStartWith = 2

getStarterText(opts) =>
  if(!opts) opts = {};
  // We make the AI start with the last `numMessagesToPutInStartWith` messages, rather than putting all the messages in the instruction. I think this might make the AI less likely to repeat the last message since it can more easily "see" what it just wrote.
  let lastFewMessagesArr = chatLogsEl.value.trim().split(/\n{2,}/).filter(m => !/^SUMMARY\^[0-9]+:/.test(m.trim())).slice(-numMessagesToPutInStartWith);
  if(writingInstructionsEl.value.trim() || opts.nextMessageDraftOrInstruction || whatHappensNextEl.value.trim()) {
    // Add the writing instructions and related stuff if they have specified that:
    let haveAddedOOC = false;
    let lastMessage = lastFewMessagesArr.pop();
    if(writingInstructionsEl.value.trim() || whatHappensNextEl.value.trim()) {
      let message = "";
      if(whatHappensNextEl.value.trim()) message = `Here's what will happen in the next message: **${whatHappensNextEl.value.trim().replace(/\n+/g, ". ")}**`;
      if(writingInstructionsEl.value.trim()) message += `${message ? " And " : ""}I'm going to keep these writing instructions in mind as I write: ${writingInstructionsEl.value.trim().replace(/\n+/g, ". ")}`
      lastFewMessagesArr.push(`(OOC: ${message})`);
      haveAddedOOC = true;
    }
    if(opts.nextMessageDraftOrInstruction) {
      lastFewMessagesArr.push(`(OOC: ${haveAddedOOC ? "Also, note" : "Note"} the next message will creatively interpret this idea: "${opts.nextMessageDraftOrInstruction.replace(/\n+/g, " ").replace(/"/g, "'")}")`);
      haveAddedOOC = true;
    }
    
    lastFewMessagesArr.push(lastMessage);
  }
  let lastFewMessagesText = lastFewMessagesArr.join("\n\n").trim();
  if(opts.continueMode) {
    return lastFewMessagesText.trim();
  } else if(lastMessageIsEmpty()) {
    return lastFewMessagesText.trim(); // if last message is empty, then we already have the "\n\n<name>:" part, so we don't need to add it
  } else {
    return lastFewMessagesText.trim() + "\n\n" + getNextTurnName() + ":";
  }
  
updateVisibilityOfReplyButtonsAndSelectors() =>
  if(inputEl.value.trim() === "") {
    sendAsCharacterCtn.style.display = "none";
    autoRespondCtn.style.display = "none";
    quickReplyButtonsCtn.style.display = "flex";
  } else {
    sendAsCharacterCtn.style.display = "flex";
    quickReplyButtonsCtn.style.display = "none";
    if(autoImproveCheckboxEl.checked) {
      autoRespondCtn.style.display = "none";
    } else {
      autoRespondCtn.style.display = "flex";
    }
  }

async handleSendButtonClick(opts) =>
  if(opts && opts.mode === "continue") {
    window.mostRecentChatLogEditWasAContinuationGeneration = true;
    window.mostRecentGenerationContinuationChatLogContextText = chatLogsEl.value;
  } else {
    // we also set this to false if user manually inputs text into chatlogs (ctrl+f for this variable in HTML panel)
    window.mostRecentChatLogEditWasAContinuationGeneration = false;
  }
  
  chatLogsEl.value = chatLogsEl.value.trim();
  
  try { injectSummariesAndComputeNextSummariesInBackgroundIfNeeded(); } catch(e) { console.error(e); }
  await new Promise(r => setTimeout(r, 5)); // just in case i accidentally make the above function async at some point - want to ensure it grabs a snapshot of the chat logs text before `temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance` stuff
  
  rateLastMessageBadBtn.disabled = true;
  rateLastMessageGoodBtn.disabled = true;
  rateLastMessageBadBtn.style.opacity = 1;
  rateLastMessageGoodBtn.style.opacity = 1;
  
  let generatedTextAndThereWereNoErrors = false;
  try {
    chatLogsEl.value = chatLogsEl.value.replace(/\n{2,}/g, "\n\n"); // ensure exactly 2 newlines between all messages
    let nextMessageDraftOrInstruction = null;
    
    // if the input box contains some text, then they have explicitely selected a character to reply with, so we need to remove any 'empty' messages from the end of the chat
    if(inputEl.value.trim() !== "" && opts.mode === "normal") {
      let i = 0;
      while(chatLogsEl.value.trim() !== "" && lastMessageIsEmpty()) {
        if(chatLogsEl.value.trim().slice(-1) !== ":") {
          break; // just an extra safety guard to prevent deleting non-empty messages
        }
        chatLogsEl.value = chatLogsEl.value.split(/\n{2,}/).slice(0, -1).join("\n\n");
        if(i++ > 10000) return alert("There was an error in the code. Please report this error in the feedback box with error code '243'.");
      }
    }
    
    window.nextMessageDraftOrInstruction_prevMessage = null;
    
    if(opts.mode === "normal" || opts.mode === "regen") {
      // if they're in auto-improve mode, then inputEl actually contains the "instruction" or "draft" for the next message, not the message itself
      if(autoImproveCheckboxEl.checked && inputEl.value.trim() !== "") {
        nextMessageDraftOrInstruction = inputEl.value.trim();
        inputEl.value = "";
        if(!chatLogsEl.value.endsWith(sendAsCharacterSelectEl.value+":")) {
          chatLogsEl.value += '\n\n'+sendAsCharacterSelectEl.value+':';
        }
        updateVisibilityOfReplyButtonsAndSelectors();
        window.nextMessageDraftOrInstruction_prevMessage = nextMessageDraftOrInstruction;
      }
    }
    
    if(opts.mode === "normal") {
      // if there's some text in the input box, we add that text to the chat logs with the user character's name at the start
      if(inputEl.value.trim() !== "") {
        if(chatLogsEl.value.trim() !== "") chatLogsEl.value += "\n\n";
        chatLogsEl.value += (sendAsCharacterSelectEl.value || userName)+": "+inputEl.value.trim();
        
        chatLogsEl.scrollTop = chatLogsEl.scrollHeight;
        inputEl.value = ""; // we don't set localStorage.input="" yet - in case e.g. page reloads mid generation
        
        // inputEl is now empty, so we appropriately adjust visible reply buttons/selectors:
        updateVisibilityOfReplyButtonsAndSelectors();
        
        if(!autoRespondCheckboxEl.checked) {
          return; // no response needed - user wants to e.g. manually use quick reply buttons to choose responding character
        }
      }

      if(chatLogsEl.value.trim() === "") {
        chatLogsEl.value += getNextTurnName() + ":";
      }
      
      if(!lastMessageIsEmpty()) {
        chatLogsEl.value += "\n\n" + getNextTurnName() + ":"; // add two new lines and the name of the person speaking next, ready for the response that the AI is about to write into the chat logs
      }
    }
    
    chatLogsEl.scrollTop = chatLogsEl.scrollHeight;
    
    continueMessageBtn.disabled = true;
    continueMessageBtn.style.display = 'none';
    continueMessageBtn.style.visibility = 'hidden'; // can't *only* use `display` because that's controlled independently by the caret/focus tracking code.
    sendMessageBtn.disabled = true;
    regenMessageBtn.disabled = true;
    deleteLastMessageBtn.disabled = true;
    quickReplyButtonsCtn.querySelectorAll("button").forEach(el => el.disabled=true);
    undoDeleteLastMessageCtn.style.display = "none";

    // note: if this `handleSendButtonClick` function is being called by the regen function, then the regen function will re-enable these as needed:
    regenPrevButton.disabled = true;
    regenNextButton.disabled = true;
    
    // stop button replaces rating buttons during generation:
    if(rateLastMessageCtn.offsetWidth) {
      stopGenerationBtn.style.width = rateLastMessageCtn.offsetWidth+"px";
      stopGenerationCtn.style.marginRight = rateLastMessageCtn.style.marginRight;
    }
    rateLastMessageCtn.style.display = "none";
    stopGenerationCtn.style.display = "";
    
    addBackTemporarilyRemovedPrefixIfNeeded(); // just to be safe
    
    let chatLogs;
    try {
      // get a version of the message feed with hierarchical summaries swapped in:
      let messagesWithSummaryReplacements = getMessagesWithSummaryReplacements(chatLogsEl.value);
      
      if(messagesWithSummaryReplacements.slice(-8).filter(m => /^SUMMARY\^[0-9]+:/.test(m)).length > 0) {
        console.error("Summarization is going too close to the end of the chat. Must stay back so LLM doesn't get confused, and so messages-in-startWith trick works.");
        // debugger;
      }
      messagesWithSummaryReplacements = messagesWithSummaryReplacements.map(m => m.replace(/SUMMARY\^[0-9]+:/, "Summary (previous events):").trim());

      // we leave off the last `numMessagesToPutInStartWith` messages, since we'll put them in the `startWith` instead of the `instruction` (I think this might make the AI less likely to repeat itself as the chat gets longer, since if the most recent messages are in the response, then it might be able to more easily "see" what it just wrote)
      chatLogs = messagesWithSummaryReplacements.slice(0, -numMessagesToPutInStartWith).join("\n\n").trim() || "(No chat messages yet. This is the beginning of the chat.)";
    } catch(e) {
      console.error("Falling back to using *all* messages because there was an error while trying to compute messagesWithSummaryReplacements:", e);
      
      chatLogs = chatLogsEl.value.trim().split(/\n{2,}/).filter(m => !/^SUMMARY\^[0-9]+:/.test(m.trim())).slice(0, -numMessagesToPutInStartWith).join("\n\n").trim() || "(No chat messages yet. This is the beginning of the chat.)";
    }
    
    chatLogs = chatLogs.replace(/\{\{user\}\}/ig, userName).replace(/\{\{char\}\}/ig, botName);
    
    // let newlineStopStr = chatLogsEl.value.length < 1000 ? "\n" : "\n\n"; // <-- for the first few messages, stop at a single newline, to ensure that AI learns the double-newline rule between messages
    // // BUT for the very first message, we need to show the AI that it shouldn't write e.g. "Narrator:\nOnce upon..." so we need to use \n\n as the stop sequence and then remove the newline:
    // if(chatLogsEl.value.length < 50) newlineStopStr = "\n\n";
    
    // EDIT: commented above out because stopping at "\n" can lead to case where AI "can't reply" during start of chat because it hasn't properly learned not to add a newline after "Name:"
    // The risk with using just "\n\n" is that the AI writes the whole chat without putting blank lines between messsages, and so just doesn't stop writing.
    // So we also add character names here to try prevent that.
    let stopSequences = ["\n\n", `\n${userName}:`, `\n${botName}:`];
    
    window.numMessagesGeneratedThisSession = (window.numMessagesGeneratedThisSession || 0) + 1;
    
    let lastSyntaxHighlightTime = 0;
    let withheldTrailingNewlines = null; // in case they are the newlines that indicate the end of the message - we prepend them to the next token if there is a next token
    let gotFirstChunk = false;
    let pendingObj = ai({
      instruction: () => {
        // The reason we construct this with a function rather than just putting it all in the actual `instruction` list is kind of technical - it's because we don't want to evaluate square/curly blocks that happen to be in the chat logs, scenario, or character descriptions
        // EDIT: Now that we have the `literal-plugin`, we don't really need to do this, but it's fine.
        instruction.nextMessageDraftOrInstruction = nextMessageDraftOrInstruction;
        let output = instruction.evaluateItem;
        let botDescription = botDescriptionEl.value.trim() || "(Not specified.)";
        let userDescription = userDescriptionEl.value.trim() || "(Not specified.)";
        let scenario = scenarioEl.value.trim() || "(None specified. Freeform.)";
        output = output.replace("<<<BOT_DESCRIPTION_PLACEHOLDER>>>", botDescription.replace(/\{\{user\}\}/ig, userName).replace(/\{\{char\}\}/ig, botName));
        output = output.replace("<<<USER_DESCRIPTION_PLACEHOLDER>>>", userDescription.replace(/\{\{user\}\}/ig, userName).replace(/\{\{char\}\}/ig, botName));
        output = output.replace("<<<SCENARIO_PLACEHOLDER>>>", scenario.replace(/\{\{user\}\}/ig, userName).replace(/\{\{char\}\}/ig, botName));
        output = output.replace("<<<CHAT_LOGS_PLACEHOLDER>>>", chatLogs);
        output = output.replace(/@eraseableLine\n/g, "");
        return output;
      },
      startWith: () => {
        let continueMode = opts.mode === "continue" || opts.mode === "regen";
        let text = getStarterText({continueMode, nextMessageDraftOrInstruction}); 
        // inject the "super important" writing instructions:
        let messages = text.split(/\n{2,}/);
        if(responseLengthCheckboxEl.checked && (Math.random() < 0.3 || window.numMessagesGeneratedThisSession%4 === 0) && chatLogsEl.value.length > 300) { // allow the first couple of messages to be 'natural', and only add it every second message or so, just to prevent "over-instructing" the AI e.g. into longer and longer messages
          messages[messages.length-1] = messages[messages.length-1].replace(":", ` (detailed one-paragraph response):`);
          text = messages.join("\n\n");
        }
        return text.replace(/\{\{user\}\}/ig, userName).replace(/\{\{char\}\}/ig, botName);
      },
      stopSequences, 
      onChunk: (data) => {
        if(data.isFromStartWith) {
          // onChunk gives us all chunks of the AI's response, including the startWith text that we specified, so we skip that text - we only want to output the stuff that the AI actually wrote into the chat logs
        } else {
          let textChunk = data.textChunk;
          if(!gotFirstChunk) {
            if(chatLogsEl.value.length > 50000) {
              addBackTemporarilyRemovedPrefixIfNeeded(); // <-- just to be safe
              let prevPageScrollTop = document.scrollingElement.scrollTop; // need to preserve overall page position due to browser's built-in anti-scroll-jank algorithm messing with us here
              window.temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance = chatLogsEl.value.slice(0, -50000)
              chatLogsEl.value = chatLogsEl.value.slice(-50000);
              document.scrollingElement.scrollTop = prevPageScrollTop; // restore page scroll position
            }
            if(/(^|\n\n).{0,100}: ?$/.test(chatLogsEl.value)) { // if this is a normal "\n\nCharName:" prefixed generation, then remove an newlines that the AI adds to the start of the message
              textChunk = textChunk.replace(/^\n+/, " "); // replace it with a space else we get e.g. "Narrator:Once upon..."
            }
          }
          gotFirstChunk = true;
          
          // withhold trailing newlines and add them back to the next chunk (if it turns out they're not the end-of-message newlines)
          if(withheldTrailingNewlines) {
            textChunk = withheldTrailingNewlines+textChunk;
            withheldTrailingNewlines = null;
          }
          if(textChunk.endsWith("\n\n")) {
            withheldTrailingNewlines = "\n\n";
            textChunk = textChunk.slice(0, -2);
          } else if(textChunk.endsWith("\n")) {
            withheldTrailingNewlines = "\n";
            textChunk = textChunk.slice(0, -1);
          }
          
          chatLogsEl.value += textChunk; // we're manually adding each chunk of generated text to the "chatLogsEl" text box, rather than using `outputTo`, since `outputTo` would clear all the existing text and only add the *response*, whereas we want to preserve all the existing text, and just add the response to the end. 
          if(chatLogsEl.scrollTop > (chatLogsEl.scrollHeight - chatLogsEl.offsetHeight)-30) { // <-- if the text box is already scrolled near the end of the text
            chatLogsEl.scrollTop = 99999999; // scroll down to bottom of text box as story streams in
          }
          
          try {
            if(window.applyChatLogSyntaxHighlighting && Date.now()-lastSyntaxHighlightTime > 1000) {
              window.applyChatLogSyntaxHighlighting();
              lastSyntaxHighlightTime = Date.now();
            }
          } catch(e) { console.error(e); }
        }
      },
    });
    loaderEl.innerHTML = pendingObj.loadingIndicatorHtml; 
    
    window.lastMessagePendingObj = pendingObj;
    
    stopGenerationBtn.onclick = function() {
      pendingObj.stop();
    };

    setTimeout(() => {
      chatLogsEl.scrollTop = 999999999; // <-- scroll to the bottom of the chat logs
    }, 50); // we do it 50ms after they click send, so that we scroll down *after* the character's name has been added. EDIT: wait, but we add the name above? I think this is old code - probably not needed anymore.
    
    let data = await pendingObj;
    if(data.stopReason !== "error") generatedTextAndThereWereNoErrors = true;
    // we're not actually using this data, but I do want to await the promise anyway, since other functions currently expect `await handleSendButtonClick()` to resolve when generation is finished
  } catch(e) {
    console.error(e);
  }
  
  // Note that rateLastMessageCtn is displayed by the updateLastMessageButtonsDisplayIfNeeded, below (since it's only displayed if localStorage.sendCount is high enough).
  stopGenerationCtn.style.display = "none";
  
  addBackTemporarilyRemovedPrefixIfNeeded();

  continueMessageBtn.disabled = false;
  continueMessageBtn.style.visibility = 'visible'; // we don't also set display='' here because the tracking code will make it visible when needed
  sendMessageBtn.disabled = false;
  regenMessageBtn.disabled = false;
  deleteLastMessageBtn.disabled = false;
  quickReplyButtonsCtn.querySelectorAll("button").forEach(el => el.disabled=false);
  
  if(generatedTextAndThereWereNoErrors) {
    setTimeout(() => { // delay it a bit to prevent accidental clicks, since it swaps out with stop button
      rateLastMessageBadBtn.disabled = false;
      rateLastMessageGoodBtn.disabled = false;
    }, 2500);
  }

  loaderEl.innerHTML = ""; // clear the loading indicator
  let pageScrollTop = document.scrollingElement.scrollTop;
  chatLogsEl.value = chatLogsEl.value.trim(); // remove newlines/spaces from start and end of chatlog
  document.scrollingElement.scrollTop = pageScrollTop; // otherwise chrome's "layout shift prevention" messes with the page scroll height
  localStorage.chatLogs = chatLogsEl.value; // save chat logs to localStorage so it's still there even if the page is reloaded

  chatLogsDeleteBtn.style.display = '';
  deleteAndRegenLastMessageCtn.style.display = 'flex';
  updateCharacterNameViews();
  
  localStorage.input = inputEl.value;

  if(localStorage.sendCount === undefined || isNaN(Number(localStorage.sendCount))) localStorage.sendCount = "0";
  localStorage.sendCount = Number(localStorage.sendCount) + 1;
  triggerTipIfNeeded();
  updateLastMessageButtonsDisplayIfNeeded();
  try { if(window.applyChatLogSyntaxHighlighting) window.applyChatLogSyntaxHighlighting(); } catch(e) { console.error(e); }


addBackTemporarilyRemovedPrefixIfNeeded() =>
  if(window.temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance) {
    // page scroll messes up when we add prefix logs back, so we need to save + restore scroll position:
    let prevPageScrollTop = document.scrollingElement.scrollTop;
    chatLogsEl.value = window.temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance + chatLogsEl.value;
    chatLogsEl.scrollTop = 999999999;
    document.scrollingElement.scrollTop = prevPageScrollTop;  
    window.temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance = null;
  }

getMessagesWithSummaryReplacements(text, opts) =>
  if(!opts) opts = {};
  const minimumMessageLevel = opts.minimumMessageLevel || 0; // used by the summarization process.
  
  let messages = text.split(/\n{2,}/).map(m => m.trim()).filter(m => m);
  let messagesWithSummaryReplacements = [];
  let highestLevelSeen = 0;
  
  // it's we go backwards through the messages, and only 'collect' a message if its level is not below the highest level we've seen so far. it makes sense if you think about it for a bit.
  // said another way, we go from the end of the messages to the start while 'monotonically climbing' up a level whenever we hit a 'higher' message.
  while(messages.length > 0) {
    let m = messages.pop();
    let level = Number((m.match(/SUMMARY\^([0-9]+):/)||[])[1] || 0);
    if(level < minimumMessageLevel) continue;
    if(level >= highestLevelSeen) {
      messagesWithSummaryReplacements.unshift(m);
      highestLevelSeen = level;
    }
  }
  return messagesWithSummaryReplacements;

summaryPromptInstruction
  Your task is to generate some text for a chat/roleplay/story/narration and then a 'SUMMARY' of that text, and then repeat a few times. Below are the characters and the initial scenario, and a summary of earlier events. You must write the text, and then a summary of that text that you wrote, and then some more text, and a summary of that new text, and repeat. Each summary should be a single paragraph of text which concisely compresses the recent text to roughly half its original size.
  IMPORTANT: Every summary must be UNIQUE, and it must be concise, and information dense. Avoid flowery prose in summaries. Write concise summaries, but don't miss any important facts/events.
  IMPORTANT: Summaries must contain ALL important details from the text they're summarizing. Try to include *every* important detail in your summaries, resulting in a summary that is about half the length of the original text.
  Use this format/template for your response:
  ```
  \[A\]: <story/narration text>
  SUMMARY of \[A\]: <a dense, one-paragraph summary of the \[A\] text>
  ---
  \[B\]: <story/narration text>
  SUMMARY of \[B\]: <a dense, one-paragraph summary of the \[B\] text>
  ---
  \[C\]: <story/narration text>
  SUMMARY of \[C\]: <a dense, one-paragraph summary of the \[C\] text>
  ```
  [""]
  # Noah (Character):
  [literal((botDescriptionEl.value.trim().replace(/\n+/g, "\n") || "(Not specified.)").replaceAll("{{user}}", userName).replaceAll("{{char}}", botName))]
  [""]
  # Ava (Character):
  [literal((userDescriptionEl.value.trim().replace(/\n+/g, "\n") || "(Not specified.)").replaceAll("{{user}}", userName).replaceAll("{{char}}", botName))]
  [""]
  # Initial Scenario & Lore:
  [literal((scenarioEl.value.trim().replace(/\n+/g, "\n") || "(None specified. Freeform.)").replaceAll("{{user}}", userName).replaceAll("{{char}}", botName))]
  [""]
  # Summary of Previous Events:
  [literal(window.summaryMessagesForInstruction.join("\n"))]
  [""]
  ---
  [""]
  Again, your task is to write some text labelled with a letter, and then a summary of that text, and then some new text, and then a summary of that new text, and so on. Each summary should be a single paragraph of text which compresses the new text to roughly half its original length. Don't add flowery prose to summarise. Summary messages should be *dense* with important facts and information. Include *all* the plausibly-relevant story details from the text within the summary.
  IMPORTANT: Each 'SUMMARY' message must be UNIQUE and distinct from previous summaries. And 'SUMMARY of \[C\]' should include ALL important details from the \[C\] text and *never* invent any details that weren't in the text. Avoid accidentally repeating the events/details from earlier messages/summaries.
  IMPORTANT: The summaries must use short, information-dense sentences to compress the text into the key facts. Summaries should concisely capture *all* the *important* points from the text, compressing the text to about half its original length while retaining all important events/details.
  $output = [this.joinItems("\n")] // joins all of the above lines together

// CAUTION: note to self: don't make this async - must synchronously grab chatlog text due to `temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance` stuff changing chat log text during streaming
injectSummariesAndComputeNextSummariesInBackgroundIfNeeded() =>
  if(!window.summariesReadyToInject) window.summariesReadyToInject = [];
  // inject summaries if we have any:
  if(window.summariesReadyToInject.length > 0) {
    // ensure logs are normalized so our message comparison checks work:
    let allMessagesOriginal = chatLogsEl.value.split(/\n{2,}/g).map(m => m.trim()).filter(m => m);
    let allMessagesNew = allMessagesOriginal.slice(0);
    for(let {summarizedMessages, lastMessageSummarizedIndex, summary, level} of window.summariesReadyToInject) {
      // CAUTION: NO ASYNC ALLOWED HERE. Must all be sync due to temorarily ablation that can happen to chatLogsEl.
      let lastSummarizedMessage = summarizedMessages[summarizedMessages.length-1];
      if(allMessagesOriginal[lastMessageSummarizedIndex] === lastSummarizedMessage) {
        allMessagesNew.splice(lastMessageSummarizedIndex + 1, 0, `SUMMARY^${level}: ${summary}`);
      } else {
        console.warn("Content of last-summmarized-message doesn't match content of message at lastMessageSummarizedIndex. Safe to ignore this warning if logs have been edited since last 'send' button click. This summary will simply be discarded and we'll compute a new one with the up-to-date chat logs.");
      }
    }
    chatLogsEl.value = allMessagesNew.join("\n\n");
    window.summariesReadyToInject = [];
  }
  
  const { countTokens, idealMaxContextTokens } = ai({getMetaObject:true});
  
  const contextLengthToIdeallyStayUnder = idealMaxContextTokens*0.88;
  const numCharsToSummarizeAtATime = 1500; // don't make this bigger without testing - IIRC, the summary calls to the AI could have context too large (causing implicit middle-out ablation) at when the summary hierarchy gets "deep"
  
  // must grab chat logs text synchronously, since chatLogsEl can be temporarily ablated during streaming for rendering performance.
  const chatLogsElText = chatLogsEl.value;
  const messagesWithSummaryReplacements = getMessagesWithSummaryReplacements(chatLogsElText);
  
  let currentlyUsedContextLength = countTokens(messagesWithSummaryReplacements.join("\n\n") + botDescriptionEl.value + userDescriptionEl.value + scenarioEl.value);
  if(currentlyUsedContextLength < contextLengthToIdeallyStayUnder) {
    console.log(`Summarization not needed. currentlyUsedContextLength=${currentlyUsedContextLength} which is less than ${contextLengthToIdeallyStayUnder}`);
    return;
  }
      
  // compute next summary in background if needed:
  (async function() {
    if(window.alreadyDoingSummary) return;
    try {
      window.alreadyDoingSummary = true;
      
      const allMessageObjs = chatLogsElText.split(/\n{2,}/).map(m => m.trim()).filter(m => m).map((text, i) => {
        return {
          text, // note that this `text` is trimmed in the `map` above - very important that we do this kind of normalization for summary replacement stuff, since we do actual string-match replacement.
          index: i,
          level: Number((text.match(/SUMMARY\^([0-9]+):/)||[])[1] || 0)
        };
      });
      
      // conceptually we treat each "level" just like the first.
      // the first level is just a bunch of messages with interspersed "SUMMARY^1: ..." messages, where the summary messages are a summary of the messages before them, up to the *previous* "SUMMARY^1: ..." message.
      // so for the next level, we just delete/ignore the ^0 messages (i.e. the *actual* messages), and do exactly the same thing - i.e. treat "SUMMARY^1: ..." as if they were "messages" and "SUMMARY^2: ..." are the summaries of those "messages".
      
      let summaryLevelToMessageBlocks = new Map();
      let summaryLevelBeingProcessed = 1;
      while(1) {
        // grab messages that are relevant to this 'level' (i.e. only this level and lower one):
        const thisLevelAndPreviousLevelMessageObjs = allMessageObjs.filter(m => m.level === summaryLevelBeingProcessed || m.level === summaryLevelBeingProcessed-1);
        
        if(thisLevelAndPreviousLevelMessageObjs.length === 0) {
          console.log("Finished creating summaryLevelToMessageBlocks.");
          break;
        }
        
        // get all summary 'blocks' (i.e. groups of messages ending with a summary message of this `level` that summarizes them, except for final block which doesn't have a summary at the end)
        const blocks = [];
        let currentBlock = [];
        currentBlock.globalMessageIndices = [];
        for(let m of thisLevelAndPreviousLevelMessageObjs) {
          currentBlock.push(m.text);
          currentBlock.globalMessageIndices.push(m.index); // this is for use in determining summary injection/placement
          if(m.level === summaryLevelBeingProcessed) {
            blocks.push(currentBlock);
            currentBlock = [];
            currentBlock.globalMessageIndices = [];
          }
        }
        if(summaryLevelBeingProcessed === 1 && currentBlock.length === 0) {
          console.warn("final block for summaryLevel==1 should have messages? if it doesn't, then we're maybe summarizing too close to the end of the chat log?");
        }
        blocks.push(currentBlock); // final block doesn't have a summary at the end
        summaryLevelToMessageBlocks.set(summaryLevelBeingProcessed, blocks);
        
        summaryLevelBeingProcessed++;
      }
      
      const summaryLevelBlockEntries = [...summaryLevelToMessageBlocks.entries()].sort((a,b) => a[0]-b[0]); // ascending order
      for(let [summaryLevel, blocks] of summaryLevelBlockEntries) {
        
        // note: a block is just an array of messages, and all of them have a summary message (i.e. higher-level message) at the end EXCEPT the last block - we're in the process of adding that summary message here.
        // but also note: the block has a globalMessageIndices property which is also an array (see above)
        let messagesToSummarizeFromFinalBlock = blocks[blocks.length-1];
        
        // note that we can use numCharsToSummarizeAtATime here even for the first level without worrying about summarizing too close to the end of the chat because we have a currentlyUsedContextLength check before running this summarization process.
        let numCharsInFinalBlock = messagesToSummarizeFromFinalBlock.reduce((a,v) => a+v.length, 0);
        if(numCharsInFinalBlock < numCharsToSummarizeAtATime) { 
          console.log(`summaryLevel=${summaryLevel} doesn't need summarizing yet. numCharsInFinalBlock=${numCharsInFinalBlock}`);
          continue;
        }
      
        // remove messages from last block (which contains all messages after the last summary) until it's a good size for summarization:
        while(1) {
          if(messagesToSummarizeFromFinalBlock.length <= 2) break;
          let numChars = messagesToSummarizeFromFinalBlock.reduce((a,v) => a+v.length, 0);
          if(numChars < numCharsToSummarizeAtATime) break;
          
          // to speed things up, drop latter half if it's way too big:
          if(numChars > numCharsToSummarizeAtATime*10) {
            let halfOfMessagesCount = Math.floor(messagesToSummarizeFromFinalBlock.length/2);
            for(let j = 0; j < halfOfMessagesCount; j++) {
              messagesToSummarizeFromFinalBlock.pop();
              messagesToSummarizeFromFinalBlock.globalMessageIndices.pop();
            }
          } else {
            messagesToSummarizeFromFinalBlock.pop();
            messagesToSummarizeFromFinalBlock.globalMessageIndices.pop(); // this is an array of indices aligned with the messages array, for detemining summary injection location
          }
        }

        if(messagesToSummarizeFromFinalBlock.length === 0) {
          console.error("No messages to summarize??");
          continue;
        }

        let existingSummary = window.summariesReadyToInject.filter(s => s.summarizedMessages.join("\n\n") === messagesToSummarizeFromFinalBlock.join("\n\n"))[0];
        if(existingSummary) {
          console.error("Existing summary hasn't been injected yet?? Should have happened before this code ran.");
          return;
        }
        
        // Note: It may seem brittle to choose an *index* to inject the summary at, but we also check to ensure the previous message matches.
        // And if the text has since been edited, that's fine - the summary just gets thrown away and we re-do it next time the send button is clicked.
        let lastMessageSummarizedIndex = messagesToSummarizeFromFinalBlock.globalMessageIndices[messagesToSummarizeFromFinalBlock.length-1];
        if(messagesToSummarizeFromFinalBlock.globalMessageIndices.length !== messagesToSummarizeFromFinalBlock.length) { console.error("should be one index per message"); return; }
        
        let exampleBlocksForStartWith = blocks.slice(-3, -1);
        let exampleBlockSummaries = exampleBlocksForStartWith.map(b => b[b.length-1]);
        
        // we get all messages for this summary level and above for placement in instruction (i.e. as context to help with summarization):
        let instructionSummaries = getMessagesWithSummaryReplacements(chatLogsElText, {minimumMessageLevel:summaryLevel});
        
        // note that we can't just remove the last two instruction summaries here - they aren't necessarily the same as the summaries from the `exampleBlocksForStartWith` because they may have been 'compressed' into a higher level, so there can actually be no overlap at all.
        // so we need to pop the instructionSummaries off based on the ones that are actually in the example blocks:
        while(1) {
          if(instructionSummaries.length === 0) break;
          if(exampleBlockSummaries.includes(instructionSummaries[instructionSummaries.length-1])) {
            instructionSummaries.pop();
            continue;
          }
          break;
        }
        instructionSummaries = instructionSummaries.map(m => m.replace(/SUMMARY\^[0-9]+:/, "").trim());
        
        let startWithBlocks = exampleBlocksForStartWith.map((block) => ({messages:block.slice(0, -1), summary:block.slice(-1)[0]}));
        startWithBlocks.push({messages:messagesToSummarizeFromFinalBlock, summary:""});
        
        if(messagesToSummarizeFromFinalBlock.join("\n").replaceAll(`SUMMARY^${summaryLevel-1}:`, "").includes("SUMMARY^")) {
          console.error("Should have only been summaryLevel-1 summaries in messagesToSummarizeText. messagesToSummarizeFromFinalBlock:", messagesToSummarizeFromFinalBlock);
        }
        
        let startWith = startWithBlocks.map(({messages, summary}, blockI) => {
          let letterLabel = "";
          if(blockI===0) letterLabel = "[A]";
          if(blockI===1) letterLabel = "[B]";
          if(blockI===2) letterLabel = "[C]";

          let messagesText = messages.map((message, mi) => {
            message = message.replace(`SUMMARY\^${summaryLevel-1}:`, "").replace(`SUMMARY\^${summaryLevel}:`, "").replace(/\n/g, " ").trim();
            return `${summaryLevel === 1 ? `(${mi+1}) ` : ""}${message}`; // we prefix bottom-level messages with numbers, but not SUMMARY^N messages.
          }).join(" ");
          
          summary = summary.replace(`SUMMARY\^${summaryLevel-1}:`, "").replace(`SUMMARY\^${summaryLevel}:`, "").replace(/\n/g, " ").trim();

          return `FULL TEXT of ${letterLabel}: ${messagesText}\nSUMMARY of ${letterLabel}: ${summary}`;
        }).join("\n\n");
        
        // since possible for there to be no blocks before the messages to summarize
        startWith = startWith.trim(); // this is also important to prevent whitespace at end of startWith

        window.summaryMessagesForInstruction = instructionSummaries.length > 0 ? instructionSummaries : ["(None.)"]; // used in summaryPromptInstruction
        let instruction = root.summaryPromptInstruction.evaluateItem;
        window.summaryMessagesForInstruction = null;

        let promptOptions = {
          instruction,
          startWith,
          stopSequences: ["\n\n", "\n---", "FULL TEXT"],
        };

        let data = await root.ai(promptOptions);
        
        if(data.stopReason === "error") continue; // could retry a few times, but this is no big deal, since every message sent triggers another attempt
        
        let summary = data.generatedText.trim().replace(/\n+/g, " ").trim().replace(/---$/, "").replace("FULL TEXT", "").trim();
        if(!summary.trim() || (instructionSummaries[instructionSummaries.length-1] || "").trim() === summary.trim()) {
          // AI has copied the previous summary or gave blank summary, which sometimes happens.
          console.warn("AI copied previous summary or gave empty summary. Skipping this summary level for this 'round'. Summary:", summary);
          continue;
        }
        
        console.log("----------------");
        console.log("----------------");
        console.log("----------------");
        console.log("𝗟𝗘𝗩𝗘𝗟:", summaryLevel);
        console.log("𝗜𝗡𝗦𝗧𝗥𝗨𝗖𝗧𝗜𝗢𝗡:", instruction);
        console.log("𝗦𝗧𝗔𝗥𝗧𝗪𝗜𝗧𝗛:", startWith);
        console.log("𝗦𝗨𝗠𝗠𝗔𝗥𝗬:", summary);
        console.log("----------------");
        console.log("----------------");
        console.log("----------------");
        
        window.summariesReadyToInject.push({summarizedMessages:messagesToSummarizeFromFinalBlock, lastMessageSummarizedIndex, summary, level:summaryLevel});
      }
    } catch(e) {
      console.error(e);
    } finally {
      window.alreadyDoingSummary = false;
    }
  })();


async copyChatTextToClipboardWithoutSummaries() =>
  let text = chatLogsEl.value.split(/\n{2,}/).map(p => p.trim()).filter(p => !p.startsWith("SUMMARY^")).join("\n\n");
  
  await navigator.clipboard.writeText(text);
  copyChatTextWithoutSummariesBtn.textContent = "✅ copied";
  setTimeout(() => {
    copyChatTextWithoutSummariesBtn.textContent = "📋 copy chat logs without summaries";
  }, 3000);


// async copySessionShareLink() =>
//   if(!botNameEl.value.trim() || !userNameEl.value.trim()) {
//     alert("Please add at least the character names before creating a share link.");
//     return;
//   }
//   shareSessionBtn.textContent = "✅ copied!";
//   setTimeout(() => shareSessionBtn.textContent = "🔗 share this chat", 1500);
//   let shareData = {
//     aiChat: getCurrentChatData(),
//   };
//   navigator.clipboard.writeText(`https://perchance.org/${window.generatorName}#${JSON.stringify(shareData)}`);

updateLastMessageButtonsDisplayIfNeeded() =>
  if(localStorage.sendCount && Number(localStorage.sendCount) > 5) {
    // rating buttons only get shown after several messages to reduce clutter for newbies.
    // and when we show rating buttons, we reduce the size of the others so the buttons fit on mobile (since hopefully they know what those buttons do by now, so they don't need the "full" label)
    rateLastMessageCtn.style.display = ""; 
    askForRatingsNoticeEl.style.display = ""; 
    deleteLastMessageBtn.textContent = deleteLastMessageBtn.dataset.shortContent;
    regenMessageBtn.textContent = regenMessageBtn.dataset.shortContent;
  }

async rateLastMessage(rating) =>
  if(!window.lastMessagePendingObj) return;
  
  if(!localStorage.knowsHowRatingsWork) {
    if(!confirm("Your ratings help improve Perchance's AI plugin, which powers this chat. Please do not submit ratings if your chat includes personal info.\n\nContinue?")) return;
    localStorage.knowsHowRatingsWork = "1";
  }
  
  let score = rating==="good" ? 1 : 0;
  rateLastMessageBadBtn.disabled = true;
  rateLastMessageGoodBtn.disabled = true;
  if(rating === "good") {
    rateLastMessageBadBtn.style.opacity = 0.2;
  } else {
    rateLastMessageGoodBtn.style.opacity = 0.2;
  }
  
  if(!window.recentRatingReasonCounts) window.recentRatingReasonCounts = {};
  let reasonCountEntries = Object.entries(window.recentRatingReasonCounts).sort((a,b) => b[1]-a[1]);
  if(reasonCountEntries.length > 10) reasonCountEntries = reasonCountEntries.slice(0, 10);
  window.recentRatingReasonCounts = Object.fromEntries(reasonCountEntries);
  recentRatingReasonsDataList.innerHTML =  reasonCountEntries.map(e => `<option value="${e[0].replace(/</g, "&lt;").replace(/"/g, "&quot;")}"></option>`).join("");
  
  let reasonResolver;
  let reasonFinishPromise = new Promise(r => reasonResolver=r);
  ratingReasonEl.value = "";
  ratingReasonCtn.style.display = "";
  ratingReasonEl.focus();
  await new Promise(r => setTimeout(r, 100));
  
  // if they click anywhere other than the reason input, then we resolve with the current contents of the reason box
  function windowClickHandler(event) {
    if(!ratingReasonCtn.contains(event.target)) {
      reasonResolver(ratingReasonEl.value);
    }
  }
  window.addEventListener("click", windowClickHandler);
  
  // if they press enter, then we resolve too
  function enterKeydownHandler(event) {
    if(event.key === 'Enter') {
      reasonResolver(ratingReasonEl.value);
    }
  }
  ratingReasonEl.addEventListener("keydown", enterKeydownHandler);
  
  let reason = await reasonFinishPromise;
  if(reason.length < 100) window.recentRatingReasonCounts[reason] = (window.recentRatingReasonCounts[reason] || 0) + 1;
  
  ratingReasonCtn.style.display = 'none';
  window.removeEventListener("click", windowClickHandler);
  ratingReasonEl.removeEventListener("keydown", enterKeydownHandler);
  window.lastMessagePendingObj.submitUserRating({score, reason});
  
  

saveChatDataToUsersDevice() => 
  let data = getCurrentChatData();
  let suggestedName = (data.userName+"-"+data.botName).replace(/[^a-zA-Z0-9\-_]/g, "");
  let filename = prompt("Choose a filename:", window.lastSaveFilenameUsed || suggestedName);
  if(filename === null) return;
  if(filename !== suggestedName) window.lastSaveFilenameUsed = filename;
  filename += ".ai-chat.json";
  let blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  let url = URL.createObjectURL(blob);
  let a = document.createElement('a');
  a.href = url;
  a.download = filename;
  a.click();
  URL.revokeObjectURL(url);
  
async loadChatDataFromUsersDevice() =>
  return new Promise((resolve, reject) => {
    let input = document.createElement('input');
    input.type = 'file';
    input.accept = 'application/json, text/json, text/plain, application/JSON, .json, application/x-gzip, application/gzip, .gz, .json.gz';

    input.onchange = async (event) => {
      let file = event.target.files[0];
      
      let errorAppend = "";
      
      if (file) {
        try {
          let blob;
          if(file.name.endsWith(".gz") || file.type.endsWith("gzip")) {
            blob = await decompressBlobWithGzip(file);
          } else {
            blob = file;
          }
          
          let content = await blob.text();
          if(content.includes("chatbot-ui-v1")) errorAppend = ` Perchance has multiple AI chat interfaces (since anyone can build their own). You may be trying to load a save file in the wrong interface. You're currently using perchance.org/𝗮𝗶-𝗰𝗵𝗮𝘁\n\n👉 perchance.org/ai-character-chat is another popular interface.`;
          
          let data = JSON.parse(content);
          if(data.format === "perchance-ai-chat-v1") {
            if((botDescriptionEl.value+userDescriptionEl.value+scenarioEl.value+chatLogsEl.value+writingInstructionsEl.value).trim() !== "") {
              let confirmed = confirm("Loading this data will 𝗼𝘃𝗲𝗿𝘄𝗿𝗶𝘁𝗲 your current chat. Continue?");
              if(!confirmed) return;
            }
            loadDataIntoTextAreasAndLocalStorage(data);
          } else {
            alert("Unknown save file format."+errorAppend);
          }
        } catch (error) {
          alert("There was an error while loading that chat file."+errorAppend);
          reject(error);
        }
      }
    };

    input.click();
  });

setMiscDataDefaults() =>
  if(!window.miscData.deletedCharacterNames) window.miscData.deletedCharacterNames = [];

loadDataIntoTextAreasAndLocalStorage(data) =>
  // put data in input boxes + variables:
  botNameEl.value = data.botName;
  userNameEl.value = data.userName;
  botDescriptionEl.value = data.botDescription;
  userDescriptionEl.value = data.userDescription;
  scenarioEl.value = data.scenario;
  chatLogsEl.value = data.chatLogs;
  whatHappensNextEl.value = data.whatHappensNext ?? "";
  writingInstructionsEl.value = data.writingInstructions ?? "";
  
  backgroundImageUrlEl.value = data.backgroundImageUrl ?? "";
  botAvatarUrlEl.value = data.botAvatarUrl ?? "";
  userAvatarUrlEl.value = data.userAvatarUrl ?? "";
  backgroundAudioUrlEl.value = data.backgroundAudioUrl ?? "";
  
  window.miscData = data.miscData || {};
  
  // put data in localstorage:
  localStorage.botName = data.botName;
  localStorage.userName = data.userName;
  localStorage.botDescription = data.botDescription;
  localStorage.userDescription = data.userDescription;
  localStorage.botAvatarUrl = data.botAvatarUrl ?? "";
  localStorage.userAvatarUrl = data.userAvatarUrl ?? "";
  localStorage.scenario = data.scenario;
  localStorage.backgroundImageUrl = data.backgroundImageUrl ?? "";
  localStorage.backgroundAudioUrl = data.backgroundAudioUrl ?? "";
  localStorage.chatLogs = data.chatLogs;
  localStorage.whatHappensNext = data.whatHappensNext ?? "";
  localStorage.writingInstructions = data.writingInstructions;
  
  if(data.miscData) {
    try { localStorage.miscData = JSON.stringify(data.miscData); } catch(e) { console.error(e); }
  }
  setMiscDataDefaults();
  updateCharacterNameViews();
  
  updateBackgroundImageDisplay();
  updateBackgroundAudioPlayer();
  
  updateAvatarImageDisplay({charLabel:'bot', url:localStorage.botAvatarUrl});
  updateAvatarImageDisplay({charLabel:'user', url:localStorage.userAvatarUrl});
  
  checkOverlyLongFixedTokens();
  
  try { if(window.applyChatLogSyntaxHighlighting) window.applyChatLogSyntaxHighlighting(); } catch(e) { console.error(e); }
  
  userDescriptionEl.scrollTop = 0;
  botDescriptionEl.scrollTop = 0;
  scenarioEl.scrollTop = 0;
  chatLogsEl.scrollTop = chatLogsEl.scrollHeight;


async generateShareLinkForChat() => 
  if(!confirm("This will save the current chat data as a sharable link/URL. It's a private URL that's only accessible to others if you give them the link. Continue?")) return;
  
  if(!window.CompressionStream) {
    alert("Share links use a feature that's only available in modern browsers. Please upgrade your browser to the latest version to use this feature.");
    return;
  }
  if((botDescriptionEl.value+userDescriptionEl.value+scenarioEl.value).trim() === "") {
    alert("You need to add character descriptions and a scenario before sharing.");
    return;
  }
  shareLinkCtn.style.display = "none";
  
  shareChatBtn.disabled = true;
  shareChatBtn.textContent = "⏳ uploading current chat data...";
  
  let chatDataJson = JSON.stringify(getCurrentChatData());
  
  // convert json text to blob:
  chatDataJson = chatDataJson.replace(/#/g, "%23"); // since hash is a special character in dataurls (like normal URLs)
  let blob = await fetch("data:text/plain;charset=utf-8,"+chatDataJson).then(res => res.blob());
  
  // compress blob:
  let compressedBlob = await compressBlobWithGzip(blob);
  
  let { url, size, error } = await upload(compressedBlob);
  if(error) {
    shareChatLinkInputEl.value = `error: ${error}`;
  } else {
    shareChatLinkInputEl.value = `https://perchance.org/${window.generatorName}#data=`+url.replace("https://user-uploads.perchance.org/file/", "uup1:");
  }
  shareLinkCtn.style.display = "";
  shareChatBtn.textContent = "🔗 share this chat";
  shareChatBtn.disabled = false;
  shareChatBtn.style.display = "none";

async compressBlobWithGzip(blob) =>
  const cs = new CompressionStream('gzip');
  const compressedStream = blob.stream().pipeThrough(cs);
  let outputBlob = await new Response(compressedStream).blob();
  return new Blob([outputBlob], { type: "application/gzip" }); // <-- to add the correct mime type
 
async loadDataFromUrlHash() => 
  let success = false;
  if(!window.DecompressionStream) {
    alert("Character share links use a browser feature that's only available in modern browsers. Please upgrade your browser to the latest version to allow for loading data from character share links.");
    return {success, error:"browser_compat"};
  }
  
  let loadingModal = document.createElement('div');
  loadingModal.innerHTML = `<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 9999; display: flex; justify-content: center; align-items: center;">
    <div style="padding: 20px; background-color: var(--box-color); border-radius: 8px;">
      ⏳ loading chat data...
    </div>
  </div>`;
  loadingModal = loadingModal.firstElementChild;
  document.body.append(loadingModal);
  
  try {
    let hashText = window.location.hash.slice(1);
    if(!hashText.startsWith("data=")) {
      throw new Error("Invalid share URL.");
    }
    let fileUrl = hashText.replace(/^data=/, "");
    if(fileUrl.startsWith("uup1:")) {
      fileUrl = fileUrl.replace("uup1:", "https://user-uploads.perchance.org/file/");
    }
    let fetchOptions = {};
    if(window.AbortSignal && AbortSignal.timeout) fetchOptions.signal = AbortSignal.timeout(10000);
    let blob = await fetch(fileUrl, fetchOptions).then(res => res.blob());
    let text;
    if(fileUrl.endsWith(".gz")) {
      let decompressedBlob = await decompressBlobWithGzip(blob);
      text = await decompressedBlob.text();
    } else {
      text = await blob.text();
    }
    let data = JSON.parse(text);
    if(data.format === "perchance-ai-chat-v1") {
      if(( (localStorage.botDescription||"") + (localStorage.userDescription||"") + (localStorage.scenario||"") + (localStorage.chatLogs||"") + (localStorage.writingInstructions||"") ).trim() !== "") {
        let confirmed = confirm("𝗛𝗲𝗮𝗱𝘀 𝘂𝗽: You're loading a chat share link. This will overwrite your existing chat. 𝗖𝗼𝗻𝘁𝗶𝗻𝘂𝗲?\n\n(Note: You can click cancel and then load the share link in your browser's incognito/private mode to avoid overwriting your current chat, or just save your existing chat first.)");
        if(!confirmed) {
          loadingModal.remove();
          return {success, error:"loading_cancelled_by_user"};
        }
      }
      loadDataIntoTextAreasAndLocalStorage(data);
      success = true;
    } else {
      alert("Unknown chat data format.");
    }
  } catch(e) {
    alert(`Failed to load chat data: ${e.message}`);
    console.error(e);
  }
  
  loadingModal.remove();
  return {success};
  
async decompressBlobWithGzip(blob) =>
  const ds = new DecompressionStream("gzip");
  const decompressedStream = blob.stream().pipeThrough(ds);
  return await new Response(decompressedStream).blob();

getCurrentChatData() =>
  return {
    format: "perchance-ai-chat-v1",
    botName: botNameEl.value.trim(),
    userName: userNameEl.value.trim(),
    botDescription: botDescriptionEl.value.trim(),
    userDescription: userDescriptionEl.value.trim(),
    botAvatarUrl: botAvatarUrlEl.value.trim(),
    userAvatarUrl: userAvatarUrlEl.value.trim(),
    scenario: scenarioEl.value.trim(),
    backgroundAudioUrl: backgroundAudioUrlEl.value.trim(),
    backgroundImageUrl: backgroundImageUrlEl.value.trim(),
    chatLogs: chatLogsEl.value.trim(),
    whatHappensNext: whatHappensNextEl.value.trim(),
    writingInstructions: writingInstructionsEl.value.trim(),
    miscData: window.miscData,
  };

deleteLastMessage() =>
  let messages = chatLogsEl.value.trim().split(/\n{2,}/);
  
  // save page scroll so we can recover it (else Chrome Android's auto page-shift-prevention messes with page scroll):
  let prevPageScrollTop = document.scrollingElement.scrollTop;
  chatLogsEl.scrollTop = 999999999;
    
  chatLogsEl.value = messages.slice(0, -1).join('\n\n');
  
  // recover page scroll:
  document.scrollingElement.scrollTop = prevPageScrollTop;
  
  window.lastMessageDeleted = messages.at(-1);
  undoDeleteLastMessageCtn.style.display = "";
  clearTimeout(window.hideUndoDeleteLastMessageCtnTimeout);
  window.hideUndoDeleteLastMessageCtnTimeout = setTimeout(() => {
    undoDeleteLastMessageCtn.style.display = "none";
  }, 5000);
  updateDeleteButtonVisibility();
  regenPrevButton.disabled = true;
  regenNextButton.disabled = true;
  window.nextMessageDraftOrInstruction_prevMessage = null;
  
undoDeleteLastMessage() =>
  chatLogsEl.value += '\n\n'+window.lastMessageDeleted;
  undoDeleteLastMessageCtn.style.display = 'none';
  updateRegenPrevNextButtonVisibility();

simpleHash(str) =>
  let sum = 0;
  for(let i = 0; i < str.length; i++) {
    sum = Math.imul(31, sum) + str[i].charCodeAt(0) | 0;
  }
  return sum;
  
updateRegenPrevNextButtonVisibility() =>
  // note that this will clear `window.currentRegenAlternatives` if current context doesn't match its associated `window.currentRegenContextHash`
  regenPrevButton.disabled = true;
  regenNextButton.disabled = true;
  let messages = chatLogsEl.value.trim().split(/\n{2,}/);
  let currentLastMessage = messages.at(-1);
  let currentContextMessagesText = messages.slice(0, -1).join('\n\n');
  let currentRegenContextHash = simpleHash(currentContextMessagesText);
  if(currentRegenContextHash !== window.currentRegenContextHash) {
    window.currentRegenAlternatives = [currentLastMessage];
    window.currentRegenContextHash = currentRegenContextHash;
  } else {
    let currentLastMessageRegenIndex = window.currentRegenAlternatives.findIndex(m => m === currentLastMessage);
    if(currentLastMessageRegenIndex === -1) {
      console.error("this shouldn't happen? because the hash matched, so it should at least have the current message in the window.currentRegenAlternatives array");
    } else {
      if(currentLastMessageRegenIndex > 0) regenPrevButton.disabled = false;
      if(currentLastMessageRegenIndex < window.currentRegenAlternatives.length-1) regenNextButton.disabled = false;
    }
  }

async regenLastMessage() =>
  regenMessageBtn.disabled = true;
  if(window.nextMessageDraftOrInstruction_prevMessage && inputEl.value.trim() === "") {
    inputEl.value = window.nextMessageDraftOrInstruction_prevMessage;
  }

  let messages = chatLogsEl.value.trim().split(/\n{2,}/);
  let currentLastMessage = messages.at(-1);
  let currentRegenContextHash;
  if(window.mostRecentChatLogEditWasAContinuationGeneration) {
    // if last gen was a continuation, then the hash/id of that "regen alternative" must use use the partial content of the final message too (which was used as context):
    currentRegenContextHash = simpleHash(window.mostRecentGenerationContinuationChatLogContextText);
  } else {
    currentRegenContextHash = simpleHash(messages.slice(0, -1).join('\n\n'));
  }
  if(currentRegenContextHash !== window.currentRegenContextHash) {
    window.currentRegenAlternatives = [currentLastMessage]; // note that it's correct to use currentLastMessage even if this is a continuation, since the full last message was the result of the regen either way
    window.currentRegenContextHash = currentRegenContextHash;
  }

  // save page scroll so we can recover it (else Chrome Android's auto page-shift-prevention messes with page scroll):
  let prevPageScrollTop = document.scrollingElement.scrollTop;
  chatLogsEl.scrollTop = 999999999;
  
  if(window.mostRecentChatLogEditWasAContinuationGeneration) {
    chatLogsEl.value = window.mostRecentGenerationContinuationChatLogContextText;
  } else {
    let {name, content} = getLastMessage();
    chatLogsEl.value = messages.slice(0, -1).join('\n\n');
    chatLogsEl.value += "\n\n" +  name + ":";
    chatLogsEl.value = chatLogsEl.value.trim();
  }

  // recover page scroll:
  document.scrollingElement.scrollTop = prevPageScrollTop;

  if(window.mostRecentChatLogEditWasAContinuationGeneration) {
    // we're regen-ing the continuation, not the whole last message:
    await handleSendButtonClick({mode:'continue'});
  } else {
    await handleSendButtonClick({mode:"regen"});
  }
  {
    let {name, content} = getLastMessage();
    window.currentRegenAlternatives.push(`${name}: ${content}`);
  }
  regenPrevButton.disabled = false;
  regenNextButton.disabled = true;
  regenMessageBtn.disabled = false;
  
prevRegenMessage() =>
  let messages = chatLogsEl.value.trim().split(/\n{2,}/);
  let currentLastMessage = messages.at(-1);
  let currentLastMessageRegenIndex = window.currentRegenAlternatives.findIndex(m => m === currentLastMessage);
  if(currentLastMessageRegenIndex === 0) {
    console.error("tried to go to prev index when it was already zero");
    return;
  }
  
  // save page scroll so we can recover it (else Chrome Android's auto page-shift-prevention messes with page scroll):
  let prevPageScrollTop = document.scrollingElement.scrollTop;
  chatLogsEl.scrollTop = 999999999;
  
  chatLogsEl.value = (messages.slice(0, -1).join('\n\n') + "\n\n" + window.currentRegenAlternatives[currentLastMessageRegenIndex-1]).trim();
  
  // recover page scroll:
  document.scrollingElement.scrollTop = prevPageScrollTop;
  
  if(currentLastMessageRegenIndex-1 === 0) {
    regenPrevButton.disabled = true;
  }
  regenNextButton.disabled = false;
  
nextRegenMessage() =>
  let messages = chatLogsEl.value.trim().split(/\n{2,}/);
  let currentLastMessage = messages.at(-1);
  let currentLastMessageRegenIndex = window.currentRegenAlternatives.findIndex(m => m === currentLastMessage);
  if(currentLastMessageRegenIndex === window.currentRegenAlternatives.length-1) {
    console.error("tried to go to next index when it was already max");
    return;
  }
  
  // save page scroll so we can recover it (else Chrome Android's auto page-shift-prevention messes with page scroll):
  let prevPageScrollTop = document.scrollingElement.scrollTop;
  chatLogsEl.scrollTop = 999999999;
  
  chatLogsEl.value = (messages.slice(0, -1).join('\n\n') + "\n\n" + window.currentRegenAlternatives[currentLastMessageRegenIndex+1]).trim();
  
  // recover page scroll:
  document.scrollingElement.scrollTop = prevPageScrollTop;
  
  if(currentLastMessageRegenIndex+1 === window.currentRegenAlternatives.length-1) {
    regenNextButton.disabled = true;
  }
  regenPrevButton.disabled = false;
  
undoRegenLastMessage() =>
  if(!window.lastMessageDeletedForRegen) {
    console.error("Tried to undo regen when we had already undone it?")
    return;
  }
  let messages = chatLogsEl.value.trim().split(/\n{2,}/);
  window.lastMessageDeletedForRegen = null;
  chatLogsEl.value = messages.slice(0, -1).join('\n\n');
  chatLogsEl.value += '\n\n'+window.lastMessageDeletedForRegen;
  undoRegenMessageCtn.style.display = 'none';
  
  try { if(window.applyChatLogSyntaxHighlighting) window.applyChatLogSyntaxHighlighting(); } catch(e) { console.error(e); }

loadChatDataFromLocalStorage() =>
  // Notice that we have oninput="localStorage.blah=this.value" on the input boxes.
  // That saves their value to localStorage whenever they are changed.
  // So during the initial page load, we load those values from localStorage if they exist.
  // NOTE: You could simply use `perchance.org/remember-plugin` like `[remember(root, "@inputs")]` rather than this big mess.
  if(localStorage.userName) userNameEl.value = localStorage.userName;
  if(localStorage.botName) botNameEl.value = localStorage.botName;
  if(localStorage.botDescription) botDescriptionEl.value = localStorage.botDescription;
  if(localStorage.userDescription) userDescriptionEl.value = localStorage.userDescription;
  if(localStorage.scenario) scenarioEl.value = localStorage.scenario;
  if(localStorage.chatLogs) chatLogsEl.value = localStorage.chatLogs;
  if(localStorage.whatHappensNext) whatHappensNextEl.value = localStorage.whatHappensNext;
  if(localStorage.writingInstructions) writingInstructionsEl.value = localStorage.writingInstructions;
  if(localStorage.input) inputEl.value = localStorage.input;
  
  if(localStorage.userAvatarUrl) userAvatarUrlEl.value = localStorage.userAvatarUrl;
  if(localStorage.botAvatarUrl) botAvatarUrlEl.value = localStorage.botAvatarUrl;
  if(localStorage.backgroundImageUrl) backgroundImageUrlEl.value = localStorage.backgroundImageUrl;
  if(localStorage.backgroundAudioUrl) backgroundAudioUrlEl.value = localStorage.backgroundAudioUrl;
  
  // for random small data:
  window.miscData = {};
  if(localStorage.miscData) {
    try { window.miscData = JSON.parse(localStorage.miscData); } catch(e) { console.error(e); localStorage.miscData = "{}"; }
  }
  // set defaults:
  setMiscDataDefaults();
  
  try { if(window.applyChatLogSyntaxHighlighting) window.applyChatLogSyntaxHighlighting(); } catch(e) { console.error(e); }

triggerTipIfNeeded() =>
  if(!localStorage.tipsSeen) localStorage.tipsSeen = "";
  // can add tip based on e.g. text that is at the end of the chat logs, or whatever you want.
  // here I'm just using sendCount as a simple way to add "introduction" tips as they go.
  let sendCount = Number(localStorage.sendCount);
  {
    let tipName = "editInitialMessages1";
    if(sendCount === 5 && !localStorage.tipsSeen.includes(`|${tipName}|`)) {
      tipMessageEl.innerHTML = `Tip: A character's first few messages will heavily influence the way that character talks for the rest of the conversation. If the character isn't talking in the style you want, be sure to click the message and edit it to the desired style.`;
      tipEl.style.display = "";
      localStorage.tipsSeen += `|${tipName}|`;
    }
  }
  {
    let tipName = "repetition1";
    if(sendCount === 40 && !localStorage.tipsSeen.includes(`|${tipName}|`)) {
      tipMessageEl.innerHTML = `Tip: You can edit any message by simply clicking on it. If you let the AI write badly, its quality will become worse as the chat goes on, so <b>always edit or delete the messages that you don't like</b>.`;
      tipEl.style.display = "";
      localStorage.tipsSeen += `|${tipName}|`;
    }
  }
  {
    if(sendCount > 30 && !localStorage.haveUsedTabToContinueMessage) {
      let isTouchScreen = false;
      try { isTouchScreen = window.matchMedia("(pointer: coarse)").matches; } catch(e) { console.error(e); }
      if(window.innerWidth > window.innerHeight && !isTouchScreen) {
        continueMessageBtnTabLabel.style.display = "";
      }
    }
  }


getRecentCharacterNames() =>
  let recentMessages = chatLogsEl.value.replace(/\{\{user\}\}/gi, userName).replace(/\{\{char\}\}/gi, botName).trim().split(/\n{2,}/).slice(-600).filter(m => m.trim());
  let recentNames = recentMessages.filter(m => m.includes(":")).map(m => m.trim().split(":")[0].trim());
  recentNames.push(botName.evaluateItem);
  recentNames.push(userName.evaluateItem);
  recentNames.push("Narrator");
  recentNames = recentNames.filter(n => n.length < 50); // in case of e.g. a manually-added paragraph of writing between messages (i.e. that doesn't have a name at the start, but happens to have a colon)
  // let uniqueNames = Array.from(new Set(recentNames));
  // let uniqueNames = Object.entries(nameHist).sort((a,b) => b[1]-a[1]).map(e => e[0]);
  let nameHist = recentNames.reduce((a,v) => (a[v]=(a[v]||0)+1, a), {});
  // sort them by number first, and then if their number is within 10 of another (to prevent constant order swapping with each new message submitted), use alphabetical
  let sortedUniqueNames = Object.entries(nameHist).map(e => [e[0], Math.round(e[1]/10)]).sort(([aName, aCount], [bName, bCount]) => bCount - aCount || aName.localeCompare(bName)).map(([name]) => name);
  sortedUniqueNames = sortedUniqueNames.filter(n => !n.startsWith("SUMMARY^") && n.toLowerCase() !== "ooc" && n.toLowerCase() !== "(ooc");
  return sortedUniqueNames;

updateCharacterNameViews() =>
  let recentCharacterNames = getRecentCharacterNames();
  recentCharacterNames = recentCharacterNames.filter(n => !window.miscData.deletedCharacterNames.includes(n));
  quickReplyButtonsCtn.innerHTML = recentCharacterNames.map(n => `<button data-name="${n.replace(/"/g, "&quot;")}" style="font-size:75%;">🗣️ ${n}</button>`).join("");
  quickReplyButtonsCtn.querySelectorAll("button").forEach(btn => {
    btn.onclick = function() {
      chatLogsEl.value = chatLogsEl.value.trim();
      if(!chatLogsEl.value.endsWith('\n\n'+this.dataset.name+':')) {
        chatLogsEl.value += '\n\n'+this.dataset.name+':';
      }
      handleSendButtonClick({mode:"normal"});
    };
  });
  let addCharBtn = document.createElement("button");
  addCharBtn.style.cssText = "font-size:75%; min-width:1.9rem;";
  addCharBtn.textContent = "+";
  addCharBtn.title = "Add a character";
  addCharBtn.onclick = function() {
    let characterName = prompt("Enter the name of the new character that you'd like to enter the chat. You can describe extra characters in the 'Scenario' box if needed.");
    if(characterName === null || characterName.trim() === "") return;
    characterName = characterName.trim();
    if(window.miscData.deletedCharacterNames.includes(characterName)) {
      window.miscData.deletedCharacterNames = window.miscData.deletedCharacterNames.filter(n => n !== characterName);
      localStorage.miscData = JSON.stringify(window.miscData);
      updateCharacterNameViews();
    }
    chatLogsEl.value = chatLogsEl.value.trim();
    chatLogsEl.value += '\n\n'+characterName.trim()+':';
    handleSendButtonClick({mode:"normal"});
    chatLogsDeleteBtn.dataset.mode = 'delete';
  };
  quickReplyButtonsCtn.append(addCharBtn);
  
  let removeCharBtn = document.createElement("button");
  removeCharBtn.style.cssText = "font-size:75%;";
  removeCharBtn.textContent = "🗑️";
  removeCharBtn.title = "Remove a button (revolutionary)";
  removeCharBtn.onclick = function() {
    let characterName = prompt("Enter the name of the character button that you'd like to remove.");
    if(characterName === null || characterName.trim() === "") return;
    characterName = characterName.trim();
    if(!window.miscData.deletedCharacterNames.includes(characterName)) {
      window.miscData.deletedCharacterNames.push(characterName);
      localStorage.miscData = JSON.stringify(window.miscData);
      updateCharacterNameViews();
    }
  };
  quickReplyButtonsCtn.append(removeCharBtn);
  
  // update the "send as" <select> element, ensuring user character is first by default, and preserving previously-selected value if that character still 'exists'
  let userNameEvaluated = userName.evaluateItem;
  let existingSendAsValue = sendAsCharacterSelectEl.value;
  sendAsCharacterSelectEl.innerHTML = [userNameEvaluated, ...recentCharacterNames.filter(n => n !== userNameEvaluated)].map(n => `<option>${n}</option>`).join("");
  sendAsCharacterSelectEl.innerHTML += `<option value="~~~NEW_CHAR~~~">𝗡𝗲𝘄...</option>`;
  if(recentCharacterNames.includes(existingSendAsValue)) sendAsCharacterSelectEl.value = existingSendAsValue;
  
  document.querySelectorAll(".containsCharName").forEach(el => update(el));
  

updateDeleteButtonVisibility() =>
  botDescriptionDeleteBtn.style.display = botDescriptionEl.value.trim() ? "" : "none";
  userDescriptionDeleteBtn.style.display = userDescriptionEl.value.trim() ? "" : "none";
  scenarioDeleteBtn.style.display = scenarioEl.value.trim() ? "" : "none";
  chatLogsDeleteBtn.style.display = chatLogsEl.value.trim() ? "" : "none";
  writingInstructionsDeleteBtn.style.display = writingInstructionsEl.value.trim() ? "" : "none";

generateCharacterDescription(botOrUser, buttonEl) =>
  if(buttonEl.dataset.currentlyGenerating) {
    buttonEl.stopGeneration();
    return;
  }
  let descriptionEl = botOrUser=="bot" ? botDescriptionEl : userDescriptionEl;
  let nameEl = botOrUser=="bot" ? botNameEl : userNameEl;
  
  if(botOrUser=="bot") botDescriptionDeleteBtn.dataset.mode='delete';
  else userDescriptionDeleteBtn.dataset.mode='delete';
  
  let warningText = "";
  if(descriptionEl.value.trim() !== "") warningText = "The existing description will be cleared. ";
  let inspiration = prompt(`${warningText}Type some keywords or ideas below (optional), and then click OK.`, window["lastCharacterDescriptionInspirationIdea_"+botOrUser] || "");
  if(inspiration === null) return; // <-- they clicked cancel
  window["lastCharacterDescriptionInspirationIdea_"+botOrUser] = inspiration;
  // buttonEl.disabled = true;
  buttonEl.textContent = "🛑 stop";
  descriptionEl.value = "";
  
  let originalDescriptionElPlaceholder = descriptionEl.placeholder;
  descriptionEl.placeholder = "Loading...";
  
  let loadingIndicatorEl = document.createElement("div");
  
  let startWith = "Name:";
  if(nameEl.value.trim()) startWith = `Name: ${nameEl.value.trim()}\nAppearance:`;
  let responseObj = ai({
    instruction: `Write a creative, interesting, description of a character using the following keywords/prompt/ideas as inspiration: ${inspiration || "(None provided. Just be creative!)"}\n\nYour description should include the name, age, appearance, personality, and character background. Keep it short. Be creative!`,
    startWith,
    onChunk: function(data) {
      descriptionEl.value += data.textChunk;
      descriptionEl.scrollTop = 99999999; // scroll down to bottom of text box
    },
    onFinish: function(data) {
      buttonEl.textContent = "✨ generate";
      buttonEl.dataset.currentlyGenerating = "";
      if(botOrUser=="bot") localStorage.botDescription = botDescriptionEl.value;
      else localStorage.userDescription = userDescriptionEl.value;
      updateDeleteButtonVisibility();
      loadingIndicatorEl.remove();
      descriptionEl.placeholder = originalDescriptionElPlaceholder;
      
      if(!nameEl.value.trim() && /^Name: .+/.test(data.text.trim())) {
        let name = data.text.trim().split("\n").map(t => t.trim()).filter(l => l.startsWith("Name: "))[0];
        if(name) {
          nameEl.value = name.replace(/^Name: /, "");
          if(botOrUser=="bot") localStorage.botName = nameEl.value;
          else localStorage.userName = nameEl.value;
        }
      }
      
      if(botOrUser=="bot") resizeTextAreaHeightToFitContent(botDescriptionEl, {min:100});
      else resizeTextAreaHeightToFitContent(userDescriptionEl, {min:100});
    },
  });
  
  loadingIndicatorEl.innerHTML = responseObj.loadingIndicatorHtml;
  loadingIndicatorEl.style.cssText = `position:absolute; bottom:0.5rem; right:0.5rem; width:min-content; height:min-content;`;
  buttonEl.closest(".charDescriptionCtn").append(loadingIndicatorEl);
  
  buttonEl.dataset.currentlyGenerating = "1";
  buttonEl.stopGeneration = function() {
    responseObj.stop();
  };



generateScenarioDescription(buttonEl) =>
  if(buttonEl.dataset.currentlyGenerating) {
    buttonEl.stopGeneration();
    return;
  }
  if(botNameEl.value.trim() === "" || userNameEl.value.trim() === "" || botDescriptionEl.value.trim() === "" || userDescriptionEl.value.trim() === "") {
    alert("Please fill in character names and descriptions first.");
    return;
  }
  let warningText = "";
  if(scenarioEl.value.trim() !== "") warningText = "The existing scenario description will be cleared. ";
  window.scenarioInspiration = prompt(`${warningText}Type some keywords or ideas below (optional), and then click OK.`, window.lastScenarioInspirationIdea);
  if(window.scenarioInspiration === null) return; // <-- they clicked cancel
  window.lastScenarioInspirationIdea = window.scenarioInspiration;
  // buttonEl.disabled = true;
  buttonEl.textContent = "🛑 stop";
  scenarioEl.value = "";
  
  let originalScenarioElPlaceholder = scenarioEl.placeholder;
  scenarioEl.placeholder = "Loading...";
  
  let loadingIndicatorEl = document.createElement("div");
  
  let responseObj = ai({
    instruction: scenarioGenerationPrompt.evaluateItem,
    startWith: "The scenario begins with",
    stopSequences: ["\n\n"],
    onChunk: function(data) {
      scenarioEl.value += data.textChunk;
      scenarioEl.scrollTop = 99999999; // scroll down to bottom of text box
    },
    onFinish: function(data) {
      buttonEl.disabled = false;
      buttonEl.textContent = "✨ generate";
      buttonEl.dataset.currentlyGenerating = "";
      localStorage.scenario = scenarioEl.value;
      updateDeleteButtonVisibility();
      resizeTextAreaHeightToFitContent(scenarioEl);
      loadingIndicatorEl.remove();
      scenarioEl.placeholder = originalScenarioElPlaceholder;
    },
  });
  
  loadingIndicatorEl.innerHTML = responseObj.loadingIndicatorHtml;
  loadingIndicatorEl.style.cssText = `position:absolute; bottom:0.5rem; right:0.5rem; width:min-content; height:min-content;`;
  scenarioAreaCtn.append(loadingIndicatorEl);
  
  buttonEl.dataset.currentlyGenerating = "1";
  buttonEl.stopGeneration = function() {
    responseObj.stop();
  };
  

resizeTextAreaHeightToFitContent(textArea, opts) =>
  if(!opts) opts = {};
  let pageScrollTop = document.scrollingElement.scrollTop;
  textArea.style.height = 0+"px";
  let height = textArea.scrollHeight+10;
  if(opts.min !== undefined && height < opts.min) {
    height = opts.min;
  }
  textArea.style.height = `${height}px`;
  document.scrollingElement.scrollTop = pageScrollTop; // otherwise chrome's crappy anti-layout-shift heuristics messes with overall page scroll


async generateCharactersAndScenario() =>
  let inspiration = prompt("Enter a few keywords/ideas and the AI will use them to generate the characters and the scenario:", window.lastCharactersAndScenarioInspirationIdea);
  if(inspiration === null) return;
  
  window.lastCharactersAndScenarioInspirationIdea = inspiration;
  inspiration = inspiration.trim();
  let inspirationInstruction = inspiration ? "You MUST use these instructions/ideas as inspiration: "+inspiration+"\nTry to make your best guess at the intention/idea behind these user-provided instructions/ideas, and then invent a creative and fascinating scenario based on those intentions." : "";

  let fullInstruction = charactersAndScenarioGenerationPrompt.evaluateItem.replace("##outroInspirationPlaceholder##", inspirationInstruction);
  if(inspiration) fullInstruction = fullInstruction.replace("##introInspirationPlaceholder##", `The characters and scenario must be an enthralling and creative interpretation of these instructions: **`+inspiration+`**\nDon't just repeat text from the above instructions verbatim in your response - you must instead creatively interpret the above instructions.`);

  botDescriptionDeleteBtn.dataset.mode = 'delete';
  userDescriptionDeleteBtn.dataset.mode = 'delete';
  scenarioDeleteBtn.dataset.mode = 'delete';
  
  generateCharactersAndScenarioBtn.disabled = true;
  generateCharactersAndScenarioBtn.textContent = "⌛ loading...";
  
  function render(text) {
    let lines = text.split("\n").map(l => l.trim()).filter(l => l.includes(":"));
    let char1 = lines.find(l => l.startsWith("CHARACTER 1 NAME:"))?.trim().split(":").slice(1).join(":").trim();
    let char2 = lines.find(l => l.startsWith("CHARACTER 2 NAME:"))?.trim().split(":").slice(1).join(":").trim();
    let desc1 = lines.find(l => l.startsWith("CHARACTER 1 DESCRIPTION:"))?.trim().split(":").slice(1).join(":").trim();
    let desc2 = lines.find(l => l.startsWith("CHARACTER 2 DESCRIPTION:"))?.trim().split(":").slice(1).join(":").trim();
    let scenario = text.split("STARTING SCENARIO:")[1]?.trim().replace(/\n+/g, "\n\n").replace(/GENRE:\s*/, "").trim();
    botNameEl.value = char1 || "";
    userNameEl.value = char2 || "";
    botDescriptionEl.value = desc1 || "";
    userDescriptionEl.value = desc2 || "";
    scenarioEl.value = scenario || "";
    resizeTextAreaHeightToFitContent(botDescriptionEl, {min:120});
    resizeTextAreaHeightToFitContent(userDescriptionEl, {min:120});
    resizeTextAreaHeightToFitContent(scenarioEl, {min:120});
    document.querySelectorAll(".containsCharName").forEach(el => update(el));
  }
  
  let outputLength = 0;
  let seenStartingScenario = false;
  
  let responseObj = ai({
    instruction: fullInstruction,
    startWith: "CHARACTER 1 NAME:",
    stopSequences: ["GENRE:"],
    onChunk: function(data) {
      outputLength += data.textChunk.length;
      generateCharactersAndScenarioBtn.textContent = `⌛ ${Math.round(outputLength/5)} words...`; // just an approximation
      try { render(data.fullTextSoFar); } catch(e) { console.error(e); }
    },
  });
  generateCharactersAndScenarioLoaderEl.innerHTML = responseObj.loadingIndicatorHtml;
  
  stopCharAndScenarioGenBtn.style.display = "block";
  stopCharAndScenarioGenBtn.onclick = function() {
    responseObj.stop();
    stopCharAndScenarioGenBtn.style.display = "none";
  };
  
  let characterGalleryCtnWasVisible = characterGalleryOuterCtn.offsetHeight !== 0;
  characterGalleryOuterCtn.style.display = "none";
  
  let data = await responseObj;
  console.log("generateCharactersAndScenario text:", data.text);
  render(data.text);
  stopCharAndScenarioGenBtn.onclick = null;
  stopCharAndScenarioGenBtn.style.display = "none";
  // let lines = data.text.split("\n").map(l => l.trim()).filter(l => l.includes(":"));
  // let char1 = lines.find(l => l.startsWith("CHARACTER 1 NAME:"))?.trim().split(":").slice(1).join(":").trim();
  // let char2 = lines.find(l => l.startsWith("CHARACTER 2 NAME:"))?.trim().split(":").slice(1).join(":").trim();
  // let desc1 = lines.find(l => l.startsWith("CHARACTER 1 DESCRIPTION:"))?.trim().split(":").slice(1).join(":").trim();
  // let desc2 = lines.find(l => l.startsWith("CHARACTER 2 DESCRIPTION:"))?.trim().split(":").slice(1).join(":").trim();
  // let scenario = data.text.split("STARTING SCENARIO:")[1]?.trim().replace(/\n+/g, "\n\n");
  // botNameEl.value = char1;
  // userNameEl.value = char2;
  // botDescriptionEl.value = desc1;
  // userDescriptionEl.value = desc2;
  // scenarioEl.value = scenario;
  
  resizeTextAreaHeightToFitContent(botDescriptionEl, {min:120});
  resizeTextAreaHeightToFitContent(userDescriptionEl, {min:120});
  resizeTextAreaHeightToFitContent(scenarioEl, {min:120});
  
  localStorage.botName = botNameEl.value;
  localStorage.userName = userNameEl.value;
  localStorage.botDescription = botDescriptionEl.value;
  localStorage.userDescription = userDescriptionEl.value;
  localStorage.scenario = scenarioEl.value;
  
  if(characterGalleryCtnWasVisible) characterGalleryOuterCtn.style.display = "";

  generateCharactersAndScenarioLoaderEl.innerHTML = "";
  setTimeout(() => {
    generateCharactersAndScenarioBtn.textContent = "✨ generate characters";
    generateCharactersAndScenarioBtn.disabled = false;
  }, 2000);
  generateCharactersAndScenarioBtn.textContent = "⬇️ finished ⬇️";
  generateScenarioBtn.textContent = generateScenarioBtn.dataset.regenerateTextContent;
  generateScenarioBtn.style.fontWeight = "bold";
  updateCharacterNameViews();
  updateDeleteButtonVisibility();
  update();
  checkOverlyLongFixedTokens();


$meta
  title = [page.title === "AI Chat" ? `AI Chat & Roleplay` : page.title] (online, free, no sign-up, unlimited)
  description = AI RP - A completely free & simple roleplay AI / "Character AI" chat using Perchance's new AI text generation feature - chat with AI characters. Just create a character and a scenario for the chat/roleplay, and send a message. A simple roleplay AI chat bot with no login/sign-up needed - completely free! No account needed 😌 It's fast and has no limits on daily usage. Can do basically any character/scenario type - stories, anime characters, warrior cats roleplay, funny/silly/cute/wholesome, friend/companion/romantic/boyfriend/girlfriend AI/chatbot, movie and TV show characters - if you can describe it, then you can probably create your own chat bot for it. Basically a simple CAI / Character AI alternative. This AI chat has no filter, so 18+ chat content can be produced if the character/chat scenario is NSFW. You can define restrictions, or lack thereof via the character's description.


commentChannels
  general
    commentPlaceholderText = add a friendly comment...
    // Can add options under each channel to overwrite ot add to the below defaults. See all options here: https://perchance.org/comments-plugin
  chill
    commentPlaceholderText = add a friendly comment...
  rp
    commentPlaceholderText = type a response...
  spam
    commentPlaceholderText = for testing stuff, screaming, etc.


defaultCommentOptions // See here for all the options:  https://perchance.org/comments-plugin
  width = 100%
  height = 100%
  forceColorScheme = [localStorage.forceColorScheme || null]
  submitButtonText = send
  customEmojis = {import:huge-emoji-list}
  bannedUsers
    4dafd569d9ba2925c9e7
    61fb1f2cdce8b2a45566
    077e8a3a7070ef8753ee
    c6176e59a16e56d6ebd1
    475f01f601f7296e1795
    1ef522bfc0e0b04726fe
    30743893ff00cac07b40
    207b4a86b9c8cf8e3f7a
    1b99cb216e4ccab2e621
    7d13d56eb4d0e0476a97
    0e074e1bd613d069d222
    edd8e65b646ae9e2e068
    6bbf852c37327f456bdc
    e8c39295d13fca0d9857
    f741f6b9ee39352986fc
    cb2d3380206704a59c46
    5ad3f7d1b0304bf72082
    eec997bbbc9f046f8ff8
    ca7a57165f5787cf88a4
    d7d170b1bbf548ae3638
    be637eaa304797119acd
    0e78c74c5f9a77d4db5a
    438c28dc386894293503
    aa961bb79a20734840e5
    e90223049cbbfe8be4b3
    ff052ab022e5ef70a068
    a8c2ac60554d819cb8f5
    11251530e81ee31356e0
    acff28b65738ae8bdd45
    8fa4467352d56da8cb36
    9b7cd52d178c29f9a345
    b7056ab30c71eef50bad
    efc383c36880dd1e1d04
    2ea2f6c9489e247f47b2
    8ff54b7e7c86df34299c
    1543f059d555546cee00
    7af28ae824cf24ad6ba6
    abf3df5c8248fecdc19b
    4aa0853d3220fa2de451
    f1f84a99c8cc6e05a7df
    612f7c65807081b466ef
    525bef02fcc597ede909
    91136fa2eddb04aa4486
    6a949f353ac7c00de62f
    11e3f9e3e7067cbb39bb
    078c511806780b093c1c
    59c3c5f536176c8e7f33
    e0a872fbcbdecbcb1f5a
    cb3b92a5a0daf3041074
    82565d73d3edeabee4b2
    feaf485e320453c7d3a8
    0c2f42261e9cb646beab
    c3c44c783c49d7d24d2d
    24bf4c765691d77e0e21
    f9a8c3bdb0826bbbacfb
    805ea82081d3e35a3a29
    7deb09386351314aa37a
    0a355460c1fc87d21b43
    14c9fbbf22b10c1eff49
    b200367e7ba720c676f7
    31bbe9add51a5027c915
    18aa714f6d54b732b82d
    2efe053c0743ced16387
    0e350153fe685e8910f3
    ca52d7634fccf9138a35
    e99a04469c4ff70dcba0
    8f6efaf567b87d5abdc1
    605002470a92344fe3e9
    b8a044bad575f65d036a
    90450f63ea7156a03b68
    87ba81889f3bbb4f8938
    cc3beb423b692cf6e88c
    e24bd4628cc403e71e5e
    a81c5074b8cc293dbc5c
    cfeb00a504eadff9bf00
    054c08f278e9d742c5ca
    48550adb9af3163cf52d
    73534b0c250d33a7a1eb
    05b77a08a08374d2b067
    0a1c18de17d109105133
    59a9f6461bd44cb09c8a
    c9bcfce563920cde9653
    54d69c54c896f41ea3a5
    44fe18e05a860e42aede
    66f3fcab067d6a65e095
    60ff21ce4f4cc5dded13
    50960ba005b0b20ac164
    867c8f189789e666a290
    1cce65854c731b698ca6
    c9bcfce563920cde9653
    502480632b9205aae350
    8afd5d2b61d66107350e
    ca11e183b0b05f99ffb0
    2c3be00b8c298451bfdc
    ff3497c54ba1d0dac2e4
    131757b7751ca199d180
    1a2ff02c7e553ac6d101
    0add78c872569a59fe82
    102beb20abae23d55b1d
    2810ab58adc05415e01d
    51288fcd471abbe4a76a
    ed45642501b906e22010
    b4779ce10243470cea9f
    16ff9078150a4cc1ca93
    a142d1cb2399d3e720f4
    8de155d5dc2613448f0e
    6ef2c2771cfc05ae4e61
    45eefc96101ea482dfab
    a1a87e5b9669dee5ef44
    e1203e41a5f27a69fed6
    a63785a58d6e14fddd90
    1d0d596bf26b07c78193
    8f4707b80d0be70f8ee1
    a07b8b8814ec55ef9410
    4457a3a800a01a953839
    59ee95c2fe69dc025355
    8206cc2f8645a1a663c5
    7d2ee53550cfc0e01acd
    0d8f8654dd1dc348685b
    867d7fc56fa2d5164383
    c8760ac3611807fbc176
    a3c088fdc1ab8b4e95a6
    8c3f91f9c86627197b1c
    GLAR-acf19250a7cf6d141b60
    7TRU-7d46f805da2e6fe15f8d
    S8N8-4ba7f4aa3b6b9ebe13a2
    6R8M-f952ba97463f98f9821f
    AT2N-4b41f8aff8f73dfd2865
    MOYO-5a62771fa44bbb7a2b31
    4127-ba329baefdf7cafd1f6a
    ROYS-34e1deb44b8c06bb9bb5
    YISF-41bddc6485c2d3a49366
    1UUQ-89471a355f12a65804de
    PZFU-9add581fd2f1b7f1d5f4
    7N5S-2c9d535aa37c0760e429
    KMDB-cfc29219c8eca59b6d7f
    53XQ-c7080b34cdac7a3bae4b
    I8Y5-19420f798c84440f5db7
    LEPY-9aaf12f05dd6f8bc0537
    F6XG-0b4b00ddecab089c023d
    G4US-caede0c143c161246fc7
    1Q3Z-971178bddd22a099efad
    WC61-346c023508f4c5c3e266
    0BDA-b60301c50cc63afc25b3
    X3SN-35de5b533c17587343fe
    ODU2-d9830993d43ffaf6a8cb
    0I5J-c41fd8af242d95d3709a
    54PA-8fb4efffe136ea6f88f8
    226D-c6db83b16b47ff0f977a
    FO9Y-049036ab02dba0791abe