├── assets ├── response.png └── settings.png ├── manifest.json ├── settings.html ├── style.css ├── README.md └── index.js /assets/response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitorfdl/Sillytavern-CYOA/HEAD/assets/response.png -------------------------------------------------------------------------------- /assets/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vitorfdl/Sillytavern-CYOA/HEAD/assets/settings.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "display_name": "Sillytavern-CYOA", 3 | "loading_order": 15, 4 | "requires": [], 5 | "optional": [], 6 | "js": "index.js", 7 | "css": "style.css", 8 | "author": "@vitorfdl", 9 | "version": "1.6", 10 | "homePage": "https://github.com/vitorfdl/Sillytavern-CYOA" 11 | } 12 | -------------------------------------------------------------------------------- /settings.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | CYOA Responses 5 |
6 |
7 |
8 |
9 | 10 | Use {{suggestionNumber}} for the Number of Responses. 11 | 12 |
13 |
14 |
15 | 16 | Use {{suggestionText}} for the text from the chosen context. 17 | 18 |
19 |
20 | 24 |
25 | 26 |
27 | 28 |
29 |
30 | 31 |
32 | 33 |
34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | .mes_text .custom-suggestions { 2 | display: flex; 3 | flex-direction: column; 4 | gap: 0.5em; 5 | margin: 1em 0.5em; 6 | padding: 0.5em; 7 | border: 2px solid var(--SmartThemeBorderColor); 8 | } 9 | 10 | .mes_text .custom-suggestions:before { 11 | content: "How does the story continue?"; 12 | font-weight: bold; 13 | display: block; 14 | text-align: center; 15 | font-size: 1em; 16 | margin-bottom: 0.5em; 17 | } 18 | 19 | .mes_text .custom-suggestions > .custom-suggestion { 20 | display: flex; 21 | } 22 | 23 | .mes_text .custom-suggestions > .custom-suggestion button { 24 | color: var(--SmartThemeBodyColor); 25 | background-color: var(--black50a); 26 | border: 1px solid var(--SmartThemeBorderColor); 27 | border-radius: 5px; 28 | padding: 0.75em; 29 | margin: 0.25em 0; 30 | font-size: 1em; 31 | opacity: 0.5; 32 | cursor: pointer; 33 | transition: opacity 0.3s; 34 | align-items: center; 35 | justify-content: center; 36 | text-align: center; 37 | } 38 | 39 | 40 | .mes_text .custom-suggestions > .custom-suggestion button:hover { 41 | opacity: 1; 42 | } 43 | 44 | .mes_text .custom-suggestions > .custom-suggestion button.custom-suggestion { 45 | flex: 1 1 auto; 46 | } 47 | 48 | .mes_text .custom-suggestions > .custom-suggestion button.custom-edit-suggestion { 49 | flex: 0 0 auto; 50 | } 51 | 52 | .mes_text .custom-suggestions > .custom-suggestion button.custom-edit-suggestion .custom-text { 53 | display: none; 54 | } 55 | 56 | @media (max-width: 1200px) { 57 | .mes_text .custom-suggestions { 58 | margin: 0.2em 0; 59 | padding: 0.5em; 60 | gap: 0.5em; 61 | } 62 | 63 | .mes_text .custom-suggestions:before { 64 | font-size: 1em; 65 | margin-bottom: 0.3em; 66 | } 67 | 68 | .mes_text .custom-suggestions > .custom-suggestion { 69 | flex-direction: column; 70 | } 71 | 72 | .mes_text .custom-suggestions > .custom-suggestion button { 73 | font-size: 0.9em; 74 | padding: 0.75em; 75 | margin: 0.1em 0; 76 | width: 100%; 77 | } 78 | 79 | .mes_text .custom-suggestions > .custom-suggestion button.custom-edit-suggestion { 80 | margin-top: 0.2em; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SillyTavern CYOA Responses Extension 2 | 3 | > **⚠️ ARCHIVE NOTICE: This repository has been archived and is no longer being maintained. My current work is now focused on the [NarratrixAI App](https://narratrixai.com).** 4 | 5 | --- 6 | 7 | # SillyTavern CYOA Responses Extension 8 | This extension adds Choose Your Own Adventure (CYOA) style responses to your SillyTavern chats. It generates multiple response options for the user to choose from, enhancing interactivity and allowing for branching narratives. 9 | 10 |

11 | 12 |

13 | 14 | ## Features 15 | - Generates multiple response options for user selection 16 | - Customizable number of response options 17 | - Impersonation of selected responses 18 | - Slash command integration for quick access 19 | - Customizable prompts for option generation and impersonation 20 | 21 | ## Installation 22 | Use SillyTavern's built-in extension installer with this URL: 23 | 24 | ## Usage 25 | 1. Open the extension settings in SillyTavern. 26 | 2. Configure the LLM prompts for option generation and impersonation. 27 | 3. Set the desired number of response options. 28 | 4. Use the `/cyoa` slash command in chat to generate CYOA options. 29 | 30 | ### CYOA Buttons 31 | After generating CYOA options, you'll see a set of buttons for each suggestion: 32 | - **Suggestion Button**: Clicking on the main suggestion button will automatically impersonate the selected option. This means the AI will act as if the user had chosen that particular story beat. 33 | - **Edit Button**: Next to each suggestion is an edit button (pencil icon). Clicking this will copy the suggestion text to the input area, allowing you to modify it before sending or use it as inspiration for your own response. 34 | 35 | ### Settings 36 |

37 | 38 |

39 | 40 | - **LLM Prompt for Options**: Customize the prompt used to generate CYOA response options. You can use `{{suggestionNumber}}` as a placeholder for the number of responses. 41 | - You must ensure the LLM response contains each suggestion between `` tags. The plugin also support `< suggestion >` and `Suggestion N: text...` as valid tags. 42 | - **LLM Prompt for Impersonation**: Set the prompt used when impersonating the selected response. Use `{{suggestionText}}` as a placeholder for the chosen option's text. 43 | - **Apply World Info / Author's Note**: Toggle to include World Info and Author's Note in the CYOA generation process. 44 | - **Number of Responses**: Adjust the slider to set how many CYOA options are generated (1-10). 45 | 46 | ### LLM Prompt Examples: 47 | ``` 48 | Stop the roleplay now and provide a response with {{suggestionNumber}} brief distinct single-sentence suggestions for the next story beat on {{user}} perspective. Ensure each suggestion aligns with its corresponding description: 49 | 1. Eases tension and improves the protagonist's situation 50 | 2. Creates or increases tension and worsens the protagonist's situation 51 | 3. Leads directly but believably to a wild twist or super weird event 52 | 4. Slowly moves the story forward without ending the current scene 53 | 5. Pushes the story forward, potentially ending the current scene if feasible' 54 | 55 | Each suggestion surrounded by `` tags. E.g: 56 | suggestion_1 57 | suggestion_2 58 | ... 59 | 60 | Do not include any other content in your response. 61 | ``` 62 | 63 | ### Impersonate Prompt Example: 64 | ``` 65 | [Event Direction for the next story beat on {{user}} perspective: {{suggestionText}}] 66 | [Based on the expected events, write the user response] 67 | ``` 68 | 69 | ## Slash Command 70 | The extension exposes the `/cyoa` slash command, which can be added to SillyTavern's QuickSettings for easy access. This command triggers the generation of CYOA response options. 71 | 72 | ## Prerequisites 73 | - SillyTavern version 1.12.4 or higher 74 | 75 | ## Support and Contributions 76 | For support or questions, use the github issues or join the SillyTavern discord server. 77 | 78 | Contributions to improve this extension are welcome. Please submit pull requests or issues on the GitHub repository. 79 | 80 | ## Credits 81 | This extension was inspired by the work of LenAnderson. Original idea: https://gist.github.com/LenAnderson/7686604c9da30dee21b76a633a0027f4 82 | 83 | ## License 84 | No License, feel free to use this extension for whatever you want. 85 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import { 2 | extension_settings, 3 | getContext, 4 | } from "../../../extensions.js"; 5 | 6 | import { saveSettingsDebounced, 7 | setEditedMessageId, 8 | generateQuietPrompt, 9 | is_send_press, 10 | substituteParamsExtended, 11 | } from "../../../../script.js"; 12 | 13 | import { SlashCommandParser } from '../../../slash-commands/SlashCommandParser.js'; 14 | import { SlashCommand } from '../../../slash-commands/SlashCommand.js'; 15 | import { getMessageTimeStamp } from '../../../RossAscends-mods.js'; 16 | import { MacrosParser } from '../../../macros.js'; 17 | import { is_group_generating, selected_group } from '../../../group-chats.js'; 18 | 19 | const extensionName = "Sillytavern-CYOA"; 20 | const extensionFolderPath = `scripts/extensions/third-party/${extensionName}`; 21 | const defaultSettings = { 22 | enabled: false, 23 | llm_prompt: `Stop the roleplay now and provide a response with {{suggestionNumber}} brief distinct single-sentence suggestions for the next story beat on {{user}} perspective. Ensure each suggestion aligns with its corresponding description: 24 | 1. Eases tension and improves the protagonist's situation 25 | 2. Creates or increases tension and worsens the protagonist's situation 26 | 3. Leads directly but believably to a wild twist or super weird event 27 | 4. Slowly moves the story forward without ending the current scene 28 | 5. Pushes the story forward, potentially ending the current scene if feasible 29 | 30 | Each suggestion surrounded by \`\` tags. E.g: 31 | suggestion_1 32 | suggestion_2 33 | ... 34 | 35 | Do not include any other content in your response.`, 36 | llm_prompt_impersonate: `[Event Direction for the next story beat on {{user}} perspective: \`{{suggestionText}}\`] 37 | [Based on the expected events, write the user response]`, 38 | apply_wi_an: true, 39 | num_responses: 5, 40 | response_length: 500, 41 | }; 42 | let inApiCall = false; 43 | 44 | /** 45 | * Parses the CYOA response and returns the suggestions buttons 46 | * @param {string} response 47 | * @returns {string} text 48 | */ 49 | function parseResponse(response) { 50 | const suggestions = []; 51 | const regex = /(.+?)<\/suggestion>|Suggestion\s+\d+\s*:\s*(.+)|Suggestion_\d+\s*:\s*(.+)|^\d+\.\s*(.+)/gim; 52 | let match; 53 | 54 | while ((match = regex.exec(`${response}\n`)) !== null) { 55 | const suggestion = match[1] || match[2] || match[3] || match[4]; 56 | if (suggestion && suggestion.trim()) { 57 | suggestions.push(suggestion.trim()); 58 | } 59 | } 60 | 61 | if (suggestions.length === 0) { 62 | return; 63 | } 64 | 65 | const newResponse = suggestions.map((suggestion) => 66 | `
`); 67 | return `
${newResponse.join("")}
` 68 | } 69 | 70 | async function waitForGeneration() { 71 | try { 72 | // Wait for group to finish generating 73 | if (selected_group) { 74 | await waitUntilCondition(() => is_group_generating === false, 1000, 10); 75 | } 76 | // Wait for the send button to be released 77 | waitUntilCondition(() => is_send_press === false, 30000, 100); 78 | } catch { 79 | console.debug('Timeout waiting for is_send_press'); 80 | return; 81 | } 82 | } 83 | /** 84 | * Handles the CYOA response generation 85 | * @returns 86 | */ 87 | async function requestCYOAResponses() { 88 | const context = getContext(); 89 | const chat = context.chat; 90 | 91 | // no characters or group selected 92 | if (!context.groupId && context.characterId === undefined) { 93 | return; 94 | } 95 | 96 | // Currently summarizing or frozen state - skip 97 | if (inApiCall) { 98 | return; 99 | } 100 | 101 | // No new messages - do nothing 102 | // if (chat.length === 0 || (lastMessageId === chat.length && getStringHash(chat[chat.length - 1].mes) === lastMessageHash)) { 103 | if (chat.length === 0) { 104 | return; 105 | } 106 | 107 | removeLastCYOAMessage(chat); 108 | 109 | await waitForGeneration(); 110 | 111 | toastr.info('CYOA: Generating response...'); 112 | const prompt = extension_settings.cyoa_responses?.llm_prompt || defaultSettings.llm_prompt || ""; 113 | const useWIAN = extension_settings.cyoa_responses?.apply_wi_an || defaultSettings.apply_wi_an; 114 | const responseLength = extension_settings.cyoa_responses?.response_length || defaultSettings.response_length; 115 | // generateQuietPrompt(quiet_prompt, quietToLoud, skipWIAN, quietImage = null, quietName = null, responseLength = null, noContext = false) 116 | const response = await generateQuietPrompt(prompt, false, !useWIAN, null, "Suggestion List", responseLength); 117 | 118 | const parsedResponse = parseResponse(response); 119 | if (!parsedResponse) { 120 | toastr.error('CYOA: Failed to parse response'); 121 | return; 122 | } 123 | 124 | await sendMessageToUI(parsedResponse); 125 | } 126 | 127 | /** 128 | * Removes the last CYOA message from the chat 129 | * @param {getContext.chat} chat 130 | */ 131 | function removeLastCYOAMessage(chat = getContext().chat) { 132 | let lastMessage = chat[chat.length - 1]; 133 | if (!lastMessage?.extra || lastMessage?.extra?.model !== 'cyoa') { 134 | return; 135 | } 136 | 137 | const target = $('#chat').find(`.mes[mesid=${lastMessage.mesId}]`); 138 | if (target.length === 0) { 139 | return; 140 | } 141 | 142 | setEditedMessageId(lastMessage.mesId); 143 | target.find('.mes_edit_delete').trigger('click', { fromSlashCommand: true }); 144 | } 145 | 146 | /** 147 | * Sends the parsed CYOA response to the SillyTavern UI 148 | * @param {string} parsedResponse 149 | */ 150 | async function sendMessageToUI(parsedResponse) { 151 | const context = getContext(); 152 | const chat = context.chat; 153 | 154 | const messageObject = { 155 | name: "CYOA Suggestions", 156 | is_user: true, 157 | is_system: false, 158 | send_date: getMessageTimeStamp(), 159 | mes: `${parsedResponse}`, 160 | mesId: context.chat.length, 161 | extra: { 162 | api: 'manual', 163 | model: 'cyoa', 164 | } 165 | }; 166 | 167 | context.chat.push(messageObject); 168 | // await eventSource.emit(event_types.MESSAGE_SENT, (chat.length - 1)); 169 | context.addOneMessage(messageObject, { showSwipes: false, forceId: chat.length - 1 }); 170 | } 171 | 172 | /** 173 | * Handles the CYOA click event by doing impersonation 174 | * @param {*} event 175 | */ 176 | async function handleCYOABtn(event) { 177 | const $button = $(event.target); 178 | const text = $button?.text()?.trim() || $button.find('.custom-text')?.text()?.trim(); 179 | if (text.length === 0) { 180 | return; 181 | } 182 | await waitForGeneration(); 183 | 184 | removeLastCYOAMessage(); 185 | // Sleep for 500ms before continuing 186 | await new Promise(resolve => setTimeout(resolve, 250)); 187 | 188 | const inputTextarea = document.querySelector('#send_textarea'); 189 | if (!(inputTextarea instanceof HTMLTextAreaElement)) { 190 | return; 191 | } 192 | 193 | let impersonatePrompt = extension_settings.cyoa_responses?.llm_prompt_impersonate || ''; 194 | impersonatePrompt = substituteParamsExtended(String(extension_settings.cyoa_responses?.llm_prompt_impersonate), { suggestionText: text }); 195 | 196 | const quiet_prompt = `/impersonate await=true ${impersonatePrompt}`; 197 | inputTextarea.value = quiet_prompt; 198 | 199 | if ($button.hasClass('custom-edit-suggestion')) { 200 | return; // Stop here if it's the edit button 201 | } 202 | 203 | inputTextarea.dispatchEvent(new Event('input', { bubbles: true })); 204 | 205 | const sendButton = document.querySelector('#send_but'); 206 | if (sendButton instanceof HTMLElement) { 207 | sendButton.click(); 208 | } 209 | } 210 | 211 | /** 212 | * Handles the CYOA by sending the text to the User Input box 213 | * @param {*} event 214 | */ 215 | // function handleCYOAEditBtn(event) { 216 | // const $button = $(event.target); 217 | // const text = $button.find('.custom-text').text().trim(); 218 | // if (text.length === 0) { 219 | // return; 220 | // } 221 | 222 | // removeLastCYOAMessage(); 223 | // const inputTextarea = document.querySelector('#send_textarea'); 224 | // if (inputTextarea instanceof HTMLTextAreaElement) { 225 | // inputTextarea.value = text; 226 | // } 227 | // } 228 | 229 | 230 | /** 231 | * Settings Stuff 232 | */ 233 | function loadSettings() { 234 | extension_settings.cyoa_responses = extension_settings.cyoa_responses || {}; 235 | if (Object.keys(extension_settings.cyoa_responses).length === 0) { 236 | extension_settings.cyoa_responses = {}; 237 | } 238 | Object.assign(defaultSettings, extension_settings.cyoa_responses); 239 | 240 | $('#cyoa_llm_prompt').val(extension_settings.cyoa_responses.llm_prompt).trigger('input'); 241 | $('#cyoa_llm_prompt_impersonate').val(extension_settings.cyoa_responses.llm_prompt_impersonate).trigger('input'); 242 | $('#cyoa_apply_wi_an').prop('checked', extension_settings.cyoa_responses.apply_wi_an).trigger('input'); 243 | $('#cyoa_num_responses').val(extension_settings.cyoa_responses.num_responses).trigger('input'); 244 | $('#cyoa_num_responses_value').text(extension_settings.cyoa_responses.num_responses); 245 | $('#cyoa_response_length').val(extension_settings.cyoa_responses.response_length).trigger('input'); 246 | $('#cyoa_response_length_value').text(extension_settings.cyoa_responses.response_length); 247 | 248 | } 249 | 250 | function addEventListeners() { 251 | $('#cyoa_llm_prompt').on('input', function() { 252 | extension_settings.cyoa_responses.llm_prompt = $(this).val(); 253 | saveSettingsDebounced(); 254 | }); 255 | 256 | $('#cyoa_llm_prompt_impersonate').on('input', function() { 257 | extension_settings.cyoa_responses.llm_prompt_impersonate = $(this).val(); 258 | saveSettingsDebounced(); 259 | }); 260 | 261 | $('#cyoa_apply_wi_an').on('change', function() { 262 | extension_settings.cyoa_responses.apply_wi_an = !!$(this).prop('checked'); 263 | saveSettingsDebounced(); 264 | }); 265 | $('#cyoa_num_responses').on('input', function() { 266 | const value = $(this).val(); 267 | extension_settings.cyoa_responses.num_responses = Number(value); 268 | $('#cyoa_num_responses_value').text(value); 269 | saveSettingsDebounced(); 270 | }); 271 | 272 | $('#cyoa_response_length').on('input', function() { 273 | const value = $(this).val(); 274 | extension_settings.cyoa_responses.response_length = Number(value); 275 | $('#cyoa_response_length_value').text(value); 276 | saveSettingsDebounced(); 277 | }); 278 | } 279 | 280 | // This function is called when the extension is loaded 281 | jQuery(async () => { 282 | //add a delay to possibly fix some conflicts 283 | await new Promise(resolve => setTimeout(resolve, 900)); 284 | const settingsHtml = await $.get(`${extensionFolderPath}/settings.html`); 285 | $("#extensions_settings").append(settingsHtml); 286 | loadSettings(); 287 | addEventListeners(); 288 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ 289 | name: 'cyoa', 290 | callback: async () => { 291 | await requestCYOAResponses(); 292 | return ''; 293 | }, 294 | helpString: 'Triggers CYOA responses generation.', 295 | })); 296 | 297 | MacrosParser.registerMacro('suggestionNumber', () => `${extension_settings.cyoa_responses?.num_responses || defaultSettings.num_responses}`); 298 | 299 | // Event delegation for CYOA buttons 300 | $(document).on('click', 'button.custom-edit-suggestion', handleCYOABtn); 301 | $(document).on('click', 'button.custom-suggestion', handleCYOABtn); 302 | }); 303 | --------------------------------------------------------------------------------