perchance.org Open in urlscan Pro
2606:4700:20::681a:614  Public Scan

URL: https://perchance.org/ai-chat
Submission: On July 27 via manual from US — Scanned from DE

Form analysis 0 forms found in the DOM

Text Content

WE VALUE YOUR PRIVACY

We and our partners store and/or access information on a device, such as cookies
and process personal data, such as unique identifiers and standard information
sent by a device for personalised advertising and content, advertising and
content measurement, audience research and services development. With your
permission we and our partners may use precise geolocation data and
identification through device scanning. You may click to consent to our and our
1444 partners’ processing as described above. Alternatively you may click to
refuse to consent or access more detailed information and change your
preferences before consenting. Please note that some processing of your personal
data may not require your consent, but you have a right to object to such
processing. Your preferences will apply to this website only. You can change
your preferences or withdraw your consent at any time by returning to this site
and clicking the "Privacy" button at the bottom of the webpage.
Please note that this website/app uses one or more Google services and may
gather and store information including but not limited to your visit or usage
behaviour. You may click to grant or deny consent to Google and its third-party
tags to use your data for below specified purposes in below Google consent
section.
MORE OPTIONSDISAGREEAGREE


AI CHAT

(Psst. Click here if you'd like to try out a different character chat
interface.)

×
⏳


This is a demonstration of what's possible with Perchance's new AI Text Plugin,
which complements the image generation feature. I'll improve the interface a bit
at some point, but for now it's pretty minimal. Try creating a character and a
scenario below, and see what's possible using this new plugin! You can also try
writing a story or going on an adventure or exploring a new planet. Then head to
the plugin page to see some simple examples that you can use as starting points
to create your own generators.

Need some character ideas for this demo? Try creating a character. Or click the
'generate characters' button below to get the AI to come up with characters and
a scenario for you based on a prompt:

↓

🛑 stop ✨ generate characters

— or —
🧝🏽 show character gallery
— Names —
— Bot Character Description —
✨ generate
— User Character Description —
✨ generate
— Scenario & Lore —
✨ generate
— Chat Logs —

✅ understood
🛑

👎 👍
↩️ undo
⬅️ 🔁 regen ➡️
↩️ undo
🗑️ delete last
auto-improve
📨 send message
as UserBotNarrator𝗡𝗲𝘄...
auto-respond
🗣️ Bot🗣️ Narrator🗣️ User+🗑️

🗑️
long responses
💾 save this chat 📁 load a chat 🔗 share this chat
copy link
(this link contains a snapshot of the chat data at the time the link was
generated)

If an AI response makes you happy 👍 or makes you bored or makes mistakes 👎,
please rate it! Each vote helps to improve the AI. There's no need to vote on
"average" responses.

 * If you scroll up in the chat logs of a long chat, you'll see that some
   special summary messages have been inserted. Feel free to edit the content of
   these summaries, but don't move or delete them. They help extend the AI's
   memory. If you want to easily get the full chat text without the summary
   paragraphs, you can click this button: 📋 copy chat logs without summaries
 * This page uses your browser's 'localStorage' to remember your messages,
   descriptions, etc. even after you refresh to page. To remove the data, use
   the delete buttons, or just manually select the text in the text boxes and
   delete it. Your chats with the AI above are not stored on a server. They're
   stored privately in your browser/device storage only.

--------------------------------------------------------------------------------

💬 show comments

+
general?
chill?
rp?
spam?
+

+
(people claiming to be admins in this chat are lying)
(use ai in other channels until your trust score is higher)
🧝 write as character UnnamedAdd char...
auto-send?
a link to a page with just the ai group chat thing

Note: I've decided to hide the comments by default for now because I noticed
that there were some trolls and I don't have time to moderate right now. I'll
try to sweep through and permaban spammers, trolls, bullies, racists,
homophobes, etc. as often as possible, but in the meantime I suggest that you
immediately block trolls, bullies, and shady people. Do not talk to them. Please
encourage other chat participants to block them instead of engaging with them.

Note: If you are reporting a bug/issue in the feedback, please give as much
detail as you can. For example, if it's not working, then was it working
originally? If it never worked, what browser are you using? And on what
operating system? E.g. "Chrome on Windows 10" or "Chrome on Chromebook". If it
was originally working, but then stopped working suddenly, then try deleting
some messages to see if it has something to do with the length of the chat. Also
try refreshing the page, and let me know if that didn't help. Is the
"Loading..." thing in the top-right corner of the page? The more details you
add, the quicker I can fix it :) Thank you to the pioneers who are testing this!
There'll likely be a few annoying issues while this plugin is new.


🌃
  ⇱  

🗨️ feedback
⚄︎perchance
👥︎community (2h)
🌈hub
📚︎tutorial
📦︎resources
🎲︎generators
⊕︎new
🆕ai chat
🔑︎login/signup
✏️︎edit


fold
wrap
ai = {import:ai-text-plugin} // the plugin that actually generates the text
upload = {import:upload-plugin} // for uploading data for share links
commentsPlugin = {import:comments-plugin} 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() || "User"] 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. I'll
improve the interface a bit at some point, but for now it's pretty minimal. Try
creating a character and a scenario below, and see what's possible using this
new plugin! 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>. Then head to the <a
href="/ai-text-plugin" target="_blank">plugin page</a> to see some simple
examples that you can use as starting points to create your own
generators.<br><br>Need some character ideas for this demo? Try <a
href="https://perchance.org/ai-character-description" target="_blank"
style="font-weight:bold;">creating a character</a>. Or click the 'generate
characters' button below to get the AI to come up with characters and a scenario
for you based on a prompt: // 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(); 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; let
temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance = null; 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 = "";
localStorage.input = ""; // 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 = ""; 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.)"; } // 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 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.replaceAll("{{user}}", userName).replaceAll("{{char}}",
botName)); output = output.replace("<<<USER_DESCRIPTION_PLACEHOLDER>>>",
userDescription.replaceAll("{{user}}", userName).replaceAll("{{char}}",
botName)); output = output.replace("<<<SCENARIO_PLACEHOLDER>>>",
scenario.replaceAll("{{user}}", userName).replaceAll("{{char}}", 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; }, 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) { 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
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 } } }, }); // trigger AI to respond
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";
if(temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance) { // page
scroll messes up when we add prefix logs back, so we need to save + restore: let
prevPageScrollTop = document.scrollingElement.scrollTop; chatLogsEl.value =
temporarilyRemovedPrefixChatLogsForStreamingRenderPerformance +
chatLogsEl.value; chatLogsEl.scrollTop = 999999999;
document.scrollingElement.scrollTop = prevPageScrollTop; }
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; }, 2000); } 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(); if(localStorage.sendCount === undefined ||
isNaN(Number(localStorage.sendCount))) localStorage.sendCount = "0";
localStorage.sendCount = Number(localStorage.sendCount) + 1;
triggerTipIfNeeded(); updateLastMessageButtonsDisplayIfNeeded();
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
filename = prompt("Choose a filename:",
(data.userName+"-"+data.botName).replace(/[^a-zA-Z0-9\-_]/g, "")); if(filename
=== null) return; 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';
input.onchange = async (event) => { let file = event.target.files[0]; if (file)
{ try { let content = await file.text(); 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 overwrite your current
chat. Continue?"); if(!confirmed) return; }
loadDataIntoTextAreasAndLocalStorage(data); } else { alert("Unknown save file
format."); } } catch (error) { alert("There was an error while loading that chat
file."); 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; 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.scenario = data.scenario; 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(); async
generateShareLinkForChat() => 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(), scenario:
scenarioEl.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"; }, 4000);
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'; 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; // 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();
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: If the AI <b>repeats itself</b>, you should click the message and edit it
to remove the repetition! Otherwise it'll be more likely to repeat messages in
the future. You can edit any message by simply clicking on it. And, in general,
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.trim().split(/\n{2,}/).slice(-300).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(); $meta title = [page.title === "AI
Chat" ? `AI Chat & Roleplay` : page.title] (online, free, no sign-up, unlimited)
description = 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.
Talk to AI characters, no login/sign-up needed - completely free! 😌 An AI chat
generator that'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 CAI / Character AI
alternative. WARNINGS: (1) Everything the AI says is made up. (2) Inappropriate
content (i.e. 18+ chat messages) should only be produced if explicitely
prompted. If needed, guide the AI using the 'scenario' input - i.e. you can
describe your own 'filter' / restrictions to the AI.
createCommentsTabs(commentOptionsList, opts) => // takes *list* of
commentOptions as input if(!opts) opts = {}; let ctn =
document.createElement("div"); ctn.innerHTML = ` <div class="aboveCommentTabsEl"
style="position: relative;"> ${opts.addCloseButton ? `<div style="position:
absolute; height: 2rem; text-align: center; width: 100%; bottom: 0;
font-size:75%;"><button class="tabs-module-close-button">❌ close</button></div>`
: ``} </div> <div class="tabs-header"></div> <div class="tabs-content"></div> `;
if(opts.addCloseButton) { ctn.querySelector(".tabs-module-close-button").onclick
= function() { ctn.remove(); if(opts.onClose) opts.onClose(); }; } let
commentsChannelNameToObj = {}; const tabsHeaderEl =
ctn.querySelector('.tabs-header'); const tabsContentEl =
ctn.querySelector('.tabs-content'); const aboveCommentTabsEl =
ctn.querySelector('.aboveCommentTabsEl'); function
deleteCommentsChannel(channel) {
if(!localStorage.__aiChatCustomCommentsPluginChannels)
localStorage.__aiChatCustomCommentsPluginChannels = "{}"; let customChannels =
{}; try { customChannels =
JSON.parse(localStorage.__aiChatCustomCommentsPluginChannels) } catch(e) {
console.error("Failed to parse custom channels from localStorage:", e); } delete
customChannels[channel]; localStorage.__aiChatCustomCommentsPluginChannels =
JSON.stringify(customChannels);
tabsHeaderEl.querySelector(`.tab[data-channel='${channel}']`)?.remove();
tabsContentEl.querySelector(`.tab-content[data-channel='${channel}']`)?.remove();
tabsHeaderEl.append(tabsHeaderEl.querySelector(".addCustomChannel")); // move
"+" button to the end } function addCommentsChannel(channel) {
if(!localStorage.__aiChatCustomCommentsPluginChannels)
localStorage.__aiChatCustomCommentsPluginChannels = "{}"; let customChannels =
{}; try { customChannels =
JSON.parse(localStorage.__aiChatCustomCommentsPluginChannels) } catch(e) {
console.error("Failed to parse custom channels from localStorage:", e); } let
alreadyExisted = true; let existingTabEl =
tabsHeaderEl.querySelector(`.tab[data-channel='${channel}']`); if(!existingTabEl
&& channel !== "") { alreadyExisted = false; customChannels[channel] =
{channel:channel}; let commentOptions =
JSON.parse(JSON.stringify(customChannels[channel])); addTab(commentOptions); }
localStorage.__aiChatCustomCommentsPluginChannels =
JSON.stringify(customChannels);
tabsHeaderEl.append(tabsHeaderEl.querySelector(".addCustomChannel")); // move
"+" button to the end let tab =
tabsHeaderEl.querySelector(`.tab[data-channel='${channel}']`); let tabContent =
tabsContentEl.querySelector(`.tab-content[data-channel='${channel}']`); return
{tab, tabContent, alreadyExisted}; } // Function to react to tab clicks function
handleTabClick(event) { let clickedTab = event.target;
if(!clickedTab.classList.contains('tab')) return; // they didn't click directly
on a tab if(event.target.classList.contains('addCustomChannel')) { // They
clicked on the "+" icon to add a new channel. let channel = prompt("Choose a
channel name. Note: You can type #channelname in the chat to share your
channel."); if(channel) { let removeChannel = false; if(channel.startsWith("!"))
{ removeChannel = true; channel = channel.slice(1); } channel =
channel.toLowerCase(); channel = channel.replace(/[^a-z0-9\-]/g, "");
if(!channel) { alert("Channel names can only contain lower-case letters, numbers
and hyphens."); return; } if(channel === "feedback") return; if(removeChannel) {
deleteCommentsChannel(channel);
showTabByChannelName(tabsHeaderEl.firstElementChild.dataset.channel); } else {
addCommentsChannel(channel); showTabByChannelName(channel); alert(`The
'${channel}' channel has been added. Type #${channel} in the chat to share it.`)
} } return; } else { showTabByChannelName(clickedTab.dataset.channel); } } //
Given the channel of a tab, it shows the tab content: function
showTabByChannelName(channel) { window.currentlyShownCommentsChannel = channel;
localStorage.__aiChatCustomCommentsPluginLastViewedChannel = channel;
tabsHeaderEl.querySelectorAll('.tab').forEach(el =>
el.classList.remove('active-tab')); let activeTab =
tabsHeaderEl.querySelector(`.tab[data-channel='${channel}']`);
activeTab.classList.add('active-tab');
tabsContentEl.querySelectorAll('.tab-content').forEach(el =>
el.classList.remove('active-content'));
tabsContentEl.querySelector(`.tab-content[data-channel='${channel}']`).classList.add('active-content');
if(!window.unseenCommentCountByChannel) window.unseenCommentCountByChannel = {};
window.unseenCommentCountByChannel[channel] = 0; updateUnseenCountHtml(channel);
// ban AI from general and no-ai: if(opts.tabFocusControlsAutoCommentCtn) {
autoCommentCtn.style.opacity = (channel === "no-ai" || channel === "") ? "0.3" :
"1"; autoCommentCtn.style.pointerEvents = (channel === "no-ai" || channel ===
"") ? "none" : "auto"; // but allow on general if they've commented enough:
if(channel === "") { if(localStorage.numCommentsSubmittedToGeneral &&
Number(localStorage.numCommentsSubmittedToGeneral) > 200) {
autoCommentCtn.style.opacity = "1"; autoCommentCtn.style.pointerEvents = "auto";
document.querySelector("#"+[..."lEegasseMslennahCrehtOnIiAesu"].reverse().join("").replaceAll(".",
"")).style.visibility = "hidden"; } else {
document.querySelector("#"+[..."lEegasseMslennahCrehtOnIiAesu"].reverse().join("").replaceAll(".",
"")).style.visibility = "visible"; } } else {
document.querySelector("#"+[..."lEegasseMslennahCrehtOnIiAesu"].reverse().join("").replaceAll(".",
"")).style.visibility = "hidden"; } } if(!tabsHeaderEl.channelDeleteBtn) { let
btn = tabsHeaderEl.channelDeleteBtn = document.createElement("div");
btn.style.cssText = "cursor:pointer; width:min-content; display:flex;
align-items:center; justify-content:center; padding-right:0.6rem;
padding-left:0.1rem;"; btn.innerHTML = "🗑️"; btn.onclick = function() { let
channel = this.dataset.channelToDelete; if(confirm(`Hide/unpin the '${channel}'
channel?`)) { deleteCommentsChannel(channel);
showTabByChannelName(tabsHeaderEl.firstElementChild.dataset.channel);
btn.style.display = "none"; } }; } // add delete button if it's not a main
channel: if(channel !== "" && !commentsChannels.selectAll.map(c =>
c.getName).includes(channel)) { let btn = tabsHeaderEl.channelDeleteBtn;
btn.style.display = "flex"; btn.dataset.channelToDelete = channel;
activeTab.after(btn); } else { tabsHeaderEl.channelDeleteBtn.style.display =
"none"; } } // Adds a new tab to the module function addTab(commentOptions) {
commentOptions.replacedDuringUpdate = true; // needed to allow multiple
instances of the same channel (since we have multiple tabbed-comments-modules)
for(let key of defaultCommentsOptions.getAllKeys) { if(!commentOptions[key] &&
!commentOptions.hasOwnProperty(key)) { // getter, because the default options
could be dynamic (e.g. dark/light mode using localStorage), and this
createCommentsTabs function is (as of writing) called every time the dark/light
mode is switched. Object.defineProperty(commentOptions, key, { get: () =>
defaultCommentsOptions[key], }); } } let commentsPluginOutput =
commentsPlugin(commentOptions); commentsChannelNameToObj[commentOptions.channel]
= commentsPluginOutput; const tab = document.createElement('div'); tab.className
= 'tab'; tab.textContent = commentOptions.channel || "general"; tab.innerHTML +=
`<span class="unseenCount">?</span>`; tab.dataset.channel =
commentOptions.channel; const tabContent = document.createElement('div');
tabContent.className = 'tab-content'; tabContent.innerHTML =
commentsPluginOutput; tabContent.dataset.channel = commentOptions.channel;
tabsHeaderEl.appendChild(tab); tabsContentEl.appendChild(tabContent); return
{tab, tabContent}; } let existingChannelNames = []; let channelNameToChannelData
= {}; for(let commentOptions of commentOptionsList.selectAll) {
if(!commentOptions.channel) commentOptions.channel =
commentOptions.getName==="general" ? "" : commentOptions.getName; // use name of
commentOptions object as channel name by default let channelName =
commentOptions.channel.toString(); let {tab, tabContent} =
addTab(commentOptions); channelNameToChannelData[channelName] = {tab,
tabContent, channelName}; existingChannelNames.push(channelName); } // Add the
"+" button unless it was disabled by the options argument (the second param of
this overall function) if(!opts.noCustomChannels) { // clean up a bug with
custom channel names (can remove this later): try {
if(!localStorage.__aiChatCustomCommentsPluginChannels)
localStorage.__aiChatCustomCommentsPluginChannels = "{}"; let customChannels =
{}; try { customChannels =
JSON.parse(localStorage.__aiChatCustomCommentsPluginChannels) } catch(e) {
console.error("Failed to parse custom channels from localStorage:", e); }
for(let channel in customChannels) { if(/[\s#]/.test(channel)) delete
customChannels[channel]; } localStorage.__aiChatCustomCommentsPluginChannels =
JSON.stringify(customChannels); } catch(e) { console.error(e); } // add
user-specified custom channels:
if(!localStorage.__aiChatCustomCommentsPluginChannels)
localStorage.__aiChatCustomCommentsPluginChannels = "{}"; let customChannels =
{}; try { customChannels =
JSON.parse(localStorage.__aiChatCustomCommentsPluginChannels) } catch(e) {
console.error("Failed to parse custom channels from localStorage:", e); }
for(let commentOptions of Object.values(customChannels)) {
if(existingChannelNames.includes(commentOptions.channel)) continue; // already
got that channel let channelName = commentOptions.channel.toString(); let {tab,
tabContent} = addTab(commentOptions); channelNameToChannelData[channelName] =
{tab, tabContent, channelName}; if(window.temporaryMentionedChannels &&
window.temporaryMentionedChannels.has(channelName)) {
tab.dataset.isTemporaryMentionedChannel = "1";
attachEventsToTemporaryMentionedChannelTab(tab, channelName); } } // add the "+"
button: const tab = document.createElement('div'); tab.className = 'tab
addCustomChannel'; tab.textContent = "+"; tab.style.cssText =
"min-width:1.4rem;"; tabsHeaderEl.appendChild(tab); } // let
defaultStartChannelIndex = Math.random() < 0.5 ? 0 : 1; let
defaultStartChannelIndex = 0; let activeChannelData =
channelNameToChannelData[localStorage.__aiChatCustomCommentsPluginLastViewedChannel
?? existingChannelNames[defaultStartChannelIndex]]; // randomly start at general
or no ai for new users if(!activeChannelData) { console.error("localstorage had
__aiChatCustomCommentsPluginLastViewedChannel, but that channel doesn't
exist?"); activeChannelData = channelNameToChannelData[existingChannelNames[0]];
} setTimeout(async () => { while(!document.body.contains(ctn)) await new
Promise(r => setTimeout(r, 100));
showTabByChannelName(activeChannelData.channelName); }, 10);
tabsHeaderEl.addEventListener('click', handleTabClick); let styleEl =
document.createElement("style"); styleEl.textContent = ` .tabbed-comments-ctn
.tabs-header { display: flex; font-size: 70%; overflow:auto; }
.tabbed-comments-ctn .tabs-content { flex-grow:1; } .tabbed-comments-ctn .tab {
padding: 0.25rem; cursor: pointer; background: var(--box-color, #ebebeb);
border-radius: 0.25rem; margin: 0.25rem; user-select: none; min-width:
max-content; display: flex; align-items: center; justify-content: center; }
.tabbed-comments-ctn .tab[data-is-temporary-mentioned-channel='1'] {
opacity:0.6; } .tabbed-comments-ctn .tab .unseenCount { border-radius: 100px;
padding: 0.05rem 0.15rem; font-size:80%; background:grey; margin-left: 0.1rem;
pointer-events:none; color:white; } .tabbed-comments-ctn .active-tab {
background: var(--active-comment-channel-tab-color, #c6c6c6); }
.tabbed-comments-ctn .tab-content { display: none; height:100%; }
.tabbed-comments-ctn .active-content { display: block; } `; ctn.append(styleEl);
ctn.style.cssText = "height:100%; width:100%; display:flex;
flex-direction:column;" return {element:ctn, addCommentsChannel,
deleteCommentsChannel, commentsChannelNameToObj};
getAutoCommentNameFromNickname(nickname) => let nicknameWithoutNonNameCharacters
= nickname.replace(/[\n\-:|()!{}*]/g,
"").replace(/(?![*#0-9]+)[\p{Emoji}\p{Emoji_Modifier}\p{Emoji_Component}\p{Emoji_Modifier_Base}\p{Emoji_Presentation}]/gu,
''); let words = nicknameWithoutNonNameCharacters.split(/[|/ \-})]/g).map(w =>
w.trim()).filter(w => w); while(words.slice(0, -1).join("_").length > 25)
words.pop(); // we remove the last one for the length check in case it's a name
like One Supercalifragilisticexpialidocious - better for userTag to be
"OneSupercalif" than "One" if(words.slice(0, -1).join("_").length > 15)
words.pop(); // we probably don't need the last word if all the earlier ones are
over 15 characters let name = words.join("_").trim().toLowerCase(); return name
+ (nickname.length > 25 ? "…" : ""); autoComment(character) => let commentsBox =
window.mainTabbedCommentsChannelNameToObj[window.currentlyShownCommentsChannel];
if(commentsBox.channel === "n/o/-/a/i".replaceAll("/", "")) return; let
startTime = Date.now(); autoCommentCharBtn.disabled = true;
autoCommentCharBtn.textContent = "⏳ just a sec..."; let currentUser =
commentsBox.comments.find(c => c.byCurrentUser)?.user; let comments =
commentsBox.comments.slice(-500).map(c => { let userTag =
c.user.visualId.toUpperCase(); if(c.user.nickname) { let name =
getAutoCommentNameFromNickname(c.user.nickname); if(name) userTag =
`${c.user.visualId.toUpperCase()}(${name})` } return `${userTag}:
${c.message.trim().replace(/\n{3,}/g, "\n\n")}`; });
while(comments.join("").length > 10000) comments.shift(); let nicknameOverride =
character.name.replace(/\s+/g, " ").slice(0, 50); let currentUserVisualId =
currentUser ? currentUser.visualId.toUpperCase() : "U7OP"; // just use a
random/arbitrary tag if we don't have the user's actual one (we'll get it once
they've made their first comment) let respondingUserTag =
`${currentUserVisualId}(${character.name})`; let characterDescription =
character.description.replace(/\n/g, " ").slice(0, 5000); let newCommentText =
""; //commentsBox.inputText.trim().replace(/\n/g, " ").split(" ").slice(0, 3);
let obj = ai({ instruction: ` Your task is to write the next 5 messages in the
following chat/roleplay. Keep it interesting. Stay in character based on the
previous messages. You are **NOT** an AI. You are writing as a regular chat
participant, not as an AI. When writing ${respondingUserTag}'s messages, you can
ignore the off-topic/OOC/spam/commentary chat messages and focus on the roleplay
messages that are actually a part of the story. Predict what the characters in
this chat/roleplay are most likely to say next, and then write it **EXACTLY** as
the chat participants would, based on what they've previously said. IMPORTANT:
Here's a description of ${respondingUserTag}: ${characterDescription}.
<MESSAGES> ${comments.slice(0, -4).join("\n\n---\n\n").trim() || `(None yet.
Start the chat/roleplay with an interesting starter message.)`} </MESSAGES>
Again, your task is to write the next 5 messages. Make sure your writing is
interesting, authentic, descriptive, natural, engaging, and creative. Create a
captivating and genuinely fascinating roleplay, and always stay in-character and
write responses based on the previous chat context. Don't be boring. Keep the
roleplay flowing! As a reminder about the ${respondingUserTag} character:
${characterDescription.slice(0, 500) + (characterDescription.length > 1000 ?
"..." : "")} `.trim(), startWith:
comments.slice(-4).join("\n\n---\n\n")+"\n\n---\n\n"+respondingUserTag+":
"+newCommentText, stopSequences: ["\n\n", "</MESSAGES>"], onChunk:
function(data) { if(data.isFromStartWith) return; newCommentText +=
data.textChunk; if(newCommentText.includes("</MESSAGES>")) { newCommentText =
newCommentText.replace(/<\/MESSAGES>/g, "").trim(); obj.stop(); }
commentsBox.inputText = newCommentText.trim(); // if really long, or repeating a
single character, stop: if(newCommentText.length > 800 ||
newCommentText.slice(-30) ===
newCommentText[newCommentText.length-1].repeat(30)) { obj.stop(); } }, onFinish:
async function() { autoCommentCharBtn.disabled = false;
autoCommentCharBtn.textContent = "🧝 write as character";
if(/\n---\s*$/.test(commentsBox.inputText)) { commentsBox.inputText =
commentsBox.inputText.replace(/\n---\s*$/g, ""); newCommentText =
newCommentText.replace(/\n---\s*$/g, ""); }
if(autoCommentAutoSendCheckboxEl.checked) { if(newCommentText.trim()) {
if(commentsBox.channel === "n.o.-.a.i".replaceAll(".", "")) return;
if(commentsBox.channel === "" && (!localStorage.numCommentsSubmittedToGeneral ||
Number(localStorage.numCommentsSubmittedToGeneral) < 200)) return;
if(window.lastCurrentUserCommentTime &&
Date.now()-window.lastCurrentUserCommentTime < 3000) { // too soon to submit
again, so just set nickname and let them click submit manually
commentsBox.setNicknameForNextComment(nicknameOverride);
autoCommentCharBtn.textContent = "submitting too fast"; setTimeout(() => {
autoCommentCharBtn.textContent = "🧝 write as character"; }, 1000); } else {
commentsBox.submit(newCommentText.trim(), {nickname:nicknameOverride}).then(r =>
{ if(!r.success) commentsBox.inputText = newCommentText.trim(); // recover
comment text if it failed }); commentsBox.inputText = ""; } } } else { // No
auto-submit, but we still set the nickname to their character
commentsBox.setNicknameForNextComment(nicknameOverride); } }, });
updateUnseenCountHtml(channel) => let count =
window.unseenCommentCountByChannel[channel]; for(let tabCtn of
[...commentsAreaEl.querySelectorAll(".tabbed-comments-ctn")]) { let countEl =
tabCtn.querySelector(`.tab[data-channel='${channel}'] .unseenCount`);
if(countEl) { // since this tabCtn may not have that particular tab
countEl.textContent = count; countEl.style.background = (count > 0) ? "#ab0e0e"
: ""; countEl.style.display = (count > 0) ? "inline" : "none"; } }
processCommentForUnseenCounts(comment) => let channel = comment.channel; //
initialize all the tracking variables if we haven't yet:
if(!window.knownMessageIdsByChannel) window.knownMessageIdsByChannel = {};
if(!window.knownMessageIdsByChannel[comment.channel])
window.knownMessageIdsByChannel[channel] = new Set();
if(!window.unseenCommentCountByChannel) window.unseenCommentCountByChannel = {};
// increment the unseen count if we haven't seen this comment id before:
if(!window.knownMessageIdsByChannel[channel].has(comment.id)) {
window.knownMessageIdsByChannel[channel].add(comment.id);
window.unseenCommentCountByChannel[channel] =
(window.unseenCommentCountByChannel[channel] || 0) + 1; let tabIsVisible =
false; // check if it's visible in any of the tabbedCommentsCtn elements for(let
tabCtn of [...commentsAreaEl.querySelectorAll(".tabbed-comments-ctn")]) { let
tabContentEl = tabCtn.querySelector(`.tab-content[data-channel='${channel}']`);
if(tabContentEl && tabContentEl.offsetHeight !== 0) tabIsVisible = true; }
if(tabIsVisible) { // set unseen count to 0 if tab is visible
window.unseenCommentCountByChannel[channel] = 0; }
updateUnseenCountHtml(channel); } commentsChannels 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.
attachEventsToTemporaryMentionedChannelTab(tabEl, channelName) =>
tabEl.addEventListener("click", function() {
window.temporaryMentionedChannels.delete(channelName); for(let t of
[...document.querySelectorAll(`.tabbed-comments-ctn .tabs-header
.tab[data-channel='${channelName}']`)]) { t.dataset.isTemporaryMentionedChannel
= ""; } }); window.addEventListener("beforeunload", function() {
if(tabEl.dataset.isTemporaryMentionedChannel) {
window.temporaryMentionedChannels.delete(channelName); for(let obj of
[...window.sideTabbedCommentsObjs, window.mainTabbedCommentsObj]) {
obj.deleteCommentsChannel(channelName); } } });
processCommentsForTempChannels(comments) => for(let comment of comments) { let
mentionedChannelName = comment.message.match(/(\s|^)#[a-z0-9\-]+(\s|$)/g)?.[0];
if(mentionedChannelName && !/^[0-9]+$/.test(mentionedChannelName)) { // if it's
all numbers, ignore mentionedChannelName =
mentionedChannelName.trim().replace(/^#/, "").trim(); // remove the hash from
the start and whitespace from start/end if(mentionedChannelName === "feedback")
continue; // add it as a temporary channel which self-deletes after 2 minutes.
// it upgrades to a real one when clicked. if(window.mainTabbedCommentsObj) { //
<-- just in case somehow hasn't initialized yet, or whatever // If they've got
the tab *somewhere* (e.g. in side module), then we don't add it as a temporary
channel. let alreadyExistsSomewhere =
!!document.querySelector(`.tabbed-comments-ctn .tabs-header
.tab[data-channel='${mentionedChannelName}']`); let { tab, alreadyExisted } =
window.mainTabbedCommentsObj.addCommentsChannel(mentionedChannelName);
if(!alreadyExisted && !alreadyExistsSomewhere) {
if(!window.temporaryMentionedChannels) window.temporaryMentionedChannels = new
Set(); window.temporaryMentionedChannels.add(mentionedChannelName); let newTabs
= []; newTabs.push(tab); try { // because it's new code for(let obj of
window.sideTabbedCommentsObjs) { let { tab } =
obj.addCommentsChannel(mentionedChannelName); newTabs.push(tab); } } catch(e) {
console.error(e); } for(let newTab of newTabs) {
newTab.dataset.isTemporaryMentionedChannel = "1";
attachEventsToTemporaryMentionedChannelTab(newTab, mentionedChannelName); } //
make its background blink for a couple of seconds: let blinkCount = 0; let
blinkInterval = setInterval(() => { tab.style.background = blinkCount%2===0 ?
"#043a9b" : ""; blinkCount++; if(blinkCount > 5) clearInterval(blinkInterval);
}, 300); setTimeout(() => {
window.temporaryMentionedChannels.delete(mentionedChannelName);
if(document.body.contains(tab) && tab.dataset.isTemporaryMentionedChannel) {
for(let obj of [...window.sideTabbedCommentsObjs, window.mainTabbedCommentsObj])
{ obj.deleteCommentsChannel(mentionedChannelName); } } }, 1000*40); } } } }
defaultCommentsOptions // 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} onLoad(comments) => for(let comment
of comments) { processCommentForUnseenCounts(comment); if(comment.byCurrentUser)
{ window.lastCurrentUserCommentTime = Date.now(); } } if(comments[0])
window.unseenCommentCountByChannel[comments[0].channel] = 0; // reset unseen
count since above `processCommentForUnseenCounts` has incremented it
processCommentsForTempChannels(comments.slice(-30)); onComment(comment) =>
processCommentForUnseenCounts(comment); if(comment.byCurrentUser) {
window.lastCurrentUserCommentTime = Date.now();
if(!localStorage.numCommentsSubmitted) localStorage.numCommentsSubmitted = 0;
localStorage.numCommentsSubmitted = (Number(localStorage.numCommentsSubmitted) +
1); if(!localStorage.numCommentsSubmittedToGeneral)
localStorage.numCommentsSubmittedToGeneral = 0;
localStorage.numCommentsSubmittedToGeneral =
(Number(localStorage.numCommentsSubmittedToGeneral) + 1); }
processCommentsForTempChannels([comment]); 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
867d7fc56fa2d5164383 c8760ac3611807fbc176 a3c088fdc1ab8b4e95a6

  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. I'll improve the interface a bit at some point, but for now it's pretty minimal. Try creating a character and a scenario below, and see what's possible using this new plugin! 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>. Then head to the <a href="/ai-text-plugin" target="_blank">plugin page</a> to see some simple examples that you can use as starting points to create your own generators.<br><br>Need some character ideas for this demo? Try <a href="https://perchance.org/ai-character-description" target="_blank" style="font-weight:bold;">creating a character</a>. Or click the 'generate characters' button below to get the AI to come up with characters and a scenario for you based on a prompt:







html plain text

⇱︎ fullscreen ⚠︎ warnings ⟳︎ reload  auto

wrap
<h1 style="margin-bottom:0.5rem">[page.title]</h1> <p
style="font-size:80%;">[page.subtitle]</p> <div id="characterGalleryOuterCtn"
style="display:none; position:fixed; top:0; left:0; right:0; bottom:0;
z-index:1000; background:rgba(0,0,0,0.7);"> <div id="characterGalleryCtn"
style="position:fixed; top:2.5vw; left:2.5vw; right:2.5vw; bottom:2.5vw;"> <div
style="z-index:10; position:absolute; top:0; right:0; width:0; height:0;"><div
style="font-size:1.5rem; width:2rem; height:2rem; border-radius:100%;
cursor:pointer; background:#353535; display:flex; align-items:center;
justify-content:center; transform: translateX(-50%)
translateY(-50%);">&times;</div></div> <style>@keyframes spin { 0% { transform:
rotate(0deg); } 100% { transform: rotate(360deg); }}</style> <div
style="z-index:-10; display:flex; align-items:center; justify-content:center;
position:absolute; top:0; left:0; right:0; bottom:0;"><div style="animation:
spin 2s linear infinite; font-size:3rem;">⏳</div></div> <div
id="characterGalleryIframeCtn" style="width:100%; height:100%;
border-radius:3px; overflow:hidden;"></div> </div> </div> <div
style="display:flex; flex-direction:column; justify-content:center;
align-items:center; max-width:98%; width:700px; margin:0 auto;"> <p
style="font-size:80%; margin:0.5rem; margin-bottom:0rem;
text-align:justify;">[page.intro]</p> <p style="font-size:160%;
margin:0.5rem;">↓</p> <!-- generate characters button --> <div
style="font-size:85%; margin-bottom:0rem; margin-top:0.5rem;"> <button
id="stopCharAndScenarioGenBtn" style="display:none; font-size:80%; margin:0
auto; margin-bottom:0.25rem;">🛑 stop</button> <button
id="generateCharactersAndScenarioBtn"
onclick="changeCharacterGalleryVisibility('hidden');
generateCharactersAndScenario()" style="margin-bottom:0.25rem;">✨ generate
characters</button> <div id="generateCharactersAndScenarioLoaderEl"></div>
</div> <div style="display:none; margin-bottom:1rem; width:100%;
font-size:85%;"> <div style="margin-bottom:0.25rem; opacity:0.6;">— or —</div>
<button id="showCharacterGalleryBtn"
onclick="changeCharacterGalleryVisibility('visible')">🧝🏽 show character
gallery</button> </div> <script> function
changeCharacterGalleryVisibility(state) { if(state === "hidden") {
characterGalleryOuterCtn.style.display = "none"; } else {
characterGalleryOuterCtn.style.display = "";
if(!characterGalleryIframeCtn.innerHTML.trim()) {
characterGalleryIframeCtn.innerHTML = `<iframe id="characterGalleryIframe"
src="https://null.perchance.org/ai-characters?sendEventsToParent=true&v=21"
style="display:block; width:100%; height:100%; border:0;"></iframe>`; }
setTimeout(() => { window.addEventListener("pointerdown", function() {
changeCharacterGalleryVisibility("hidden"); }, {once:true}); }, 10); } }
window.addEventListener("click", function() { if(typeof characterGalleryIframe
=== "undefined") return;
characterGalleryIframe.contentWindow.postMessage({type:"close-popup"}, "*"); });
window.addEventListener("message", function(e) { if(typeof
characterGalleryIframe === "undefined") return; if(e.source !==
characterGalleryIframe.contentWindow) return; if(e.data.type ===
"page-min-height-change") { console.log("New min height", e.data.minHeight);
characterGalleryIframe.style.height = `${e.data.minHeight}px`; } if(e.data.type
=== "character-selected") { // changeCharacterGalleryHeight("expanded"); }
if(e.data.type === "character-scenario-selected") {
changeCharacterGalleryVisibility("hidden"); let characterData =
e.data.characterData; let scenario =
characterData.scenarios[e.data.scenarioIndex]; botNameEl.value =
characterData.name; botDescriptionEl.value = characterData.description; //
if(characterData.userCharacter?.name) userNameEl.value =
characterData.userCharacter.name; //
if(characterData.userCharacter?.description) userDescriptionEl.value =
characterData.userCharacter.description; scenarioEl.value = scenario || "";
updateCharacterNameViews(); namesTitleEl.scrollIntoView({behavior:'smooth'});
localStorage.botName = botNameEl.value; localStorage.userName =
userNameEl.value; localStorage.botDescription = botDescriptionEl.value;
localStorage.userDescription = userDescriptionEl.value; localStorage.scenario =
scenarioEl.value; } }); </script> <div id="namesTitleEl"
style="margin-top:0.5rem;">— Names —</div> <input id="botNameEl" tabindex="1"
placeholder="The bot's nickname" oninput="localStorage.botName=this.value,
update(), updateCharacterNameViews()"> <input id="userNameEl" tabindex="1"
placeholder="Your nickname" oninput="localStorage.userName=this.value, update(),
updateCharacterNameViews()"> <!-- bot description --> <div
style="margin-top:2rem;">— <b class="containsCharName">[botName]</b> Character
Description —</div> <div class="charDescriptionCtn" style="position:relative;
width:100%;"> <textarea id="botDescriptionEl" tabindex="1" placeholder="Describe
who the AI/bot is and what personality you want them to have."
style="width:100%; height:120px; resize:vertical; display:block;
padding-bottom:0.5rem; padding-top:0.25rem;"
oninput="localStorage.botDescription=this.value;
botDescriptionDeleteBtn.dataset.mode='delete';
botDescriptionDeleteBtn.style.display='';"></textarea> <div
style="position:absolute; bottom:0.5rem; width:100%; height:0;"> <button
id="generateBotDescriptionBtn" onclick="generateCharacterDescription('bot',
this); botDescriptionDeleteBtn.dataset.mode='delete';" style="font-size:70%;">✨
generate</button> </div> <button id="botDescriptionDeleteBtn" data-mode="delete"
onclick="if(this.dataset.mode==='delete') {
window.deletedBotDescription=botDescriptionEl.value; botDescriptionEl.value='';
this.dataset.mode='undo'; } else {
botDescriptionEl.value=window.deletedBotDescription; this.dataset.mode='delete';
}; localStorage.botDescription=botDescriptionEl.value;"
style="position:absolute; top:-0.75rem; right:0.5rem; font-size:60%;
display:none;"></button> <style>
#botDescriptionDeleteBtn[data-mode='delete']:before { content:'🗑️ delete'; }
#botDescriptionDeleteBtn[data-mode='undo']:before { content:'↩️ undo'; }
</style> </div> <!-- user description --> <div style="margin-top:2rem;">— <b
class="containsCharName">[userName]</b> Character Description —</div> <div
class="charDescriptionCtn" style="position:relative; width:100%;"> <textarea
id="userDescriptionEl" tabindex="1" placeholder="Describe the character that
*you* will be in this chat." style="width:100%; height:120px; resize:vertical;
display:block; padding-bottom:0.5rem; padding-top:0.25rem;"
oninput="localStorage.userDescription=this.value;
userDescriptionDeleteBtn.dataset.mode='delete';
userDescriptionDeleteBtn.style.display='';"></textarea> <div
style="position:absolute; bottom:0.5rem; width:100%; height:0;"> <button
id="generateUserDescriptionBtn" onclick="generateCharacterDescription('user',
this); userDescriptionDeleteBtn.dataset.mode='delete';" style="font-size:70%;">✨
generate</button> </div> <button id="userDescriptionDeleteBtn"
data-mode="delete" onclick="if(this.dataset.mode==='delete') {
window.deletedUserDescription=userDescriptionEl.value;
userDescriptionEl.value=''; this.dataset.mode='undo'; } else {
userDescriptionEl.value=window.deletedUserDescription;
this.dataset.mode='delete'; };
localStorage.userDescription=userDescriptionEl.value;" style="position:absolute;
top:-0.75rem; right:0.5rem; font-size:60%; display:none;"></button> <style>
#userDescriptionDeleteBtn[data-mode='delete']:before { content:'🗑️ delete'; }
#userDescriptionDeleteBtn[data-mode='undo']:before { content:'↩️ undo'; }
</style> </div> <!-- scenario / lore --> <div style="margin-top:2rem;">—
Scenario &amp; Lore —</div> <div id="scenarioAreaCtn" style="position:relative;
width:100%;"> <textarea id="scenarioEl" tabindex="1" placeholder="Describe the
scenario, lore, side characters, and any other relevant information."
style="width:100%; height:120px; resize:vertical; display:block;
padding-bottom:0.5rem; padding-top:0.25rem;"
oninput="localStorage.scenario=this.value;
scenarioDeleteBtn.dataset.mode='delete'; scenarioDeleteBtn.style.display='';
"></textarea> <div style="position:absolute; bottom:0.5rem; width:100%;
height:0;"> <button id="generateScenarioBtn"
onclick="generateScenarioDescription(this);
scenarioDeleteBtn.dataset.mode='delete';" style="font-size:70%;"
data-regenerate-text-content="✨ regenerate">✨ generate</button> </div> <button
id="scenarioDeleteBtn" data-mode="delete"
onclick="if(this.dataset.mode==='delete') {
window.deletedScenario=scenarioEl.value; scenarioEl.value='';
this.dataset.mode='undo'; } else { scenarioEl.value=window.deletedScenario;
this.dataset.mode='delete'; }; localStorage.scenario=scenarioEl.value;"
style="position:absolute; top:-0.75rem; right:0.5rem; font-size:60%;
display:none;"></button> <style> #scenarioDeleteBtn[data-mode='delete']:before {
content:'🗑️ delete'; } #scenarioDeleteBtn[data-mode='undo']:before {
content:'↩️ undo'; } </style> </div> <!-- chat logs --> <div
style="margin-top:2rem;">— Chat Logs —</div> <div style="position:relative;
width:100%;"> <textarea id="chatLogsEl" placeholder="The chat logs will show up
here, and you can edit them.&#10;&#10;The text here is completely freeform - for
example, if you wanted to add a narrator that comes in every now and then, you
could just write something like:&#10;&#10;Narrator: Later that
day...&#10;&#10;And you can use *asterisks* for actions, and stuff like that."
style="width:100%; height:450px; max-height:70vh; resize:vertical;
display:block; padding-bottom:1.5rem; scrollbar-gutter:stable;"
oninput="localStorage.chatLogs=this.value;
chatLogsDeleteBtn.dataset.mode='delete'; chatLogsDeleteBtn.style.display='';"
spellcheck="false"></textarea> <div id="loaderEl" style="position:absolute;
bottom:1.5rem; right:0.5rem;"></div> <div id="tipEl" style="display:none;
position:absolute;top:0;left: 0;right: 0; font-size:85%;"> <div
style="background: var(--box-color);padding: 0.5rem;box-shadow: rgba(0, 0, 0,
0.75) 0px 1px 4px 0px; border:1px solid #676767; border-radius:3px; width:95%;
max-width:600px; margin:0px auto; margin-top:1rem;"> <div id="tipMessageEl"
style="text-align:left;"></div> <button id="tipDismissBtn"
style="margin-top:0.5rem;" onclick="tipEl.style.display='none';">✅
understood</button> </div> </div> <button id="chatLogsDeleteBtn"
data-mode="delete" onclick="if(this.dataset.mode==='delete') {
window.deletedChatLogs=chatLogsEl.value; chatLogsEl.value='';
this.dataset.mode='undo'; } else { chatLogsEl.value=window.deletedChatLogs;
this.dataset.mode='delete'; }; localStorage.chatLogs=chatLogsEl.value;"
style="position:absolute; top:-0.75rem; right:0.5rem; font-size:60%;
display:none;"></button> <style> #chatLogsDeleteBtn[data-mode='delete']:before {
content:'🗑️ delete'; } #chatLogsDeleteBtn[data-mode='undo']:before {
content:'↩️ undo'; } </style> <div id="deleteAndRegenLastMessageCtn"
style="display:[chatLogsEl.value ? 'flex' : 'none']; margin-top:0.5rem;
align-items:center; justify-content:center; position:relative; top:-0.5rem;
height:0.25rem;"> <div id="stopGenerationCtn" style="display:none;
position:relative; height:min-content; margin-right:0.5rem;"> <button
id="stopGenerationBtn" style="font-size:75%;">🛑</button> </div> <div
id="rateLastMessageCtn" style="display:none; position:relative;
height:min-content; margin-right:1rem;"> <div id="ratingReasonCtn"
style="display:none; position:absolute; text-align:center; width:100%;
font-size:80%; top:0; height:0px;"> <div style="position:absolute;
bottom:0.25rem; text-align:center; width:max-content;"> <input
id="ratingReasonEl" list="recentRatingReasonsDataList" placeholder="(Optional)
Reason" style="width:150px;"> <datalist
id="recentRatingReasonsDataList"></datalist> </div> </div> <button
id="rateLastMessageBadBtn" disabled onclick="rateLastMessage('bad');"
style="font-size:75%; filter:hue-rotate(300deg);">👎</button> <button
id="rateLastMessageGoodBtn" disabled onclick="rateLastMessage('good');"
style="font-size:75%; margin-left:0.25rem; filter:hue-rotate(35deg)
saturate(0.9);">👍</button> </div> <div style="position:relative;
height:min-content; margin-left:0.5rem; display:flex;"> <div
id="undoRegenMessageCtn" style="display:none; position:absolute;
text-align:center; width:100%; font-size:80%; top:0; height:0px;"><div
style="position:absolute; bottom:0.25rem; text-align:center;
width:100%;"><button style="width:max-content;"
onclick="undoRegenLastMessage();">↩️ undo</button></div></div> <button
id="regenPrevButton" class="regenBtn" onclick="prevRegenMessage();
chatLogsDeleteBtn.dataset.mode='delete';" disabled style="margin-left:0rem;
">⬅️</button> <button id="regenMessageBtn" class="regenBtn"
onclick="regenLastMessage(); chatLogsDeleteBtn.dataset.mode='delete';"
style="margin-left:0.5rem; min-width:4rem;" data-short-content="🔁">🔁
regen</button> <button id="regenNextButton" class="regenBtn"
onclick="nextRegenMessage(); chatLogsDeleteBtn.dataset.mode='delete';" disabled
style="margin-left:0.5rem;">➡️</button> <style> .regenBtn { font-size: 75%;
height: min-content; /* this is a hack to prevent background from being
transparent (due to user agent styles - in Chrome, at least) when button is
disabled */ /* EDIT: had to remove this - causing huge lag on low-end devices
(both android and firefox browsers) for some reason. */ /* backdrop-filter:
blur(20px); */ } </style> </div> <div style="position:relative;
height:min-content; margin-left:1.5rem;"> <div id="undoDeleteLastMessageCtn"
style="display:none; position:absolute; text-align:center; width:100%;
font-size:80%; top:0; height:0px;"><div style="position:absolute;
bottom:0.25rem; text-align:center; width:100%;"><button
style="width:max-content;" onclick="undoDeleteLastMessage();">↩️
undo</button></div></div> <button id="deleteLastMessageBtn"
onclick="deleteLastMessage(); localStorage.chatLogs=chatLogsEl.value;
chatLogsDeleteBtn.dataset.mode='delete';" style="font-size:75%; min-width:3rem;"
data-short-content="🗑️">🗑️ delete last</button> </div> </div> <script>
updateLastMessageButtonsDisplayIfNeeded(); </script> </div> <script>
chatLogsEl.addEventListener("input", () => {
window.mostRecentChatLogEditWasAContinuationGeneration = false; });
chatLogsEl.addEventListener('click', function(e) { // if they're almost scrolled
to the bottom, and they click near the bottom, scroll down the last tiny bit let
lowerFifth = this.offsetHeight * 8 / 10; let closeToBottom = this.scrollHeight -
this.scrollTop - this.clientHeight < 40; // <-- if scrolled this many px from
bottom if(e.offsetY > lowerFifth && closeToBottom) { this.scrollTop =
this.scrollHeight; } }); </script> <!-- text input area --> <div
style="position:relative; width:100%;"> <textarea id="inputEl" tabindex="1"
placeholder="Write a message here and click send, or just click send to get the
AI to generate the next message for you. You can also directly edit the chat log
text above." style="width:100%; height:85px; margin-top:0.5rem; resize:vertical;
display:block; font-size:85%;" onkeydown="if(event.which === 13) {
event.preventDefault(); handleSendButtonClick({mode:'normal'}); }"
oninput="localStorage.input=this.value;
updateVisibilityOfReplyButtonsAndSelectors();"></textarea> <div
id="autoImproveCtn" style="position:absolute; bottom:0.5rem; width:100%;
height:0;"> <div
onclick="if(!event.composedPath().includes(responseLengthCheckboxEl)) {
responseLengthCheckboxEl.click(); };
if(!localStorage.knowsWhatAutoImproveFeatureDoes) { alert(this.title);
localStorage.knowsWhatAutoImproveFeatureDoes='1'; }" title="This 'auto-improve'
feature allows you write a very short/simple message (like 'pick up the apple')
and the AI will expand that into a nicely-written message for you."
style="display:flex; cursor:pointer; align-items:center; border-radius:3px;
border:1px solid grey; width:min-content; background:var(--box-color);
padding:0.125rem; position:absolute; bottom:0; right:0.5rem; font-size:80%;">
<input id="autoImproveCheckboxEl" type="checkbox"
onclick="setTimeout(updateVisibilityOfReplyButtonsAndSelectors, 10)"> <span
style="margin-left:0.25rem; width:max-content; user-select:none;"
onclick="autoImproveCheckboxEl.click();">auto-improve</span> </div> </div>
<script> function hideAutoImproveButtonIfNeeded(event) { const len =
window.innerWidth > 500 ? 240 : 120; if(inputEl.value.length > len) {
autoImproveCtn.style.display = "none"; // so it doesn't get in the way of typing
} else if(autoImproveCtn.style.display === "none") {
autoImproveCtn.style.display = ""; } } inputEl.addEventListener("input",
hideAutoImproveButtonIfNeeded); inputEl.addEventListener("focus",
hideAutoImproveButtonIfNeeded); inputEl.addEventListener("blur", (event) => {
autoImproveCtn.style.display = ""; }); </script> </div> <div
style="display:flex; flex-direction:column; margin-top:0.5rem;
align-items:center;"> <div style="display:flex;margin-bottom:0.5rem;align-items:
center;"> <button id="sendMessageBtn" tabindex="1"
onclick="handleSendButtonClick({mode:'normal'});
chatLogsDeleteBtn.dataset.mode='delete';" style="display:flex; font-weight:bold;
font-size:120%;">📨 send message</button> <div id="sendAsCharacterCtn"
style="display:none;"> <span style="padding:0 0.25rem; display:flex;
align-items:center; font-size:80%; opacity:0.7;">as</span> <select
id="sendAsCharacterSelectEl" style="height:min-content; font-size:80%;
max-width:90px;" onchange="if(this.value === '~~~NEW_CHAR~~~') { let name =
prompt(`Enter the new character's name:`); if(name === null) {
this.value=this.options[0].value; } else if(name.trim()) { let newOption =
document.createElement('option'); newOption.textContent=name;
this.insertBefore(newOption, this.options[this.selectedIndex]); this.value=name;
} }"></select> </div> </div> <div id="autoRespondCtn" style="display:flex;
align-items:center; justify-content:center; font-size:80%; margin-bottom:0.5rem;
border:1px solid grey; padding:0.125rem; border-radius:3px;"> <input
id="autoRespondCheckboxEl" type="checkbox" style="cursor:pointer;" checked
onchange="localStorage.user_autoRespond=this.checked||'';"> <span
style="opacity:0.7; margin-left:0.25rem; cursor:pointer; user-select:none;"
onclick="autoRespondCheckboxEl.checked=!autoRespondCheckboxEl.checked;">auto-respond</span>
</div> <script>if(localStorage.user_autoResponder) autoRespondCheckboxEl.checked
= !!localStorage.user_autoResponder;</script> <div id="quickReplyButtonsCtn"
style="display:flex; gap:0.5rem; flex-wrap:wrap; justify-content:center;"></div>
<div id="futureSuggestionsCtn" style="display:none; gap:0.5rem; flex-wrap:wrap;
justify-content:center;"></div> </div> <div style="position:relative;
width:100%; margin-top:1rem;"> <div style="width:100%; display:flex;
margin-bottom:0.25rem;"> <input id="whatHappensNextEl" placeholder="(Optional)
What should happen next?" style="font-size:85%; flex-grow:1;"
oninput="whatHappensNextClearBtn.style.display=(this.value.trim()?'':'none');
localStorage.whatHappensNext=this.value;"
onchange="this.value=this.value.replace(/\n+/g, ' ')"/> <button
id="whatHappensNextClearBtn" onclick="whatHappensNextEl.value='';
this.style.display='none'; localStorage.whatHappensNext='';"
style="display:none; margin-left:0.25rem; font-size:85%;">🗑️</button> </div>
<textarea id="writingInstructionsEl" placeholder="(Optional) Brief writing
instructions for the AI - e.g. general reminders, current story arc, writing
style, emoji usage, things to avoid, etc." style="width:100%; height:80px;
resize:vertical; font-size:85%; display:block;"
oninput="localStorage.writingInstructions=this.value;
writingInstructionsDeleteBtn.dataset.mode='delete';
writingInstructionsDeleteBtn.style.display=(this.value.trim()==='')?'none':'';"
onchange="this.value=this.value.replace(/\n+/g, ' ')"
onkeydown="if(event.keyCode == 13) { event.preventDefault(); }"></textarea>
<script>if(window.innerWidth > 600) writingInstructionsEl.placeholder += " Keep
this text pretty short & concise."</script> <button
id="writingInstructionsDeleteBtn" data-mode="delete"
onclick="if(this.dataset.mode==='delete') {
window.deletedWritingInstructions=writingInstructionsEl.value;
writingInstructionsEl.value=''; this.dataset.mode='undo'; } else {
writingInstructionsEl.value=window.deletedWritingInstructions;
this.dataset.mode='delete'; };
localStorage.writingInstructions=writingInstructionsEl.value;"
style="position:absolute; bottom:0.5rem; right:0.5rem; font-size:60%;
display:none; visibility:hidden; pointer-events:none;"></button> <div
id="responseLengthCtn" style="position:absolute; bottom:0.5rem; width:100%;
height:0;"> <div
onclick="if(!event.composedPath().includes(responseLengthCheckboxEl))
responseLengthCheckboxEl.click()" style="cursor:pointer; display:flex;
align-items:center; border-radius:3px; border:1px solid grey; width:min-content;
background:var(--box-color); padding:0.125rem; position:absolute; bottom:0;
right:0.5rem; font-size:80%;"> <input id="responseLengthCheckboxEl"
type="checkbox" onclick="localStorage.responseLength=this.checked?'2':'1';">
<span style="margin-left:0.25rem; width:max-content; user-select:none;">long
responses</span> </div> </div> <script> if(localStorage.responseLength ===
undefined) localStorage.responseLength = "2"; responseLengthCheckboxEl.checked =
(localStorage.responseLength==="2"); function
hideResponseLengthButtonIfNeeded(event) { const len = window.innerWidth > 500 ?
240 : 120; if(writingInstructionsEl.value.length > len) {
responseLengthCtn.style.display = "none"; // so it doesn't get in the way of
typing } else if(responseLengthCtn.style.display === "none") {
responseLengthCtn.style.display = ""; } }
writingInstructionsEl.addEventListener("input",
hideResponseLengthButtonIfNeeded);
writingInstructionsEl.addEventListener("focus",
hideResponseLengthButtonIfNeeded);
writingInstructionsEl.addEventListener("blur", (event) => {
responseLengthCtn.style.display = ""; }); </script> <style>
#writingInstructionsDeleteBtn[data-mode='delete']:before { content:'🗑️ delete';
} #writingInstructionsDeleteBtn[data-mode='undo']:before { content:'↩️ undo'; }
</style> <script> writingInstructionsEl.addEventListener("focus", function() {
writingInstructionsDeleteBtn.style.opacity = 0;
writingInstructionsDeleteBtn.style.pointerEvents = "none"; });
writingInstructionsEl.addEventListener("blur", function() {
writingInstructionsDeleteBtn.style.opacity = 1;
writingInstructionsDeleteBtn.style.pointerEvents = "auto"; }); </script> </div>
<div style="margin-top:1rem; display:flex; justify-content:center;
align-items:center; flex-wrap:wrap; gap:0.5rem;"> <button
onclick="saveChatDataToUsersDevice()" title="Ctrl+S / Cmd+S"
style="width:max-content; height:min-content;">💾 save this chat</button>
<button onclick="loadChatDataFromUsersDevice()" style="width:max-content;
height:min-content;">📁 load a chat</button> <button id="shareChatBtn"
onclick="generateShareLinkForChat()" style="width:max-content;
height:min-content;">🔗 share this chat</button> </div> <div> <div
id="shareLinkCtn" style="display:none; margin-top:0.5rem;"> <input
style="width:300px;" id="shareChatLinkInputEl"> <button style="min-width:80px;"
onclick="navigator.clipboard.writeText(shareChatLinkInputEl.value).then(r =>
this.innerHTML='✅'); setTimeout(() => this.innerHTML='copy link', 2000);">copy
link</button> <div style="font-size:70%; opacity:0.7;">(this link contains a
snapshot of the chat data at the time the link was generated)</div> </div>
</div> <script> document.addEventListener('keydown', e => { if((e.ctrlKey ||
e.metaKey) && e.key === 's') { e.preventDefault(); // prevent the browser save
dialog from opening saveChatDataToUsersDevice(); } }); </script> </div> <!-- <p
id="newUpdateNoticeEl" style="display:none; font-size:80%; max-width:600px;
margin:1rem auto; text-align:justify; padding:0.5rem;border-radius:3px;
background:#15415f; color:#efefef;"> <b>Edit 1</b>: I've just made a few changes
to hopefully fix some teething issues. Please keep the feedback coming. Also <a
href="https://perchance.org/ai-chat-old1" target="_blank">here's a snapshot of
the old version</a> - please test the old version versus this version (for same
story / chat / situation), and if there are ways in which the old version is
pretty consistently better, please share feedback with as much detail as possibe
(ideally with a share link using the button above if possible). Thanks! <br><br>
<b>Original Announcement:</b> As you might have noticed, I recently released a
small update which adds a couple of quality-of-life fixes, and should hopefully
improve response quality a little. If I broke any features or the responses seem
worse, you can let me know using the feedback button. Please give as much detail
as possible! It's important to contrast it with how it functioned previously and
be very specific about any issues so that I can reproduce the problem - don't
send me on a wild goose chase! </p> <script>if(Number(localStorage.sendCount ||
0) > 500 && Date.now() < 1714021845937) newUpdateNoticeEl.style.display =
"";</script> --> <!-- outro text --> <p id="askForRatingsNoticeEl"
style="display:none; font-size:80%; max-width:600px; margin:1rem auto;
text-align:justify; padding:0 0.5rem;">If an AI response <b>makes you happy
👍</b> or <b>makes you bored or makes mistakes 👎</b>, please rate it! Each vote
helps to improve the AI. There's no need to vote on "average" responses.</p> <ul
style="font-size:80%; max-width:600px; margin:1rem auto; text-align:justify;
padding:0 0.5rem;"> <li style="margin-top:0.5rem;">If you scroll up in the chat
logs of a long chat, you'll see that some special summary messages have been
inserted. Feel free to edit the content of these summaries, but don't move or
delete them. <b>They help extend the AI's memory</b>. If you want to easily get
the full chat text without the summary paragraphs, you can click this button:
<button id="copyChatTextWithoutSummariesBtn"
onclick="copyChatTextToClipboardWithoutSummaries()">📋 copy chat logs without
summaries</button></li> <li style="margin-top:0.5rem;">This page uses your
browser's 'localStorage' to <b>remember your messages, descriptions, etc.</b>
even after you refresh to page. To remove the data, use the delete buttons, or
just manually select the text in the text boxes and delete it. Your chats with
the AI above are <b style="color:#e98721;">not</b> stored on a server. They're
stored privately in your browser/device storage only.</li> </ul> <hr> <p><button
onclick="if(commentsAreaEl.style.display == 'none') {
commentsAreaEl.style.display=''; this.textContent='hide comments'; } else {
commentsAreaEl.style.display='none'; this.textContent='💬 show comments'; }">💬
show comments</button></p> <div id="commentsAreaEl" style="display:none;
margin:1rem auto;"> <div style="display:flex; justify-content:center;
align-items:center;"> <div id="tabbedCommentsCtn_left"
class="tabbed-comments-ctn" style="height:min(70vh, 600px);
justify-content:center; align-items:center; flex-grow:1; min-width: 0;"></div>
<div id="tabbedCommentsCtn" class="tabbed-comments-ctn" style="height:min(70vh,
600px); max-width:min(750px, 100vw); flex-grow: 1; min-width: 0;"></div> <div
id="tabbedCommentsCtn_right" class="tabbed-comments-ctn" style="height:min(70vh,
600px); justify-content:center; align-items:center; flex-grow:1; min-width:
0;"></div> <script> window.sideTabbedCommentsObjs = new Set(); { function
updateMiddleTabModuleWidth() {
if(tabbedCommentsCtn_left.querySelector(".add-tabs-module-button") &&
tabbedCommentsCtn_right.querySelector(".add-tabs-module-button")) { // both side
modules are closed, so prevent it from getting too big:
tabbedCommentsCtn.style.maxWidth = "min(750px, 100vw)";
tabbedCommentsCtn_left.style.width = ""; tabbedCommentsCtn.style.width = "";
tabbedCommentsCtn_right.style.width = ""; } else { // at least one is open, so
make them take up thirds tabbedCommentsCtn.style.maxWidth = "";
tabbedCommentsCtn_left.style.width = "33.3%"; tabbedCommentsCtn.style.width =
"33.3%"; tabbedCommentsCtn_right.style.width = "33.3%"; }
if(tabbedCommentsCtn_left.querySelector(".add-tabs-module-button") ||
tabbedCommentsCtn_right.querySelector(".add-tabs-module-button")) { // at least
one is closed, so restore normal height tabbedCommentsCtn.style.minHeight = "";
tabbedCommentsCtn_left.style.minHeight = "";
tabbedCommentsCtn_right.style.minHeight = ""; } else { // both side panels are
open, so make the whole section taller tabbedCommentsCtn.style.minHeight =
"90vh"; tabbedCommentsCtn_left.style.minHeight = "90vh";
tabbedCommentsCtn_right.style.minHeight = "90vh"; } } function
initSideButton(side) { let buttonHtml = `<button class="add-tabs-module-button"
style="margin-${side==="left" ? "right" : "left"}:0.5rem;">+</button>`; let ctn
= document.querySelector("#tabbedCommentsCtn_"+side); ctn.innerHTML =
buttonHtml; ctn.querySelector("button").onclick = function() { ctn.innerHTML =
""; let obj = root.createCommentsTabs(root.commentsChannels, {
addCloseButton:true, onClose: function() {
window.sideTabbedCommentsObjs.delete(obj); setTimeout(() => {
initSideButton(side); updateMiddleTabModuleWidth(); }, 10); } });
ctn.appendChild(obj.element); window.sideTabbedCommentsObjs.add(obj);
updateMiddleTabModuleWidth(); }; } initSideButton("left");
initSideButton("right"); } </script> </div> <div style="opacity:0.5;
margin-bottom:2rem; margin-top:0.5rem; font-size:80%;">(people claiming to be
admins in this chat are lying)</div> <div id="useAiInOtherChannelsMessageEl"
style="font-size: 70%;margin-bottom: 0.25rem;opacity: 0.5; height:0
position:relative; top:-0.8rem; pointer-events:none; user-select:none;">(use ai
in other channels until your trust score is higher)</div> <div
id="autoCommentCtn" style="display:flex; flex-direction:column; border:1px solid
grey; border-radius:2px; width:min-content; margin:0 auto;"> <div
style="display: flex; align-items: center; justify-content: center;
padding:0.25rem;"> <!-- <div> <div style="font-size:60%; opacity:0.6;
margin-bottom:0.125rem;">(learns from ur past messages)</div> <button
id="autoCommentBtn" onclick="autoComment()">🎰 i'm feelin' lucky</button> </div>
<div style="margin:0.5rem; opacity:0.6;">or</div> --> <div style=" display:
flex; align-items: center; justify-content: center; flex-direction: column;
width:300px; gap:0.25rem"> <!-- <div style="font-size:60%; opacity:0.6;">write
as a character</div> --> <div style="display:flex; gap:0.25rem; width:100%;">
<button id="autoCommentCharBtn"
onclick="autoComment({name:autoCommentCharNameEl.value,
description:autoCommentCharNameDescriptionEl.value})" style="flex-grow:1;
min-width:max-content;">🧝 write as character</button> <select
id="autoCommentCharSelectEl"
onchange="switchToAutoCommentCharByIndex(this.value)" style="max-width:
80px;"></select> </div> <input id="autoCommentCharNameEl"
oninput="saveAutoCommentCharInfo()" placeholder="Character name..."
style="width:100%;"> <textarea id="autoCommentCharNameDescriptionEl"
oninput="saveAutoCommentCharInfo()" placeholder="Character description..."
style="width:100%; height:100px; z-index:5;"></textarea> <script>
window.autoCommentChars = []; if(localStorage.autoCommentChars) { try {
window.autoCommentChars = JSON.parse(localStorage.autoCommentChars); } catch(e)
{ alert(e); } } function saveAutoCommentCharInfo() {
autoCommentCharSelectEl.selectedOptions[0].textContent =
autoCommentCharNameEl.value;
window.autoCommentChars[autoCommentCharSelectEl.value] =
{name:autoCommentCharNameEl.value,
description:autoCommentCharNameDescriptionEl.value};
clearTimeout(window.saveAutoCommentCharInfoDebounceTimeout);
window.saveAutoCommentCharInfoDebounceTimeout = setTimeout(() => {
localStorage.autoCommentChars = JSON.stringify(window.autoCommentChars); },
500); } function switchToAutoCommentCharByIndex(index) { if(index === "#") { //
add a new <option> with an index 1 higher than the current highest: let newIndex
= window.autoCommentChars.length; let selectedOptionEl =
autoCommentCharSelectEl.selectedOptions[0]; selectedOptionEl.value = newIndex;
selectedOptionEl.textContent = "Unnamed"; autoCommentCharNameEl.value = "";
autoCommentCharNameDescriptionEl.value = ""; autoCommentCharSelectEl.innerHTML
+= `<option value="#">Add char...</option>`; autoCommentCharSelectEl.value =
newIndex; localStorage.lastActiveAutoCommentCharIndex = newIndex; } else {
autoCommentCharNameEl.value = window.autoCommentChars[index].name;
autoCommentCharNameDescriptionEl.value =
window.autoCommentChars[index].description; autoCommentCharSelectEl.value =
index; localStorage.lastActiveAutoCommentCharIndex = index; } }
if(window.autoCommentChars.length === 0) {
window.autoCommentChars.push({name:"Unnamed", description:""});
autoCommentCharSelectEl.innerHTML = `<option value="0">Unnamed</option>`; } else
{ autoCommentCharSelectEl.innerHTML = window.autoCommentChars.map((o, i) =>
`<option value="${i}">${o.name}</option>`).join(""); }
autoCommentCharSelectEl.innerHTML += `<option value="#">Add char...</option>`;
switchToAutoCommentCharByIndex(Number(localStorage.lastActiveAutoCommentCharIndex
|| 0) || 0); </script> </div> </div> <div style="display:flex;
justify-content:center; align-items:center; border-top:1px solid grey;
padding:0.25rem;"> <input id="autoCommentAutoSendCheckboxEl"
onchange="localStorage.autoCommentAutoSendCheckboxValue=this.checked?'1':''; "
style="cursor:pointer;" type="checkbox"> <div
onclick="autoCommentAutoSendCheckboxEl.click()" style="margin-left:0.25rem;
cursor:pointer; user-select:none;">auto-send?</div>
<script>autoCommentAutoSendCheckboxEl.checked =
!!localStorage.autoCommentAutoSendCheckboxValue</script> </div> </div> <div
style="margin-top:1rem; font-size: 80%;"><a href="/ai-group-chat"
target="_blank">a link to a page with just the ai group chat thing</a></div>
<style> @media only screen and (max-width: 650px) { #autoCommentCtn {
flex-direction:column; } } </style> </div> <style> #tabbedCommentsCtn_left {
display:none; } #tabbedCommentsCtn_right { display:none; } @media only screen
and (min-width: 1100px) { #tabbedCommentsCtn_left { display:flex; }
#tabbedCommentsCtn_right { display:flex; } } </style> <p style="font-size:80%;
max-width:700px; width:95%; margin:0 auto; text-align:justify;"><b
style="color:red;">Note</b>: I've decided to hide the comments by default for
now because I noticed that there were some trolls and I don't have time to
moderate right now. I'll try to sweep through and permaban spammers, trolls,
bullies, racists, homophobes, etc. as often as possible, but in the meantime I
suggest that you <b>immediately block trolls, bullies, and shady people. Do not
talk to them.</b> Please encourage other chat participants to block them instead
of engaging with them.</p> <p style="font-size:80%; max-width:700px; width:95%;
margin:0.5rem auto; text-align:justify;"><b>Note:</b> If you are reporting a
bug/issue in the feedback, please give as much detail as you can. For example,
if it's not working, then was it working originally? If it never worked, what
browser are you using? And on what operating system? E.g. "Chrome on Windows 10"
or "Chrome on Chromebook". If it was originally working, but then stopped
working suddenly, then try deleting some messages to see if it has something to
do with the length of the chat. Also try refreshing the page, and let me know if
that didn't help. Is the "Loading..." thing in the top-right corner of the page?
The more details you add, the quicker I can fix it :) Thank you to the pioneers
who are testing this! There'll likely be a few annoying issues while this plugin
is new.</p> <br> <!-- COMMENTS STUFF --> <script> function
createCommentsSectionHtml() { tabbedCommentsCtn.innerHTML = ""; // this function
is called again on switch to dark/light mode, so we need to clear any existing
HTML. let obj = root.createCommentsTabs(root.commentsChannels,
{tabFocusControlsAutoCommentCtn:true}); window.mainTabbedCommentsObj = obj;
window.mainTabbedCommentsChannelNameToObj = obj.commentsChannelNameToObj;
tabbedCommentsCtn.appendChild(obj.element); } createCommentsSectionHtml();
</script> <script> function chatDataChangedHandler() {
shareChatBtn.style.display = ""; shareLinkCtn.style.display = "none"; }
botNameEl.addEventListener("keydown", chatDataChangedHandler);
userNameEl.addEventListener("keydown", chatDataChangedHandler);
botDescriptionEl.addEventListener("keydown", chatDataChangedHandler);
userDescriptionEl.addEventListener("keydown", chatDataChangedHandler);
scenarioEl.addEventListener("keydown", chatDataChangedHandler);
chatLogsEl.addEventListener("keydown", chatDataChangedHandler);
writingInstructionsEl.addEventListener("keydown", chatDataChangedHandler);
sendMessageBtn.addEventListener("click", chatDataChangedHandler);
writingInstructionsDeleteBtn.addEventListener("click", chatDataChangedHandler);
regenPrevButton.addEventListener("click", chatDataChangedHandler);
regenMessageBtn.addEventListener("click", chatDataChangedHandler);
regenNextButton.addEventListener("click", chatDataChangedHandler);
undoRegenMessageCtn.addEventListener("click", chatDataChangedHandler);
scenarioDeleteBtn.addEventListener("click", chatDataChangedHandler);
generateScenarioBtn.addEventListener("click", chatDataChangedHandler);
generateUserDescriptionBtn.addEventListener("click", chatDataChangedHandler);
userDescriptionDeleteBtn.addEventListener("click", chatDataChangedHandler);
generateBotDescriptionBtn.addEventListener("click", chatDataChangedHandler);
botDescriptionDeleteBtn.addEventListener("click", chatDataChangedHandler);
</script> <script> function trackCaretPosition(textArea, callback, opts={}) {
function createCopy(textArea) { let copy = document.createElement('div'); let
style = getComputedStyle(textArea); let propertiesToCopy = ['overflow-x',
'overflow-y', 'display', 'font-family', 'font-size', 'font-weight', 'word-wrap',
'white-space', 'padding-left', 'padding-right', 'padding-top', 'padding-bottom',
'border-left-width', 'border-top-width', 'border-right-width',
'border-bottom-width', 'border-style', 'text-align', 'box-sizing',
'scrollbar-gutter']; propertiesToCopy.forEach(key => copy.style[key] =
style[key]); Object.assign(copy.style, { position: 'absolute', left:
`${textArea.offsetLeft}px`, top: `${textArea.offsetTop}px`, });
document.body.appendChild(copy); return copy; } textArea.style.overflow =
"auto"; // even though this is what textareas 'do', they don't have this value
by default (tho perchance normalize css does add it, so this is just for
robustness) let copy = createCopy(textArea); copy.style.visibility = 'hidden';
// copy.style.pointerEvents = 'none'; // copy.style.color = 'red'; //
copy.style.opacity = '0.3'; function updateShadowPositionAndSize() { let rect =
textArea.getBoundingClientRect(); let scrollLeft = window.pageXOffset ||
document.documentElement.scrollLeft; let scrollTop = window.pageYOffset ||
document.documentElement.scrollTop; copy.style.left = `${rect.left +
scrollLeft}px`; copy.style.top = `${rect.top + scrollTop}px`; copy.style.width =
`${textArea.offsetWidth}px`; copy.style.height = `${textArea.offsetHeight}px`;
copy.scrollTop = textArea.scrollTop; } function update() {
if(!document.activeElement === textArea) { return; }
if(opts.onlyComputePositionWhenAtEndOfText) { // this option is for performance
optimization let thereIsOnlyWhiteSpaceAfterCaret =
/^\s*$/.test(textArea.value.slice(textArea.selectionEnd));
if(!thereIsOnlyWhiteSpaceAfterCaret) { callback(null); return; } } let
startTime; if(window.performance?.now) startTime = performance.now();
updateShadowPositionAndSize(); const position = getCaretPosition(textArea,
copy); callback(position); if(startTime) { let timeTaken =
performance.now()-startTime; if(timeTaken > 50) { console.warn(`Took
${timeTaken}ms to track caret position for 'continue' button.`) } } } let
debounceTimeoutLength = 100; if(textArea.value.length > 100000)
debounceTimeoutLength = 400; setInterval(() => { if(textArea.value.length >
100000) debounceTimeoutLength = 400; }, 10000); let updateDebounceTimeout =
null; textArea.addEventListener('input', function() {
clearTimeout(updateDebounceTimeout); updateDebounceTimeout = setTimeout(() =>
update(), debounceTimeoutLength); }); textArea.addEventListener('click',
function() { clearTimeout(updateDebounceTimeout); updateDebounceTimeout =
setTimeout(() => update(), debounceTimeoutLength); });
window.addEventListener('mouseup', function() {
clearTimeout(updateDebounceTimeout); updateDebounceTimeout = setTimeout(() =>
update(), debounceTimeoutLength); }); textArea.addEventListener('scroll',
function() { clearTimeout(updateDebounceTimeout); updateDebounceTimeout =
setTimeout(() => update(), debounceTimeoutLength); });
window.addEventListener('resize', function() {
clearTimeout(updateDebounceTimeout); updateDebounceTimeout = setTimeout(() => {
updateShadowPositionAndSize(); update(); }, 10); });
textArea.addEventListener('keydown', function(e) { if(["ArrowRight",
"ArrowLeft", "ArrowUp", "ArrowDown"].includes(e.key)) {
clearTimeout(updateDebounceTimeout); updateDebounceTimeout = setTimeout(() =>
update(), debounceTimeoutLength); } }); let resizeObserver = new
ResizeObserver(() => updateShadowPositionAndSize());
resizeObserver.observe(textArea); let mutationObserver = new
MutationObserver(mutations => { for(const mutation of mutations) {
if(Array.from(mutation.removedNodes).includes(textArea)) { copy.remove();
mutationObserver.disconnect(); resizeObserver.disconnect(); } } });
mutationObserver.observe(textArea.parentNode, { childList: true }); function
getCaretPosition(textArea, copy) { let { selectionStart, selectionEnd } =
textArea; let value = textArea.value; let phantomNewline = false;
if(selectionStart === selectionEnd && value[selectionStart - 1] === '\n') {
phantomNewline = true; } copy.textContent = phantomNewline ? value.substring(0,
selectionStart) + ' ' + value.substring(selectionStart) : value;
if(!copy.firstChild) { let style = getComputedStyle(textArea); return { x:
textArea.offsetLeft + parseFloat(style.paddingLeft), y: textArea.offsetTop +
parseFloat(style.paddingTop), }; } let range = document.createRange();
range.setStart(copy.firstChild, phantomNewline ? selectionStart + 1 :
selectionStart); range.setEnd(copy.firstChild, phantomNewline ? selectionStart +
1 : Math.min(selectionEnd, selectionStart + 1)); //
range.setEnd(copy.firstChild, phantomNewline ? selectionEnd + 1 : selectionEnd);
const rect = range.getBoundingClientRect(); const scrollLeft =
window.pageXOffset || document.documentElement.scrollLeft; const scrollTop =
window.pageYOffset || document.documentElement.scrollTop; return { x: rect.left
+ scrollLeft, // - textArea.scrollLeft, y: rect.top + scrollTop, // -
textArea.scrollTop, }; } } window.continueButtonClickHandler = function() { //
NOTE: this handler is also used for Tab key press event when the continue button
is visible. if(chatLogsEl.selectionStart !== chatLogsEl.selectionEnd &&
chatLogsEl.value.slice(chatLogsEl.selectionEnd).trim() === "") { // they
highlighted some text at the end of the chat logs and then clicked the continue
button that appears above it chatLogsEl.value = chatLogsEl.value.slice(0,
chatLogsEl.selectionStart); } handleSendButtonClick({mode:'continue'}); };
if(typeof continueMessageBtn === "undefined") { // <-- just so, while generator
is being edited, multiple buttons aren't created let tmp =
document.createElement("div"); tmp.innerHTML = `<button id="continueMessageBtn"
style="position:absolute; cursor:pointer; font-size:65%; display:none;">▶️<span
id="continueMessageBtnTabLabel" style="display:none;"> (tab)</span></button>`;
let btn = tmp.firstElementChild; btn.onmousedown =
window.continueButtonClickHandler; document.body.append(btn); // must be in the
body, so it's position is relative to the body, and not e.g. the chatLogs
container element } chatLogsEl.addEventListener('keydown', function(e) {
if(e.key === 'Tab') { e.preventDefault(); if(continueMessageBtn.offsetHeight !==
0) { localStorage.haveUsedTabToContinueMessage = "1";
continueMessageBtnTabLabel.style.display = "none";
window.continueButtonClickHandler(); // if it's visible, and they press tab,
then click it for them } } else { continueMessageBtn.style.display = 'none'; }
}); trackCaretPosition(chatLogsEl, pos => { if(!pos) { // since we passed
onlyComputePositionWhenAtEndOfText:true, we get updates when cursor moves, but
only get position if at end of text (for performance reasons)
continueMessageBtn.style.display = 'none'; return; }
if(!window.chatLogsLineHeightPixels) { window.chatLogsLineHeightPixels =
getLineHeightInPixels(chatLogsEl); } // console.log(pos.x, pos.y); // let
textAfterSelectionStart = chatLogsEl.value.slice(chatLogsEl.selectionStart); let
textAfterSelectionEnd = chatLogsEl.value.slice(chatLogsEl.selectionEnd); let
thereIsOnlyWhiteSpaceAfterCaret = /^\s*$/.test(textAfterSelectionEnd); // let
selectionStartIsWithinLastFewMessages = textAfterSelectionStart.length < 3000;
// let thereIsNoTextAfterSelectionEnd = textAfterSelectionEnd.trim().length ===
0; if(document.activeElement === chatLogsEl && thereIsOnlyWhiteSpaceAfterCaret
&& pageXYIsInsideElement(pos.x, pos.y, chatLogsEl)) {
continueMessageBtn.style.display = 'block'; let buttonHeight =
continueMessageBtn.offsetHeight; if(chatLogsEl.selectionStart ==
chatLogsEl.selectionEnd) { continueMessageBtn.style.left = `${pos.x + 10}px`;
continueMessageBtn.style.top = `${pos.y -
0.5*(buttonHeight-chatLogsLineHeightPixels)}px`; } else {
continueMessageBtn.style.left = `${pos.x}px`; continueMessageBtn.style.top =
`${pos.y - buttonHeight*1.3 - 0.5*(buttonHeight-chatLogsLineHeightPixels)}px`; }
} else { continueMessageBtn.style.display = 'none'; } },
{onlyComputePositionWhenAtEndOfText:true}); chatLogsEl.addEventListener('blur',
() => { continueMessageBtn.style.display = 'none'; });
document.addEventListener('click', (event) => { if(event.target !== chatLogsEl)
{ continueMessageBtn.style.display = 'none'; // not sure why this is required in
Chrome Android (blur event handler should be enough) } }); function
getLineHeightInPixels(element) { const style = window.getComputedStyle(element);
let lineHeight = style.lineHeight; if(lineHeight === 'normal') { // Normal line
heights are usually 1.2 times the font size const fontSize =
parseFloat(style.fontSize); lineHeight = fontSize * 1.2; } else { lineHeight =
parseFloat(lineHeight); } return lineHeight; } function pageXYIsInsideElement(x,
y, element) { const { left, top, right, bottom } =
element.getBoundingClientRect(); return x >= left + window.pageXOffset && x <=
right + window.pageXOffset && y >= top + window.pageYOffset && y <= bottom +
window.pageYOffset; } </script> <script> (async function() { let hash =
window.location.hash.slice(1); if(hash && hash.startsWith("data=")) { let result
= await loadDataFromUrlHash(); if(!result.success) {
loadChatDataFromLocalStorage(); } } else { loadChatDataFromLocalStorage(); }
updateDeleteButtonVisibility(); updateCharacterNameViews();
sendAsCharacterCtn.style.display = inputEl.value.trim() ? 'flex' : 'none';
update(); updateVisibilityOfReplyButtonsAndSelectors();
if(whatHappensNextEl.value.trim()) { whatHappensNextClearBtn.style.display=''; }
chatLogsEl.scrollTop = 999999999; // scroll to the bottom of the chat logs
setTimeout(() => { chatLogsEl.scrollTop = 999999999; }, 3000); // <-- because ad
load on mobile causes screen height to change which causes text area height to
change. not full-proof, but it'll do for now })(); </script> <!-- DARK MODE
STUFF --> <div style="position:fixed; bottom:0.5rem; left:0.5rem; z-index:10;">
<button id="darkModeBtn" style="cursor:pointer;"
onclick="window.toggleManualDarkMode();
createCommentsSectionHtml();">🌃</button> <div
style="display:inline-block;">[fullscreenButton("&nbsp;&nbsp;⇱&nbsp;&nbsp;",
"&nbsp;&nbsp;⇲&nbsp;&nbsp;")]</div> </div> <script> function
toggleManualDarkMode() { let newColorScheme = (getCurrentColorScheme() ===
"dark" ? "light" : "dark"); localStorage.forceColorScheme = newColorScheme;
setColorScheme(newColorScheme); // if chosen mode matches current OS default, we
remove manual "forced" mode: let systemColorScheme = window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light";
if(systemColorScheme === newColorScheme) {
localStorage.removeItem("forceColorScheme"); } } function
getCurrentColorScheme() { if(localStorage.forceColorScheme !== undefined) {
return localStorage.forceColorScheme; } else { return window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light"; }
} function setColorScheme(scheme) { if(scheme !== "dark" && scheme !== "light")
throw new Error("scheme should be 'light' or 'dark'");
document.querySelector("#darkModeBtn").textContent = (scheme === "dark" ? "🌄" :
"🌃"); if(scheme === "dark") { document.documentElement.style.colorScheme =
"dark"; document.body.style.color = "#d8d4cf";
document.body.style.backgroundColor = "#131516";
document.documentElement.style.setProperty('--box-color', '#2a2a2a');
document.documentElement.style.setProperty('--active-comment-channel-tab-color',
'#5b5b5b'); } else { document.documentElement.style.colorScheme = "light";
document.body.style.color = "black"; document.body.style.backgroundColor =
"white"; document.documentElement.style.setProperty('--box-color', '#ebebeb');
document.documentElement.style.setProperty('--active-comment-channel-tab-color',
'#c6c6c6'); } } // during page load, set the chosen mode based on localStorage
value if it exists: if(localStorage.forceColorScheme !== undefined) {
setColorScheme(localStorage.forceColorScheme); } else { // user has not manually
overwritten, so we use OS default: let systemIsInDarkMode = !!(window.matchMedia
&& window.matchMedia('(prefers-color-scheme: dark)').matches);
setColorScheme(systemIsInDarkMode ? "dark" : "light"); } </script> <!-- FEEDBACK
STUFF --> <div style="position:fixed; bottom:0.5rem; right:0.5rem;
text-align:right; z-index:100;"> <div id="feedbackCommentsCtn"></div> <button
id="feedbackBtn893745ykfuhd" onclick="if(feedbackCommentsCtn.innerHTML.length
=== 0) { feedbackCommentsCtn.innerHTML=generateFeedbackCommentsHtml();
this.innerHTML='✖ close'; } else { feedbackCommentsCtn.innerHTML='';
this.innerHTML='🗨️ feedback'; }">🗨️ feedback</button> </div> <script> function
generateFeedbackCommentsHtml() { let options = {channel:"feedback",
hideComments:location.hash.includes("#showfeedback") ||
localStorage.showFeedback ? false : true,
height:location.hash.includes("#showfeedback") || localStorage.showFeedback ?
500 : 180, commentPlaceholderText: "Share some feedback with the dev. Do not
share personal info here, this feedback data is public.", submitButtonText:
"submit feedback", hideFullscreenButton:true, hideSettingsButton:true};
if(localStorage.forceColorScheme) options.forceColorScheme =
localStorage.forceColorScheme; return root.commentsPlugin(options); } </script>
<style> button:disabled { filter: grayscale(1) !important; /* since firefox
doesn't seem to change emoji colors to indicate disabledness - only affects text
*/ } </style> <script> try { let isTouchScreen = window.matchMedia("(pointer:
coarse)").matches; let isSafari = navigator.vendor &&
navigator.vendor.indexOf('Apple') > -1 && navigator.userAgent &&
navigator.userAgent.indexOf('CriOS') == -1 &&
navigator.userAgent.indexOf('FxiOS') == -1; if(isSafari && window.innerWidth <
800 && isTouchScreen) { let viewportMetaEl =
document.querySelector("[name=viewport]");
if(!viewportMetaEl.getAttribute("content").includes("maximum-scale")) {
viewportMetaEl.setAttribute("content", viewportMetaEl.getAttribute("content") +
", maximum-scale=1"); } whatHappensNextEl.style.fontSize = "16px";
inputEl.style.fontSize = "16px"; console.log("Safari iOS detected. Added
maximum-scale attribute and minimum font sizes to prevent zooming when clicking
textarea with small font size:", viewportMetaEl.getAttribute("content")); } }
catch(e) { console.error(e); } </script> <script>
if(window.location.href.includes("focus_lost_debug")) {
botDescriptionEl.addEventListener("focus", (event) => {
botDescriptionEl.style.backgroundColor = "green"; });
botDescriptionEl.addEventListener("blur", (event) => {
botDescriptionEl.style.backgroundColor = "red"; }); } </script> <script>
setTimeout(async () => { let persistent = await navigator.storage.persist();
if(persistent) console.log("Storage will not be cleared except by explicit user
action."); else console.warn("Storage may be cleared by the browser under
storage pressure."); }, 1000*60*15); </script>

<p style="font-size:80%; max-width:700px; width:95%; margin:0.5rem auto; text-align:justify;"><b>Note:</b> If you are reporting a bug/issue in the feedback, please give as much detail as you can. For example, if it's not working, then was it working originally? If it never worked, what browser are you using? And on what operating system? E.g. "Chrome on Windows 10" or "Chrome on Chromebook". If it was originally working, but then stopped working suddenly, then try deleting some messages to see if it has something to do with the length of the chat. Also try refreshing the page, and let me know if that didn't help. Is the "Loading..." thing in the top-right corner of the page? The more details you add, the quicker I can fix it :) Thank you to the pioneers who are testing this! There'll likely be a few annoying issues while this plugin is new.</p>






The items listed below aren't errors - they're just "warnings". Perchance
generates "warnings" when it detects code in your editor panel that looks like
it may be a mistake, but which is technically not "erroneous" - that is, it's
valid Perchance syntax, but it's "unusual" code and so might have been an
accident on your part. Feel free to ignore these warnings if you know what
you're doing! ꒰•ᴗ•꒱

close


sign up or login to create your own generators ᕕ(ᐛ)ᕗ we only email you at your
request (e.g. for password reset) 👍︎

there was a problem connecting to the server ¯\_(⊙_ʖ⊙)_/¯ check your internet
connection?

that password is not correct (⊙.☉)7 forgot it?

that password is too short (⌐■_■) >6 chars plz

something seems wrong about that email address (⊙︿⊙)

too many requests (this can sometimes be caused by VPN browser extensions)

something went wrong on the server (✖╭╮✖) plz post to forum if problem persists


cancelsignup / login

loading...

thanks for signing up! (^▽^) we just sent a verification code to (check spam
folder too) 🌺︎

there was a problem connecting to the server ¯\_(⊙_ʖ⊙)_/¯ check your internet
connection?

that code is not correct (⊙.☉)7 pls ensure you copied it exactly 👌︎

something went wrong on the server (✖╭╮✖) pls post to forum if problem persists


backverify

you're logged in! s(^‿^)-b

close

forgot your password? o(╥﹏╥)o that's all right! just enter the email you used to
sign up and you'll be sent a reset code ᵔᴥᵔ

there was a problem connecting to the server ¯\_(⊙_ʖ⊙)_/¯ check your internet
connection?

very spooky: that account was not found (⊙.☉)7 are you sure you put in the
correct email? 👻︎

something went wrong on the server (✖╭╮✖) pls post to forum if problem persists


backrequest reset code

we just sent a password reset code to 💌 when you recieve it (check your spam
folder too) enter it below and then enter a new password of your choosing
๑۞︎๑,¸¸,ø¤º°`°๑

there was a problem connecting to the server ¯\_(⊙_ʖ⊙)_/¯ check your internet
connection?

that code is not correct (⊙.☉)7 pls ensure you copied it exactly 👌︎

that password is too short (⌐■_■) >6 chars plz

something went wrong on the server (✖╭╮✖) pls post to forum if problem persists


backset password


you're logged in as - you can:

 * view your generators
 * change your password
 * change your email
 * logout


close

changing your password is easy (~‾▿‾)~ just enter your current password and your
new password •ᴗ•

there was a problem connecting to the server ¯\_(⊙_ʖ⊙)_/¯ check your internet
connection?

the current password is not correct (⊙.☉)7

that password is too short (⌐■_■) >7 chars plz

something went wrong on the server (✖╭╮✖) plz post to forum if problem persists


backset new password

your password has been changed V●ᴥ●V


close

your current email is ᶘ ᵒᴥᵒᶅ to change your email, just enter your password and
your new email 。^‿^。

there was a problem connecting to the server ¯\_(⊙_ʖ⊙)_/¯ check your internet
connection?

that password is not correct (⊙.☉)7

that new email address looks weird (⌐■_■)

something went wrong on the server (✖╭╮✖) plz post to forum if problem persists


backset new email

your email hand been changed (๑>ᴗ<๑)


close
🔤

there was a problem loading your generators ¯\_(⊙_ʖ⊙)_/¯ check your internet
connection? if the problem persists, please post to forum


back

loading...



you're viewing your generator with the url ai-chat - you can:

 * change its url
 * duplicate it
 * make public
 * download it
 * delete it


close

this generator's current url is: ai-chat

to change it, just enter a new one below ~(˘▾˘~) remember: you can only use
lower-case letters, numbers and hyphens in your url

caution: if you change it, the old url will no longer work! if your generator is
popular, and others have imported it into their own, you will break their
generators! (they will get import errors). because of this, if your generator is
popular, it's better to make a copy of this generator rather than change this
one's name ┌U・ェ・U┘

sorry, the new url must be at least 4 characters long

there was a problem connecting to the server ¯\_(⊙_ʖ⊙)_/¯ check your internet
connection?

sorry, a generator with that name already exists (⌐■_■)

something went wrong on the server (✖╭╮✖) plz post to forum if problem persists


backset new url

your generator's url has been changed ヾ(⌐■_■)ノ♪♬


close

loading...



if you click the button below, it will load a list of older versions of your
generator so you can download them in case you accidentally deleted your code,
or there was a system error. copies of your generator code are also backed-up to
your local browser storage, so if your computer ever crashes and you hadn't
saved in a while, you'll be able to come here to recover your data.

load backup/revision history



loading...

hmm (⊙_☉) there was some sort of server error while trying to get your revision
history. sorry! this doesn't happen very often. if it keeps happening (and
you've checked your internet connection), could you please make a post on the
forum?


close

close


AI Roleplay Chat / Chatbot AI Story Writer AI Image Generator AI Anime Generator
AI Human Generator AI Photo Generator AI Character Description Generator AI Text
Adventure AI Text Generator AI Poem Generator AI Lyrics Generator AI Meme Maker
AI Fanfic Generator AI Character Chat AI Story Outline/Plot Generator AI Text
Rewriter AI Adventure Game AI Story Generator With Pictures AI Coding Helper
Psychologist AI Therapist AI AI Code Generator AI Group Chat AI Insult Generator
AI Rap Lyrics Generator AI Chapter Name Generator Silly Light Novel Title
Generator AI Caption Generator Using Keywords AI Book Title Generator Using
Keywords AI Sad Book Title Generator AI Script Generator AI RPG AI Plot
Generator
why ads?