Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1 @@
# These are supported funding model platforms

github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: hype
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
25 changes: 15 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,18 @@ The extension is a thin scheduler layered on top of SillyTavern's existing Conne
### Hook points (SillyTavern event system)
Use `eventSource.on(event_types.X, handler)` for all of these. Import `eventSource` and `event_types` from `../../../../script.js` (verified — see "Verified ST internals" below).

- `MESSAGE_RECEIVED` — primary trigger for advancing the response counter. **Emitted with `(messageId, type)`** where `type` is one of `'swipe'`, `'continue'`, `'append'`, `'appendFinal'`, `'regenerate'`, `'impersonate'`, `'quiet'`, `'first_message'`, or `undefined` for a normal generation. **Advance the counter only when `type` is undefined/normal.** Filtering on `type` is what makes swipes, regens, continues, and impersonations not consume rotation slots — there is no other event-level guard.
- `MESSAGE_RECEIVED` — primary trigger for advancing the response counter. **Emitted with `(messageId, type)`** where `type` is one of `'swipe'`, `'continue'`, `'append'`, `'appendFinal'`, `'regenerate'`, `'impersonate'`, `'quiet'`, `'first_message'`, `'command'` (/sendas-inserted character messages), `'extension'` (extension-inserted messages, e.g. stable-diffusion), `'normal'`, or `undefined` for a normal generation. **Advance the counter only when `type` is undefined/null/`'normal'`** — `isCountableGeneration()` is a strict whitelist so unknown future types fail closed. One trap the emitted type alone cannot catch: a **non-streaming regenerate** deletes the AI message being redone *before* generating, so by the time `saveReply` emits `MESSAGE_RECEIVED` the preceding message is the user's and the type is **coerced to `'normal'`** (streaming regens emit `'regenerate'` faithfully). `events.js` therefore also records the type each non-dry `GENERATION_STARTED` carried (`lastGenerationType`) and refuses to count a message whose *generation* started non-countable.
- `GENERATION_STARTED` — emitted with `(type, options, dryRun)`. The moment to fire the profile switch *before* the next generation, if the rotation says it's time to switch. **Skip when `dryRun === true`** (ST emits this for prompt-token-counting and similar dry runs) and skip non-normal `type` values (same set as above). This is critical: we switch profiles *before* the model generates, not after.
- `MESSAGE_SWIPED` — fires when the user navigates between **existing** swipes via the left/right arrows; not on swipe-regeneration. Largely irrelevant to the scheduler since the `type` filter on `MESSAGE_RECEIVED`/`GENERATION_STARTED` already handles regen-swipes.
- `CHAT_CHANGED` — emitted as `'chat_id_changed'`. Reload per-chat rotation state when the user switches chats.
- `CONNECTION_PROFILE_LOADED` — fires for **both** manual user switches *and* our own programmatic `/profile` calls (verified: see "Verified ST internals"). The handler must consult an internal `isInternalSwitch` flag we set immediately before firing `/profile` and clear inside the handler — otherwise we trip our own manual-override path on every rotation.
- `CONNECTION_PROFILE_LOADED` — fires for **both** manual user switches *and* our own programmatic `/profile` calls (verified: see "Verified ST internals"). Payload is the profile **name** string (or `'<None>'`). The handler must consult an internal `isInternalSwitch` flag we set immediately before firing `/profile` — otherwise we trip our own manual-override path on every rotation. The flag is cleared by a `queueMicrotask` in `switchProfile`'s `finally` (not by the handler itself): the microtask defer means every synchronous listener that fires while the slash command resolves still sees the flag set, however many there are.
- `CHARACTER_DELETED` — emitted with `({ id, character })`. Purge that character's queue binding.
- `CHARACTER_RENAMED` — emitted with `(oldAvatar, newAvatar)`. Avatar filenames track the character name, so a rename moves our binding key; re-point it or the binding is silently orphaned.
- `APP_READY` — boot-time safety net for character-binding auto-start. `eventSource` is constructed with `autoFireAfterEmit` covering `APP_READY`, so a listener registered *after* the event already fired is invoked immediately with the last args. ST inits extensions before loading the first chat, so `CHAT_CHANGED` normally covers startup on its own; this is belt-and-braces for late-loading installs.

**Generation type strings** (for the filter logic in `rotation.js`):
- Advance counter / fire switch: `undefined` (normal user generation).
- Ignore: `'swipe'`, `'regenerate'`, `'continue'`, `'append'`, `'appendFinal'`, `'impersonate'`, `'quiet'`, `'first_message'`, plus any `dryRun === true`.
- Advance counter / fire switch: `undefined`, `null`, or `'normal'` (normal user generation). This is a **whitelist** — everything else is ignored, so a type ST adds tomorrow can never silently consume rotation slots.
- Known ignored types: `'swipe'`, `'regenerate'`, `'continue'`, `'append'`, `'appendFinal'`, `'impersonate'`, `'quiet'`, `'first_message'`, `'command'`, `'extension'`, plus any `dryRun === true` and any message whose `GENERATION_STARTED` type was non-countable (the coerced-regenerate guard above).

### Profile switching mechanism
Use SillyTavern's slash command system to switch profiles. The `/profile` command is registered by the built-in Connection Profiles extension and accepts an `await=true` named arg that resolves only after `CONNECTION_PROFILE_LOADED` and an online-status check — use it so the switch is synchronous from our perspective:
Expand Down Expand Up @@ -132,13 +132,15 @@ type ChatRouletteState = {
activeQueueId: string | null; // which queue is running, null = rotation off
currentSlotId: string | null; // the slot currently in effect
responsesRemaining: number; // counter that decrements per accepted response
responsesAllotted: number; // what the counter started at — the UI renders "N of M"
lastSwitchMessageId: number | null; // message index where we last switched, for diagnostics
history: Array<{ // optional log: which profile generated which message
messageId: number;
messageId: number; // capped at 500 entries (appendHistory)
profileName: string;
timestamp: number;
}>;
manuallyOverridden: boolean; // see "Manual override behavior"
autoBindHandled: boolean; // see "Per-character queue bindings" — the auto-start latch
};
```

Expand Down Expand Up @@ -166,7 +168,7 @@ On `MESSAGE_SWIPED`: do nothing. Swipes are same-slot retries by design.

If the user manually switches connection profiles via the existing ST UI (detected via `CONNECTION_PROFILE_LOADED` for a profile that doesn't match `currentSlotId`'s profile), set `manuallyOverridden = true`. The status indicator changes to "Rotation paused (manual override)" with a "Resume" button. Clicking Resume:
- If on the same profile a slot points to, set `manuallyOverridden = false` and continue from the current state.
- Otherwise, set `manuallyOverridden = false`, treat the next response as the start of a new rotation cycle (re-pick or advance).
- Otherwise, set `manuallyOverridden = false`, treat the next response as the start of a new rotation cycle (re-pick or advance). Implemented in `resumeRotation()` by zeroing `responsesRemaining`: the existing `GENERATION_STARTED` path then advances/re-picks and switches before the next generation, and the pick history never attributes foreign-profile responses to the slot.

### 6. Error handling

Expand Down Expand Up @@ -344,6 +346,8 @@ colour), per-slot sampler tuning (`src/sampling.js`), per-character bindings
SillyTavern-Roulette/
├── manifest.json # ST extension manifest
├── package.json # node-only: `npm test`, type:module. ST ignores it.
├── assets/
│ └── banner.jpg # README hero image (keep it small — every install clones it)
├── index.js # entry point: init() wires every subsystem
├── style.css # scoped styles + --roulette-* token set
├── README.md # user-facing docs (value, install, recipes)
Expand All @@ -366,6 +370,7 @@ SillyTavern-Roulette/
│ ├── profileColors.js # hash-based stable profile colour assignment
│ ├── queueEditor.js # form builder shared by popup + embedded paths
│ ├── settingsPanel.js # drawer block: Enable/Disable Roulette + Settings
│ ├── confirm.js # themed yes/no dialog (replaces native confirm())
│ ├── templates.html # reserved for future fragments (currently empty)
│ └── tabs/
│ ├── rotation.js # dot-strip hero + status + actions + binding + pick history
Expand Down Expand Up @@ -444,7 +449,7 @@ The `../../../../` depth in the table above is for **`index.js` at the extension
### Connection profile enumeration
ST stores connection profiles in `extension_settings.connectionManager.profiles` (verified — `public/scripts/extensions.js:172`). Each profile is a `ConnectionProfile` with at least `id` and `name` (full JSDoc at `public/scripts/extensions/connection-manager/index.js:159`). The currently selected profile is tracked as an **id** at `extension_settings.connectionManager.selectedProfile`, *not* a name — convert when comparing.

The queue editor's profile dropdown should read from this list directly and refresh on `CONNECTION_PROFILE_LOADED`, `CONNECTION_PROFILE_CREATED`, `CONNECTION_PROFILE_DELETED`, `CONNECTION_PROFILE_UPDATED`, and on modal open. **Do not cache** the profile list — the user may add/remove profiles between sessions.
The queue editor's profile dropdown reads from this list directly, re-read at every slot-row render, and the modal remounts its tabs fresh on every open. **Do not cache** the profile list — the user may add/remove profiles between sessions. (No `CONNECTION_PROFILE_*` event subscription is needed for the editor: the modal is a native `<dialog>` shown with `showModal()`, which makes the rest of ST inert — the user cannot reach the connection manager to mutate profiles while the editor is open.)

### Modals — use ST's `Popup` class
Use the `Popup` class from `../../../../scripts/popup.js` for the queue editor instead of rolling our own modal. It handles z-index, focus trap, escape-to-cancel, and theme-correct styling automatically. `POPUP_TYPE.TEXT` for content-driven popups, `POPUP_RESULT` for return-value comparison. The convenience function `callGenericPopup(content, type, inputValue, popupOptions)` is fine for simple cases; reserve the `new Popup(...)` constructor for the queue editor where we need custom buttons and form state.
Expand Down Expand Up @@ -521,15 +526,15 @@ The extension is "done" when all of the following are true on a fresh ST install
2. The Roulette panel appears in the Extensions drawer.
3. A user with at least 2 connection profiles can create a queue, save it, and activate it on a chat.
4. In **sequential** mode with fixed counts (e.g. A=3, B=2, C=4), generating 9 messages causes the active profile to be A for the first 3, B for the next 2, C for the next 4. Verified by checking the connection profile selector value before each generation.
5. Swiping a message (regenerate-as-swipe) does not advance the counter — verified via the `type === 'swipe'` filter on `MESSAGE_RECEIVED`.
6. Regenerating a message does not advance the counter — verified via the `type === 'regenerate'` filter on `MESSAGE_RECEIVED`.
5. Swiping a message (regenerate-as-swipe) does not advance the counter — verified via the countable-type whitelist on `MESSAGE_RECEIVED`.
6. Regenerating a message does not advance the counter, **with streaming on or off** — streaming regens carry `type === 'regenerate'`; non-streaming regens arrive coerced to `'normal'` and are caught by the `lastGenerationType` guard (see Hook points).
7. In **weighted-random** mode with weights 1/1/1 and run length 1, profiles switch every message and over 100 messages each profile is used roughly evenly (within reasonable variance).
8. With `noRepeatInRow: true`, no two consecutive responses ever come from the same profile (verified over 50+ messages).
9. Switching chats preserves each chat's independent rotation state.
10. Manually switching profiles mid-rotation pauses the rotation and surfaces the "Resume" affordance.
11. Deleting a profile that's in an active queue triggers the error path: that slot is skipped, rotation continues with the remaining profiles.
12. The pinned bar updates within one frame of any state change.
13. All four slash commands (`/roulette-start`, `/roulette-stop`, `/roulette-status`, `/roulette-skip`) work and produce sensible output.
13. All six slash commands (`/roulette-start`, `/roulette-stop`, `/roulette-status`, `/roulette-skip`, `/roulette-bind`, `/roulette-unbind`) work and produce sensible output; `/roulette-status` posts a visible system message (a bare command's return value is never displayed by ST).
14. No console errors during normal use.
15. The modal and bar render identically under any ST theme (they own their canvas); the drawer block follows the ST theme.

Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
![Roulette Banner](assets/banner.png)
![Roulette Banner](assets/banner.jpg)

# SillyTavern-Roulette

Expand Down Expand Up @@ -27,7 +27,11 @@ The longer your chat runs, the more value Roulette adds. Four reasons most users

## Install

In SillyTavern: **Extensions → Install Extension → Install from URL** and paste this repository's URL.
In SillyTavern: **Extensions → Install Extension → Install from URL** and paste:

```
https://github.com/hype-hosting/SillyTavern-Roulette
```

Manual install: clone into `data/<user>/extensions/SillyTavern-Roulette/` (per-user) or `public/scripts/extensions/third-party/SillyTavern-Roulette/` (global), then enable in the Extensions panel.

Expand Down
4 changes: 2 additions & 2 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

These walkthrough steps verify each acceptance criterion in a live SillyTavern. Run before tagging a release. Each section maps 1-to-1 with the acceptance criteria in `CLAUDE.md`.

> **v2.0 orientation.** The extension's primary surface is the **pinned bar** — a slim row in the chat column (default: above the message input) showing one colored dot per slot, with the active slot lit and carrying its responses-remaining count. The bar's gear opens the modal (**Rotation / Queues / Settings**). The Extensions drawer holds only **Enable/Disable Roulette** (bar visibility) and **Settings** (opens the modal). Wherever an old step said "pill", read "bar".
> **v2.0 orientation.** The extension's primary surface is the **pinned bar** — a slim row in the chat column (default: above the message input) showing one colored dot per slot, with the active slot lit and carrying its responses-remaining count. The bar's gear opens the modal (**Rotation / Queues / Settings**). The Extensions drawer holds only **Enable/Disable Roulette** (bar visibility) and **Settings** (opens the modal).

## Setup

1. Install SillyTavern 1.12+ and start it.
2. Define at least three connection profiles in **Connection Profiles** (any three providers/models — they're labels for these tests). Suggested names: `A`, `B`, `C`.
3. Install Roulette via **Extensions → Install Extension → Install from URL** with this repository's URL.
4. Hard-refresh the browser (`Cmd-Shift-R` / `Ctrl-Shift-R`) to bypass any stale `manifest.json` cache.
5. Open DevTools Console. You should see the `[Roulette] ...` init log lines ending with `init() complete`. No red errors anywhere.
5. Open DevTools Console. You should see a single `[Roulette] init() complete` log line. No red errors anywhere.

If init never logs, see `CLAUDE.md` → "Robustness pattern — self-invoke from top level".

Expand Down
Empty file removed assets/.gitkeep
Empty file.
Binary file added assets/banner.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed assets/banner.png
Binary file not shown.
19 changes: 3 additions & 16 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,38 +16,25 @@
import { registerEventListeners } from './src/events.js';
import { registerSlashCommands } from './src/slashCommands.js';
import { mountSettingsPanel } from './src/ui/settingsPanel.js';
import { openRouletteModal } from './src/ui/modal.js';
import { mountBar } from './src/ui/bar.js';
import { getSettings, applyUiSettings } from './src/state.js';

const EXT_NAME = 'Roulette';

console.log(`[${EXT_NAME}] module loaded`);

let initialized = false;

export async function init() {
if (initialized) {
console.log(`[${EXT_NAME}] init() called again — already initialized, skipping`);
return;
}
// Both the manifest hook and the self-invoke below call init(); the
// second call is a silent no-op.
if (initialized) return;
initialized = true;
console.log(`[${EXT_NAME}] init() called`);
try {
getSettings();
applyUiSettings();
registerEventListeners();
console.log(`[${EXT_NAME}] event listeners registered`);
registerSlashCommands();
console.log(`[${EXT_NAME}] slash commands registered`);
mountSettingsPanel();
console.log(`[${EXT_NAME}] settings panel mounted`);
mountBar();
console.log(`[${EXT_NAME}] pinned bar mounted (if enabled)`);
// Debug helper: window.__roulette.openModal() pops the modal.
// Removed before v1.0 release; useful during the build-out of
// each tab.
globalThis.__roulette = { openModal: openRouletteModal };
console.log(`[${EXT_NAME}] init() complete`);
} catch (err) {
initialized = false; // allow retry
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "2.0.0",
"private": true,
"description": "Rotate between SillyTavern connection profiles during roleplay.",
"license": "AGPL-3.0",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"test": "node --test tests/*.test.mjs"
Expand Down
Loading
Loading