Add Diceroll story-steering extension Before each response, quietly asks the current model for 5-10 short continuation options with probabilities (structured output with plain-text fallback), rolls a weighted die, and injects the chosen direction after the latest user message for that generation only. Both side prompts share the main chat's exact history prefix so the provider prompt cache is reused. Swipes/regenerates reuse the rolled direction unless re-rolling is enabled; a debug mode shows the option table and injected prompt under each message.

ce847b8b518928c40f2b1603fecc143114ecd24a

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

4 files changed, +645 -0Ignore whitespace
public/scripts/extensions/diceroll/index.js+509 -0
@@ -0,0 +1,509 @@
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+}
public/scripts/extensions/diceroll/manifest.json+15 -0
@@ -0,0 +1,15 @@
1+{
2+ "display_name": "Diceroll",
3+ "loading_order": 100,
4+ "requires": [],
5+ "optional": [],
6+ "js": "index.js",
7+ "css": "style.css",
8+ "author": "SillyTavern",
9+ "version": "1.0.0",
10+ "homePage": "https://github.com/SillyTavern/SillyTavern",
11+ "generate_interceptor": "dicerollGenerateInterceptor",
12+ "hooks": {
13+ "activate": "init"
14+ }
15+}
public/scripts/extensions/diceroll/settings.html+79 -0
@@ -0,0 +1,79 @@
1+<div id="diceroll_settings">
2+ <div class="inline-drawer">
3+ <div class="inline-drawer-toggle inline-drawer-header">
4+ <b data-i18n="ext_diceroll_title">Diceroll</b>
5+ <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
6+ </div>
7+ <div class="inline-drawer-content">
8+ <label class="checkbox_label" for="diceroll_enabled">
9+ <input id="diceroll_enabled" type="checkbox" />
10+ <span data-i18n="ext_diceroll_enable">Enable Diceroll</span>
11+ </label>
12+ <small data-i18n="ext_diceroll_help">Before each response, the current model is quietly asked (with the exact same chat history, so the prompt cache is reused) to list short options for how the story could continue, each with a probability. A weighted dice roll picks one, and the chosen direction is injected after the latest user message for that one generation only — it is never saved to the chat, and the next turn's history stays clean.</small>
13+
14+ <label class="checkbox_label" for="diceroll_roll_on_swipe">
15+ <input id="diceroll_roll_on_swipe" type="checkbox" />
16+ <span data-i18n="ext_diceroll_roll_on_swipe">Roll again on swipes and regenerates</span>
17+ </label>
18+ <small data-i18n="ext_diceroll_roll_on_swipe_help">When off, swiping or regenerating reuses the direction already rolled for that turn.</small>
19+
20+ <label class="checkbox_label" for="diceroll_structured">
21+ <input id="diceroll_structured" type="checkbox" />
22+ <span data-i18n="ext_diceroll_structured">Request structured output (JSON schema)</span>
23+ </label>
24+ <small data-i18n="ext_diceroll_structured_help">Turn off if your backend rejects requests containing a JSON schema; the options are then parsed from plain text.</small>
25+
26+ <label class="checkbox_label" for="diceroll_notify">
27+ <input id="diceroll_notify" type="checkbox" />
28+ <span data-i18n="ext_diceroll_notify">Show a notification with the rolled outcome</span>
29+ </label>
30+
31+ <label class="checkbox_label" for="diceroll_debug">
32+ <input id="diceroll_debug" type="checkbox" />
33+ <span data-i18n="ext_diceroll_debug">Debug: show the roll table and injected direction under each message</span>
34+ </label>
35+
36+ <div class="flex-container">
37+ <div class="flex1">
38+ <label for="diceroll_min_options" data-i18n="ext_diceroll_min">Min options</label>
39+ <input id="diceroll_min_options" class="text_pole" type="number" min="2" max="20" step="1" />
40+ </div>
41+ <div class="flex1">
42+ <label for="diceroll_max_options" data-i18n="ext_diceroll_max">Max options</label>
43+ <input id="diceroll_max_options" class="text_pole" type="number" min="2" max="20" step="1" />
44+ </div>
45+ </div>
46+
47+ <div class="flex-container">
48+ <div class="flex1">
49+ <label for="diceroll_options_role" data-i18n="ext_diceroll_options_role">Option request sent as</label>
50+ <select id="diceroll_options_role" class="text_pole">
51+ <option value="system" data-i18n="ext_diceroll_role_system">System message</option>
52+ <option value="user" data-i18n="ext_diceroll_role_user">User message</option>
53+ </select>
54+ </div>
55+ <div class="flex1">
56+ <label for="diceroll_direction_role" data-i18n="ext_diceroll_direction_role">Direction sent as</label>
57+ <select id="diceroll_direction_role" class="text_pole">
58+ <option value="system" data-i18n="ext_diceroll_role_system">System message</option>
59+ <option value="user" data-i18n="ext_diceroll_role_user">User message</option>
60+ </select>
61+ </div>
62+ </div>
63+
64+ <label for="diceroll_options_prompt" data-i18n="ext_diceroll_options_prompt">Option-generation prompt (\{{min}}, \{{max}} available)</label>
65+ <textarea id="diceroll_options_prompt" class="text_pole textarea_compact" rows="7"></textarea>
66+ <div id="diceroll_options_prompt_restore" class="menu_button menu_button_icon">
67+ <i class="fa-solid fa-clock-rotate-left"></i>
68+ <span data-i18n="ext_diceroll_restore">Restore default</span>
69+ </div>
70+
71+ <label for="diceroll_direction_template" data-i18n="ext_diceroll_direction_template">Direction message template (\{{outcome}}, \{{probability}} available)</label>
72+ <textarea id="diceroll_direction_template" class="text_pole textarea_compact" rows="4"></textarea>
73+ <div id="diceroll_direction_template_restore" class="menu_button menu_button_icon">
74+ <i class="fa-solid fa-clock-rotate-left"></i>
75+ <span data-i18n="ext_diceroll_restore">Restore default</span>
76+ </div>
77+ </div>
78+ </div>
79+</div>
public/scripts/extensions/diceroll/style.css+42 -0
@@ -0,0 +1,42 @@
1+.diceroll_debug {
2+ margin-top: 5px;
3+ padding: 5px 10px;
4+ border: 1px dashed var(--SmartThemeBorderColor, #888);
5+ border-radius: 5px;
6+ font-size: calc(var(--mainFontSize) * 0.85);
7+ opacity: 0.85;
8+}
9+
10+.diceroll_debug summary {
11+ cursor: pointer;
12+}
13+
14+.diceroll_debug_table {
15+ border-collapse: collapse;
16+ margin: 5px 0;
17+ width: 100%;
18+}
19+
20+.diceroll_debug_table td {
21+ padding: 1px 8px 1px 0;
22+ vertical-align: top;
23+ border: none;
24+}
25+
26+.diceroll_debug_table td:first-child {
27+ text-align: right;
28+ white-space: nowrap;
29+ font-variant-numeric: tabular-nums;
30+ width: 1%;
31+}
32+
33+.diceroll_debug_table tr.diceroll_chosen {
34+ font-weight: bold;
35+ color: var(--SmartThemeQuoteColor, inherit);
36+}
37+
38+.diceroll_debug_direction {
39+ white-space: pre-wrap;
40+ font-style: italic;
41+ margin-top: 4px;
42+}