| 1 | +import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from '../../extensions.js'; |
| 2 | +import { |
| 3 | + chat, |
| 4 | + chat_metadata, |
| 5 | + event_types, |
| 6 | + eventSource, |
| 7 | + extension_prompt_roles, |
| 8 | + extension_prompt_types, |
| 9 | + generateQuietPrompt, |
| 10 | + saveChatDebounced, |
| 11 | + saveSettingsDebounced, |
| 12 | + setExtensionPrompt, |
| 13 | + substituteParams, |
| 14 | +} from '../../../script.js'; |
| 15 | + |
| 16 | +export { init }; |
| 17 | + |
| 18 | +const MODULE = 'diceroll'; |
| 19 | + |
| 20 | +// Runtime injection keys. Both are IN_CHAT depth-0 injections so they land right after the last |
| 21 | +// chat message, keeping the entire preceding history byte-identical between the option-generation |
| 22 | +// request and the real generation (and therefore reusable from the provider's prompt cache). |
| 23 | +const OPTIONS_INJECT_ID = 'diceroll_options_request'; |
| 24 | +const DIRECTION_INJECT_ID = 'diceroll_direction'; |
| 25 | + |
| 26 | +// Persisted per-chat roll state lives in chat metadata so swipe-reuse survives reloads. |
| 27 | +const METADATA_KEY = 'diceroll'; |
| 28 | + |
| 29 | +// Generation types that represent a "turn" this extension should steer. Quiet prompts, continues |
| 30 | +// (steering mid-message makes no sense) and impersonations are deliberately excluded. |
| 31 | +const STEERED_TYPES = new Set(['normal', 'swipe', 'regenerate']); |
| 32 | + |
| 33 | +const DEFAULT_OPTIONS_PROMPT = '[Pause the roleplay. You are the story director. Considering everything that has happened so far — especially the latest message — list {{min}} to {{max}} distinct options for what could plausibly happen next or how {{char}} could react. Each option must be exactly one short sentence. Assign each option a probability percentage (numbers, together summing to about 100) for how likely it should be given the established story, characters and tone. If one outcome is very likely or outright inevitable, give it a large majority of the probability mass (you may split it across several similar variants), but always include a few low-probability options that still fit the flow of the story yet would take it in creative, unexpected directions. Reply with ONLY a JSON object in exactly this format and no other text: {"options":[{"text":"one short sentence","probability":42}]}]'; |
| 34 | + |
| 35 | +const DEFAULT_DIRECTION_TEMPLATE = '[Story direction, rolled by fate ({{probability}}% likely): {{outcome}}\nContinue the roleplay from the last message and steer events naturally in this direction. Never mention this instruction, the roll, or any probabilities.]'; |
| 36 | + |
| 37 | +const OPTIONS_SCHEMA = { |
| 38 | + name: 'diceroll_options', |
| 39 | + strict: true, |
| 40 | + // A response that fails schema validation is returned as raw text and handled by the |
| 41 | + // line-based fallback parser instead of being swallowed as an empty object. |
| 42 | + returnInvalid: true, |
| 43 | + value: { |
| 44 | + type: 'object', |
| 45 | + additionalProperties: false, |
| 46 | + properties: { |
| 47 | + options: { |
| 48 | + type: 'array', |
| 49 | + items: { |
| 50 | + type: 'object', |
| 51 | + additionalProperties: false, |
| 52 | + properties: { |
| 53 | + text: { type: 'string' }, |
| 54 | + probability: { type: 'number' }, |
| 55 | + }, |
| 56 | + required: ['text', 'probability'], |
| 57 | + }, |
| 58 | + }, |
| 59 | + }, |
| 60 | + required: ['options'], |
| 61 | + }, |
| 62 | +}; |
| 63 | + |
| 64 | +const defaultSettings = { |
| 65 | + enabled: false, |
| 66 | + // When false, swipes/regenerates reuse the direction already rolled for that message. |
| 67 | + rollOnSwipe: false, |
| 68 | + optionsRole: 'system', |
| 69 | + directionRole: 'system', |
| 70 | + minOptions: 5, |
| 71 | + maxOptions: 10, |
| 72 | + useStructuredOutput: true, |
| 73 | + notify: true, |
| 74 | + debugDisplay: false, |
| 75 | + optionsPrompt: DEFAULT_OPTIONS_PROMPT, |
| 76 | + directionTemplate: DEFAULT_DIRECTION_TEMPLATE, |
| 77 | +}; |
| 78 | + |
| 79 | +// True while the nested option-generation request is running. Prevents the interceptor from |
| 80 | +// reacting to its own quiet generation. |
| 81 | +let isRolling = false; |
| 82 | + |
| 83 | +// Roll data waiting to be stamped onto the message the steered generation produces. |
| 84 | +let pendingStamp = null; |
| 85 | + |
| 86 | +globalThis.dicerollGenerateInterceptor = (...args) => onGenerationIntercept(...args); |
| 87 | + |
| 88 | +function getSettings() { |
| 89 | + if (extension_settings[MODULE] === undefined) { |
| 90 | + extension_settings[MODULE] = {}; |
| 91 | + } |
| 92 | + for (const key of Object.keys(defaultSettings)) { |
| 93 | + if (extension_settings[MODULE][key] === undefined) { |
| 94 | + extension_settings[MODULE][key] = structuredClone(defaultSettings[key]); |
| 95 | + } |
| 96 | + } |
| 97 | + return extension_settings[MODULE]; |
| 98 | +} |
| 99 | + |
| 100 | +function getRole(name) { |
| 101 | + return name === 'user' ? extension_prompt_roles.USER : extension_prompt_roles.SYSTEM; |
| 102 | +} |
| 103 | + |
| 104 | +function setDirectionInjection(text, roleName) { |
| 105 | + setExtensionPrompt(DIRECTION_INJECT_ID, text, extension_prompt_types.IN_CHAT, 0, false, getRole(roleName)); |
| 106 | +} |
| 107 | + |
| 108 | +function clearDirectionInjection() { |
| 109 | + setDirectionInjection('', 'system'); |
| 110 | +} |
| 111 | + |
| 112 | +function formatProbability(value) { |
| 113 | + const number = Number(value); |
| 114 | + return Number.isInteger(number) ? String(number) : number.toFixed(1); |
| 115 | +} |
| 116 | + |
| 117 | +/** |
| 118 | + * Runs the option-generation request: the full current chat history plus the instruction injected |
| 119 | + * at depth 0, through the currently selected model/API. |
| 120 | + * @param {object} s Settings |
| 121 | + * @returns {Promise<string>} Raw model response |
| 122 | + */ |
| 123 | +async function generateOptions(s) { |
| 124 | + const instruction = substituteParams( |
| 125 | + String(s.optionsPrompt) |
| 126 | + .replaceAll('{{min}}', String(s.minOptions)) |
| 127 | + .replaceAll('{{max}}', String(s.maxOptions)), |
| 128 | + ); |
| 129 | + setExtensionPrompt(OPTIONS_INJECT_ID, instruction, extension_prompt_types.IN_CHAT, 0, false, getRole(s.optionsRole)); |
| 130 | + try { |
| 131 | + return await generateQuietPrompt({ |
| 132 | + quietPrompt: '', |
| 133 | + jsonSchema: s.useStructuredOutput ? OPTIONS_SCHEMA : null, |
| 134 | + }); |
| 135 | + } finally { |
| 136 | + setExtensionPrompt(OPTIONS_INJECT_ID, '', extension_prompt_types.IN_CHAT, 0); |
| 137 | + } |
| 138 | +} |
| 139 | + |
| 140 | +function tryParseJson(text) { |
| 141 | + const candidates = [text]; |
| 142 | + const objStart = text.indexOf('{'); |
| 143 | + const objEnd = text.lastIndexOf('}'); |
| 144 | + if (objStart >= 0 && objEnd > objStart) { |
| 145 | + candidates.push(text.slice(objStart, objEnd + 1)); |
| 146 | + } |
| 147 | + const arrStart = text.indexOf('['); |
| 148 | + const arrEnd = text.lastIndexOf(']'); |
| 149 | + if (arrStart >= 0 && arrEnd > arrStart) { |
| 150 | + candidates.push(text.slice(arrStart, arrEnd + 1)); |
| 151 | + } |
| 152 | + for (const candidate of candidates) { |
| 153 | + try { |
| 154 | + return JSON.parse(candidate); |
| 155 | + } catch { |
| 156 | + // try the next candidate |
| 157 | + } |
| 158 | + } |
| 159 | + return null; |
| 160 | +} |
| 161 | + |
| 162 | +/** |
| 163 | + * Parses model output into a weighted option list. Prefers JSON (structured output or inline), |
| 164 | + * falls back to "text (25%)" / "25% - text" style lines. |
| 165 | + * @param {string} raw Raw model response |
| 166 | + * @returns {{text: string, probability: number}[]|null} At least two options, or null |
| 167 | + */ |
| 168 | +function parseOptions(raw) { |
| 169 | + const text = String(raw ?? '').trim(); |
| 170 | + if (!text) { |
| 171 | + return null; |
| 172 | + } |
| 173 | + |
| 174 | + let options = []; |
| 175 | + const parsed = tryParseJson(text); |
| 176 | + const list = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.options) ? parsed.options : null); |
| 177 | + if (list) { |
| 178 | + options = list.map(item => ({ |
| 179 | + text: String(item?.text ?? item?.option ?? '').trim(), |
| 180 | + probability: Number(item?.probability ?? item?.percent ?? item?.chance), |
| 181 | + })); |
| 182 | + } else { |
| 183 | + const trailing = /^(.+?)\s*[-–—:([]?\s*(\d{1,3}(?:\.\d+)?)\s*%\s*[)\]]?\s*$/; |
| 184 | + const leading = /^(\d{1,3}(?:\.\d+)?)\s*%\s*[-–—:]?\s*(.+)$/; |
| 185 | + for (let line of text.split('\n')) { |
| 186 | + line = line.replace(/^\s*(?:[-*•]|\d+[.)])\s*/, '').trim(); |
| 187 | + let match = line.match(trailing); |
| 188 | + if (match) { |
| 189 | + options.push({ text: match[1].trim(), probability: Number(match[2]) }); |
| 190 | + continue; |
| 191 | + } |
| 192 | + match = line.match(leading); |
| 193 | + if (match) { |
| 194 | + options.push({ text: match[2].trim(), probability: Number(match[1]) }); |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + options = options.filter(option => option.text && Number.isFinite(option.probability) && option.probability > 0); |
| 200 | + |
| 201 | + // Models occasionally return fractions instead of percentages. |
| 202 | + const total = options.reduce((sum, option) => sum + option.probability, 0); |
| 203 | + if (total > 0 && total <= 1.5) { |
| 204 | + options = options.map(option => ({ ...option, probability: option.probability * 100 })); |
| 205 | + } |
| 206 | + |
| 207 | + return options.length >= 2 ? options : null; |
| 208 | +} |
| 209 | + |
| 210 | +/** |
| 211 | + * Weighted random roll over the options. |
| 212 | + * @param {{text: string, probability: number}[]} options Parsed options |
| 213 | + * @returns {{options: object[], chosenIndex: number, roll: number, total: number}} |
| 214 | + */ |
| 215 | +function rollOptions(options) { |
| 216 | + const total = options.reduce((sum, option) => sum + option.probability, 0); |
| 217 | + const roll = Math.random() * total; |
| 218 | + let cumulative = 0; |
| 219 | + let chosenIndex = options.length - 1; |
| 220 | + for (let i = 0; i < options.length; i++) { |
| 221 | + cumulative += options[i].probability; |
| 222 | + if (roll < cumulative) { |
| 223 | + chosenIndex = i; |
| 224 | + break; |
| 225 | + } |
| 226 | + } |
| 227 | + return { options, chosenIndex, roll, total }; |
| 228 | +} |
| 229 | + |
| 230 | +function buildDirectionText(rollData, s) { |
| 231 | + const chosen = rollData.options[rollData.chosenIndex]; |
| 232 | + return substituteParams( |
| 233 | + String(s.directionTemplate) |
| 234 | + .replaceAll('{{outcome}}', chosen.text) |
| 235 | + .replaceAll('{{probability}}', formatProbability(chosen.probability)), |
| 236 | + ); |
| 237 | +} |
| 238 | + |
| 239 | +/** |
| 240 | + * Temporarily hides the message being swiped away so the option request sees the same history as |
| 241 | + * the swipe generation itself (which pops the last message from its prompt). |
| 242 | + * @returns {(() => void)|null} Restore function |
| 243 | + */ |
| 244 | +function hideLastMessageForSwipe() { |
| 245 | + const target = chat[chat.length - 1]; |
| 246 | + if (!target || target.is_user || target.is_system) { |
| 247 | + return null; |
| 248 | + } |
| 249 | + target.is_system = true; |
| 250 | + return () => { |
| 251 | + target.is_system = false; |
| 252 | + }; |
| 253 | +} |
| 254 | + |
| 255 | +/** |
| 256 | + * Generation interceptor. Runs the option roll before the actual generation and injects the |
| 257 | + * rolled direction for the upcoming prompt build. |
| 258 | + * @param {object[]} coreChat Filtered chat that will be used for the prompt |
| 259 | + * @param {number} _contextSize Max context size |
| 260 | + * @param {(immediately: boolean) => void} _abort Abort function |
| 261 | + * @param {string} type Generation type |
| 262 | + */ |
| 263 | +async function onGenerationIntercept(coreChat, _contextSize, _abort, type) { |
| 264 | + const s = getSettings(); |
| 265 | + |
| 266 | + // The nested option request triggers interceptors itself (as type 'quiet'). |
| 267 | + if (isRolling || type === 'quiet') { |
| 268 | + return; |
| 269 | + } |
| 270 | + |
| 271 | + if (!s.enabled) { |
| 272 | + clearDirectionInjection(); |
| 273 | + return; |
| 274 | + } |
| 275 | + |
| 276 | + // Tool-call recursion re-enters Generate as 'normal'; keep the current steering untouched |
| 277 | + // instead of rolling again mid-turn. |
| 278 | + const lastMessage = chat[chat.length - 1]; |
| 279 | + if (lastMessage && !lastMessage.is_user && Array.isArray(lastMessage.extra?.tool_invocations)) { |
| 280 | + return; |
| 281 | + } |
| 282 | + |
| 283 | + // Never leak a stale direction into an unrelated generation type. |
| 284 | + clearDirectionInjection(); |
| 285 | + pendingStamp = null; |
| 286 | + |
| 287 | + if (!STEERED_TYPES.has(type) || !chat.length) { |
| 288 | + return; |
| 289 | + } |
| 290 | + |
| 291 | + // The roll belongs to the current user turn; swipes/regenerates of the same turn can reuse it. |
| 292 | + const anchor = chat.findLastIndex(x => x.is_user); |
| 293 | + const stored = chat_metadata[METADATA_KEY]; |
| 294 | + const isRedo = type === 'swipe' || type === 'regenerate'; |
| 295 | + |
| 296 | + if (isRedo && !s.rollOnSwipe && stored?.direction && stored.anchor === anchor) { |
| 297 | + pendingStamp = stored; |
| 298 | + setDirectionInjection(stored.direction, s.directionRole); |
| 299 | + return; |
| 300 | + } |
| 301 | + |
| 302 | + let rollData = null; |
| 303 | + isRolling = true; |
| 304 | + const restoreHidden = type === 'swipe' ? hideLastMessageForSwipe() : null; |
| 305 | + try { |
| 306 | + const raw = await generateOptions(s); |
| 307 | + const options = parseOptions(raw); |
| 308 | + if (!options) { |
| 309 | + throw new Error('Could not parse any options from the model response.'); |
| 310 | + } |
| 311 | + rollData = rollOptions(options); |
| 312 | + } catch (error) { |
| 313 | + console.error('[Diceroll] Option generation failed:', error); |
| 314 | + toastr.warning('Continuing without steering. ' + (error?.message ?? ''), 'Diceroll: option roll failed', { escapeHtml: true }); |
| 315 | + } finally { |
| 316 | + restoreHidden?.(); |
| 317 | + isRolling = false; |
| 318 | + } |
| 319 | + |
| 320 | + if (!rollData) { |
| 321 | + return; |
| 322 | + } |
| 323 | + |
| 324 | + const data = { |
| 325 | + anchor, |
| 326 | + options: rollData.options, |
| 327 | + chosenIndex: rollData.chosenIndex, |
| 328 | + roll: rollData.roll, |
| 329 | + total: rollData.total, |
| 330 | + direction: '', |
| 331 | + }; |
| 332 | + data.direction = buildDirectionText(rollData, s); |
| 333 | + |
| 334 | + chat_metadata[METADATA_KEY] = data; |
| 335 | + saveMetadataDebounced(); |
| 336 | + pendingStamp = data; |
| 337 | + setDirectionInjection(data.direction, s.directionRole); |
| 338 | + |
| 339 | + if (s.notify) { |
| 340 | + const chosen = rollData.options[rollData.chosenIndex]; |
| 341 | + toastr.info(`${chosen.text} (${formatProbability(chosen.probability)}%)`, '🎲 Diceroll', { escapeHtml: true }); |
| 342 | + } |
| 343 | +} |
| 344 | + |
| 345 | +/** |
| 346 | + * Copies the roll that steered a finished generation onto the produced message, so the debug view |
| 347 | + * stays correct per message and survives reloads. |
| 348 | + * @param {number} chatId Message index |
| 349 | + */ |
| 350 | +function stampMessage(chatId) { |
| 351 | + if (!pendingStamp) { |
| 352 | + return; |
| 353 | + } |
| 354 | + const message = chat[chatId]; |
| 355 | + if (!message || message.is_user || message.is_system) { |
| 356 | + return; |
| 357 | + } |
| 358 | + message.extra = message.extra || {}; |
| 359 | + message.extra.diceroll = structuredClone(pendingStamp); |
| 360 | + pendingStamp = null; |
| 361 | + saveChatDebounced(); |
| 362 | +} |
| 363 | + |
| 364 | +function renderDebugForMessage(chatId) { |
| 365 | + const mesElement = $(`#chat .mes[mesid="${chatId}"]`); |
| 366 | + if (!mesElement.length) { |
| 367 | + return; |
| 368 | + } |
| 369 | + mesElement.find('.diceroll_debug').remove(); |
| 370 | + |
| 371 | + const data = chat[chatId]?.extra?.diceroll; |
| 372 | + if (!getSettings().debugDisplay || !data || !Array.isArray(data.options)) { |
| 373 | + return; |
| 374 | + } |
| 375 | + |
| 376 | + const chosen = data.options[data.chosenIndex]; |
| 377 | + const details = $('<details class="diceroll_debug"></details>'); |
| 378 | + const rollInfo = Number.isFinite(data.roll) ? `, roll ${data.roll.toFixed(1)}/${formatProbability(data.total)}` : ''; |
| 379 | + details.append($('<summary></summary>').text(`🎲 ${chosen?.text ?? '?'} (${formatProbability(chosen?.probability ?? 0)}%${rollInfo})`)); |
| 380 | + |
| 381 | + const table = $('<table class="diceroll_debug_table"></table>'); |
| 382 | + data.options.forEach((option, index) => { |
| 383 | + const row = $('<tr></tr>').toggleClass('diceroll_chosen', index === data.chosenIndex); |
| 384 | + row.append($('<td></td>').text(`${formatProbability(option.probability)}%`)); |
| 385 | + row.append($('<td></td>').text(option.text)); |
| 386 | + table.append(row); |
| 387 | + }); |
| 388 | + details.append(table); |
| 389 | + details.append($('<div class="diceroll_debug_direction"></div>').text(data.direction ?? '')); |
| 390 | + |
| 391 | + const anchorElement = mesElement.find('.mes_block .mes_text').first(); |
| 392 | + if (anchorElement.length) { |
| 393 | + anchorElement.after(details); |
| 394 | + } else { |
| 395 | + mesElement.append(details); |
| 396 | + } |
| 397 | +} |
| 398 | + |
| 399 | +function renderAllDebug() { |
| 400 | + $('#chat .mes').each((_, element) => { |
| 401 | + renderDebugForMessage(Number(element.getAttribute('mesid'))); |
| 402 | + }); |
| 403 | +} |
| 404 | + |
| 405 | +function loadSettingsUi() { |
| 406 | + const s = getSettings(); |
| 407 | + $('#diceroll_enabled').prop('checked', s.enabled); |
| 408 | + $('#diceroll_roll_on_swipe').prop('checked', s.rollOnSwipe); |
| 409 | + $('#diceroll_structured').prop('checked', s.useStructuredOutput); |
| 410 | + $('#diceroll_notify').prop('checked', s.notify); |
| 411 | + $('#diceroll_debug').prop('checked', s.debugDisplay); |
| 412 | + $('#diceroll_min_options').val(s.minOptions); |
| 413 | + $('#diceroll_max_options').val(s.maxOptions); |
| 414 | + $('#diceroll_options_role').val(s.optionsRole); |
| 415 | + $('#diceroll_direction_role').val(s.directionRole); |
| 416 | + $('#diceroll_options_prompt').val(s.optionsPrompt); |
| 417 | + $('#diceroll_direction_template').val(s.directionTemplate); |
| 418 | +} |
| 419 | + |
| 420 | +function setupListeners() { |
| 421 | + const bindCheckbox = (id, key, onChange = null) => { |
| 422 | + $(id).on('change', function () { |
| 423 | + getSettings()[key] = !!$(this).prop('checked'); |
| 424 | + saveSettingsDebounced(); |
| 425 | + onChange?.(); |
| 426 | + }); |
| 427 | + }; |
| 428 | + |
| 429 | + bindCheckbox('#diceroll_enabled', 'enabled', () => { |
| 430 | + if (!getSettings().enabled) { |
| 431 | + clearDirectionInjection(); |
| 432 | + } |
| 433 | + }); |
| 434 | + bindCheckbox('#diceroll_roll_on_swipe', 'rollOnSwipe'); |
| 435 | + bindCheckbox('#diceroll_structured', 'useStructuredOutput'); |
| 436 | + bindCheckbox('#diceroll_notify', 'notify'); |
| 437 | + bindCheckbox('#diceroll_debug', 'debugDisplay', renderAllDebug); |
| 438 | + |
| 439 | + $('#diceroll_min_options').on('input', function () { |
| 440 | + getSettings().minOptions = Math.max(2, Number($(this).val()) || defaultSettings.minOptions); |
| 441 | + saveSettingsDebounced(); |
| 442 | + }); |
| 443 | + $('#diceroll_max_options').on('input', function () { |
| 444 | + getSettings().maxOptions = Math.max(2, Number($(this).val()) || defaultSettings.maxOptions); |
| 445 | + saveSettingsDebounced(); |
| 446 | + }); |
| 447 | + $('#diceroll_options_role').on('change', function () { |
| 448 | + getSettings().optionsRole = String($(this).val()); |
| 449 | + saveSettingsDebounced(); |
| 450 | + }); |
| 451 | + $('#diceroll_direction_role').on('change', function () { |
| 452 | + getSettings().directionRole = String($(this).val()); |
| 453 | + saveSettingsDebounced(); |
| 454 | + }); |
| 455 | + $('#diceroll_options_prompt').on('input', function () { |
| 456 | + getSettings().optionsPrompt = String($(this).val()); |
| 457 | + saveSettingsDebounced(); |
| 458 | + }); |
| 459 | + $('#diceroll_direction_template').on('input', function () { |
| 460 | + getSettings().directionTemplate = String($(this).val()); |
| 461 | + saveSettingsDebounced(); |
| 462 | + }); |
| 463 | + $('#diceroll_options_prompt_restore').on('click', () => { |
| 464 | + getSettings().optionsPrompt = DEFAULT_OPTIONS_PROMPT; |
| 465 | + $('#diceroll_options_prompt').val(DEFAULT_OPTIONS_PROMPT); |
| 466 | + saveSettingsDebounced(); |
| 467 | + }); |
| 468 | + $('#diceroll_direction_template_restore').on('click', () => { |
| 469 | + getSettings().directionTemplate = DEFAULT_DIRECTION_TEMPLATE; |
| 470 | + $('#diceroll_direction_template').val(DEFAULT_DIRECTION_TEMPLATE); |
| 471 | + saveSettingsDebounced(); |
| 472 | + }); |
| 473 | +} |
| 474 | + |
| 475 | +async function init() { |
| 476 | + const settingsHtml = await renderExtensionTemplateAsync(MODULE, 'settings'); |
| 477 | + $('#extensions_settings2').append(settingsHtml); |
| 478 | + loadSettingsUi(); |
| 479 | + setupListeners(); |
| 480 | + |
| 481 | + // A new user message starts a new turn; any previous roll no longer applies. |
| 482 | + eventSource.on(event_types.MESSAGE_SENT, () => { |
| 483 | + delete chat_metadata[METADATA_KEY]; |
| 484 | + }); |
| 485 | + |
| 486 | + eventSource.on(event_types.MESSAGE_RECEIVED, (chatId) => stampMessage(chatId)); |
| 487 | + eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, (chatId) => { |
| 488 | + stampMessage(chatId); |
| 489 | + renderDebugForMessage(chatId); |
| 490 | + }); |
| 491 | + eventSource.on(event_types.MESSAGE_SWIPED, (chatId) => renderDebugForMessage(chatId)); |
| 492 | + eventSource.on(event_types.CHAT_CHANGED, () => { |
| 493 | + pendingStamp = null; |
| 494 | + clearDirectionInjection(); |
| 495 | + renderAllDebug(); |
| 496 | + }); |
| 497 | + |
| 498 | + // The direction is consumed by exactly one generation. GENERATION_ENDED also fires when the |
| 499 | + // nested option request finishes, which is why clearing is skipped while a roll is in flight. |
| 500 | + const onGenerationDone = () => { |
| 501 | + if (isRolling) { |
| 502 | + return; |
| 503 | + } |
| 504 | + pendingStamp = null; |
| 505 | + clearDirectionInjection(); |
| 506 | + }; |
| 507 | + eventSource.on(event_types.GENERATION_ENDED, onGenerationDone); |
| 508 | + eventSource.on(event_types.GENERATION_STOPPED, onGenerationDone); |
| 509 | +} |