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
Submission: On July 27 via manual from US — Scanned from DE
Form analysis
0 forms found in the DOMText 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, "<").replace(/"/g, """)}"></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, """)}" 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%);">×</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 & 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. 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: Narrator: Later that day... 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(" ⇱ ", " ⇲ ")]</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?