From a936c275536f343c0dde0216d28f1e4fa238cddb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 15:10:06 +0000 Subject: [PATCH 1/2] v1.3: per-character queue bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind a queue to a character card; opening that character's chat starts the queue and switches the connection profile before the first generation. This was the "per-character default queues" item from the future-work list. ## Storage extension_settings.roulette.characterQueues = { [avatarFilename]: queueId } Characters are keyed by AVATAR FILENAME, never by this_chid. this_chid is an index into the live characters array and shifts whenever a character is added, deleted, or the list is re-sorted — persisting it would silently re-point bindings at the wrong character. The avatar filename is what ST core and every bundled extension (quick-reply, gallery, attachments, stats) uses for per-character storage. Group chats are excluded. getCurrentCharacter() returns null while a group is selected, making every binding path inert there. Matches quick-reply's per-character config, which bails on selected_group for the same reason: a group has several members and no non-arbitrary answer to whose binding wins. ## Activation rules The precedence logic is a pure function — decideAutoActivation() in rotation.js — so it is testable without ST: no character / no binding -> none autoBindHandled already set -> none chat already has a rotation -> mark-handled (never clobber it) bound queue no longer exists -> clear-binding otherwise -> start The autoBindHandled latch (per-chat, in chat_metadata) is what makes auto-start tolerable: stopRotation() sets it, so a rotation the user deliberately stopped does not resurrect every time the chat is reopened. reevaluateAutoActivation() clears it on purpose when a binding changes from the UI, so binding a character while sitting in their chat takes effect immediately rather than appearing to do nothing. Ordering matters: the running-rotation check precedes the stale-queue check, so we never mutate settings while a rotation the user cares about is in flight. ## Events CHAT_CHANGED -> maybe auto-start (chat_metadata is bound before this event emits: script.js:7598 vs :7641) APP_READY -> boot-time safety net. eventSource's autoFireAfterEmit set covers APP_READY, so a late-registering listener still fires. Not strictly needed — firstLoadInit() runs initExtensions() before getCharacters() — but free, and idempotent via the latch. CHARACTER_DELETED -> purge the binding ({ id, character } payload) CHARACTER_RENAMED -> follow it to the new avatar key (oldAvatar, newAvatar) deleteQueue() prunes bindings pointing at the removed queue, inline in state.js: routing it through characterBinding.js would form an import cycle. ## UI Two surfaces over one map: - Chamber tab gains a contextual "Auto-start for " select, hidden entirely in group chats. Uses implicit label association rather than a fixed element id, so a fast close/reopen of the modal can't transiently duplicate it. - Queue cards gain a masks icon opening a searchable character multi-select (src/ui/bindingPicker.js), plus a count chip. A character has at most one queue, so the picker flags characters already bound elsewhere instead of silently rebinding them. Rows are built with textContent — character names are user-supplied. New CSS is placed before the media queries, not appended, so the responsive overrides still win on source order. ## Slash commands /roulette-bind bind the current character /roulette-unbind remove the current character's binding Both refuse in group chats with an explanatory toast. ## Tests First automated coverage in the repo. src/rotation.js imports nothing, so `npm test` runs it under plain node with no browser, no ST, and no mocks. Twelve tests over slot sequencing, the noRepeatInRow guarantee, weighted distribution, the generation-type filter, and the new precedence rules. Verified non-vacuous by mutation: swapping the precedence order and disabling the latch each fail exactly one test. Docs: CLAUDE.md was stale at v1.0.0 — refreshed the version, repo tree (sampling.js and widget.js were undocumented), hook points, verified ST internals, and scope lists. README gains a Per-Character Queues section and a corrected version table (it linked to a CHANGELOG.md that does not exist). TESTING.md gains criteria 16-20. Manifest 1.2.0 -> 1.3.0. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J5wRPLqtW32eqWY9hi9uFt --- CLAUDE.md | 107 ++++++++++++++++++++-- README.md | 39 +++++++- TESTING.md | 68 +++++++++++++- manifest.json | 2 +- package.json | 11 +++ src/characterBinding.js | 192 +++++++++++++++++++++++++++++++++++++++ src/events.js | 140 +++++++++++++++++++++++++++- src/rotation.js | 50 +++++++++- src/slashCommands.js | 66 +++++++++++++- src/state.js | 17 ++++ src/ui/bindingPicker.js | 155 +++++++++++++++++++++++++++++++ src/ui/tabs/chamber.js | 75 ++++++++++++++- src/ui/tabs/queues.js | 17 +++- style.css | 167 ++++++++++++++++++++++++++++++++++ tests/rotation.test.mjs | 197 ++++++++++++++++++++++++++++++++++++++++ 15 files changed, 1283 insertions(+), 20 deletions(-) create mode 100644 package.json create mode 100644 src/characterBinding.js create mode 100644 src/ui/bindingPicker.js create mode 100644 tests/rotation.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 1ef56f9..dcd0ec7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ A SillyTavern extension that rotates between connection profiles during roleplay — sequentially or by weighted random — so the user can mix models, parameters, and providers throughout a chat without manually switching. -**Version:** 1.0.0 +**Version:** 1.3.0 **License:** AGPL-3.0 (matches SillyTavern) **Target:** SillyTavern 1.12+ (uses Connection Manager / connection profiles API) **Repo name:** `SillyTavern-Roulette` @@ -33,6 +33,9 @@ Use `eventSource.on(event_types.X, handler)` for all of these. Import `eventSour - `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. +- `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). @@ -51,7 +54,7 @@ await executeSlashCommandsWithOptions(`/profile await=true ${profileName}`, { sh `/profile` does fuzzy matching by name via Fuse.js, so an exact-name validation pass against `extension_settings.connectionManager.profiles` is required *before* firing — otherwise a typo'd slot may silently switch to an unrelated profile. ### State storage -- **Global settings** (rotation queues the user has defined, default rotation, UI preferences) → `extension_settings.roulette` (persisted via `saveSettingsDebounced()`). +- **Global settings** (rotation queues the user has defined, default rotation, UI preferences, per-character queue bindings) → `extension_settings.roulette` (persisted via `saveSettingsDebounced()`). - **Per-chat rotation state** (which queue is active, current slot index, responses remaining in current slot, history of which profile generated which message) → `chat_metadata.roulette` (persisted via `saveMetadataDebounced()` from `../../../../scripts/extensions.js`). This split is deliberate: queue *definitions* are user-level assets reused across chats; *active rotation state* is chat-specific so different stories can run different rotations independently. @@ -201,11 +204,69 @@ Register Roulette commands so power users can script: - `/roulette-stop` — deactivate rotation. - `/roulette-status` — print current state to the chat as a system message. - `/roulette-skip` — force-advance to the next slot immediately. +- `/roulette-bind ` — bind the current character to a queue (auto-starts on chat load). +- `/roulette-unbind` — remove the current character's binding. Use ST's `SlashCommandParser.addCommandObject()` (verify exact import at scaffold time). --- +### 9. Per-character queue bindings (v1.3) + +"Open this character's chat, run this queue." Bindings live in +`extension_settings.roulette.characterQueues` as `{ [avatarFilename]: queueId }`. + +**Keying.** Characters are keyed by **avatar filename**, never by `this_chid`. +`this_chid` is an index into the live `characters` array and shifts whenever a +character is added, deleted, or the list is re-sorted — persisting it would +silently re-point bindings at the wrong character. The avatar filename is what +ST core and every bundled extension (quick-reply, gallery, attachments, stats) +uses for per-character storage. + +**Group chats are excluded.** `getCurrentCharacter()` returns `null` while +`groupId` is set, which makes every binding path inert there. This matches +quick-reply's per-character config, which bails on `selected_group` for the +same reason: a group has several members and no non-arbitrary answer to +"whose binding wins?". + +**Activation rules.** The precedence logic is a pure function, +`decideAutoActivation()` in `rotation.js`, so it can be tested without ST: + +| Situation | Action | +|---|---| +| No character (group chat / nothing loaded) | `none` | +| Character has no binding | `none` | +| `autoBindHandled` already set for this chat | `none` | +| Chat already has a rotation running | `mark-handled` — never clobber it | +| Bound queue no longer exists | `clear-binding` | +| Otherwise | `start` | + +**The `autoBindHandled` latch** (per-chat, in `chat_metadata.roulette`) is what +makes auto-start tolerable. `stopRotation()` sets it, so a rotation the user +deliberately stopped does not resurrect every time the chat is reopened; +`startRotation()` sets it too, since the question is then settled either way. +`reevaluateAutoActivation()` clears it on purpose when the user changes a +binding from the UI, so binding a character while sitting in their chat takes +effect immediately instead of appearing to do nothing. + +Note the ordering: the running-rotation check comes *before* the stale-queue +check, so we never mutate settings while a rotation the user cares about is +mid-flight. + +**Lifecycle.** `CHARACTER_DELETED` purges the binding; `CHARACTER_RENAMED` +moves it to the new avatar key; `deleteQueue()` prunes any bindings pointing at +the removed queue (done inline in `state.js` — routing it through +`characterBinding.js` would form an import cycle, since that module imports +`state.js`). + +**UI.** Two surfaces, one map: a contextual `Auto-start for ` select +in the Chamber tab, and a masks-icon character multi-select on each queue card +in the Queues tab (`src/ui/bindingPicker.js`). A character has at most one +queue, so the picker calls out characters already bound elsewhere rather than +silently rebinding them. + +--- + ## Shipped in v1.0 (originally out-of-scope or stretch) These were called out as v2 / stretch in the original spec but landed in v1.0: @@ -222,17 +283,17 @@ These were called out as v2 / stretch in the original spec but landed in v1.0: - **Per-slot parameter overrides** beyond what the connection profile carries. v1.0 leans entirely on profiles; for "DeepSeek cold" vs "DeepSeek warm", make two profiles. - **Blind mode** (hide which profile generated which message until reveal). -- **Per-character default queues** (auto-activate a queue when a particular character is loaded). - **Cross-chat statistics** (how often each profile was used over all time, summary dashboards). - **Drag-load metaphor (variant L)** — the queue's chambers being directly loaded by dragging profile chips onto the cylinder, with the queue abstraction implicit. v1.0 ships variant P (read-only Chamber tab; editing in Queues tab). --- -## Repository structure (v1.0) +## Repository structure (v1.3) ``` SillyTavern-Roulette/ ├── manifest.json # ST extension manifest +├── package.json # node-only: `npm test`, type:module. ST ignores it. ├── index.js # entry point: init() wires every subsystem ├── style.css # scoped styles + --roulette-* token set ├── README.md # user-facing docs (value, install, recipes) @@ -240,27 +301,46 @@ SillyTavern-Roulette/ ├── LICENSE # AGPL-3.0 ├── src/ │ ├── state.js # extension_settings + chat_metadata helpers; UI prefs -│ ├── rotation.js # PURE core rotation logic (no ST imports) +│ ├── rotation.js # PURE core logic (no ST imports) — scheduling + auto-activation rules │ ├── profileSwitcher.js # /profile slash-command wrapper + isInternalSwitch flag -│ ├── events.js # ST event listeners + scheduler (start/stop/skip/spin) +│ ├── events.js # ST event listeners + scheduler (start/stop/skip/spin/auto-start) +│ ├── characterBinding.js # per-character queue bindings; ALL character-identity handling +│ ├── sampling.js # v1.2 per-slot sampler tuning via ST's preset system │ ├── slashCommands.js # /roulette-* command definitions │ ├── exportImport.js # queue JSON file download/upload helpers │ └── ui/ │ ├── modal.js # tabbed modal chassis (Chamber/Queues/History/Settings) │ ├── cylinder.js # SVG glassy revolver-cylinder + spin animation +│ ├── widget.js # v1.1 floating draggable mini-cylinder panel +│ ├── bindingPicker.js # character multi-select popup, opened from a queue card │ ├── profileColors.js # hash-based stable profile colour assignment │ ├── queueEditor.js # form builder shared by popup + embedded paths │ ├── settingsPanel.js # drawer block: status + quick actions + Open Roulette │ ├── statusIndicator.js # chat-input pill (icon by default, hover for details) │ ├── templates.html # reserved for future fragments (currently empty) │ └── tabs/ -│ ├── chamber.js # cylinder hero + status + Spin/Skip/Stop/Resume +│ ├── chamber.js # cylinder hero + status + actions + character-binding row │ ├── queues.js # card grid + inline editor (replaces right pane) │ ├── history.js # trail strip + per-pick rows │ └── settings.js # animation-speed slider + accent-colour picker +├── tests/ +│ └── rotation.test.mjs # node --test over the pure core (no ST, no DOM, no mocks) └── .gitignore ``` +### Tests + +`src/rotation.js` imports nothing, so it runs under plain node: + +``` +npm test # node --test tests/*.test.mjs +``` + +Covers slot sequencing, the `noRepeatInRow` guarantee, weighted distribution, +the generation-type filter, and the auto-activation precedence rules. Anything +touching ST's event system or the DOM stays in `TESTING.md` as a manual walk. +Keep `rotation.js` ST-free — that property is what makes this possible. + ### `manifest.json` ```json @@ -272,7 +352,7 @@ SillyTavern-Roulette/ "js": "index.js", "css": "style.css", "author": "Hyperion Blackthorne", - "version": "1.0.0", + "version": "1.3.0", "homePage": "https://github.com/hype-hosting/SillyTavern-Roulette", "auto_update": true, "hooks": { "activate": "init" } @@ -380,16 +460,25 @@ Verified against `SillyTavern/SillyTavern@release` at commit `51ad27f` (Merge PR 4. **`CONNECTION_PROFILE_LOADED` and our own switches** — confirmed: yes, the event fires for our `/profile` calls too (the change-handler at `public/scripts/extensions/connection-manager/index.js:752` emits unconditionally). We must use an `isInternalSwitch` flag. 5. **Manifest schema** — fields ST honors: `display_name`, `loading_order`, `requires`, `optional`, `js`, `css`, `author`, `version`, `homePage` (camelCase), `auto_update`, `hooks` (`{activate: 'init'}` convention), plus optional `i18n` and `generate_interceptor`. All bundled extensions use `hooks.activate = 'init'` and we will too. 6. **Popup helper** — yes: `Popup` class at `public/scripts/popup.js:148`, `callGenericPopup` at line 909, `POPUP_TYPE`/`POPUP_RESULT` enums. Use these instead of rolling a custom modal. +7. **Character identity** — `getContext()` (re-exported from `public/scripts/extensions.js:14-18`, defined in `public/scripts/st-context.js:114`) returns `characters`, `characterId` (`this_chid`), `groupId` (`selected_group`), and `groups`. `characterId` is an **array index**, not a stable id: the canonical stable key is `characters[this_chid].avatar`, used throughout ST core (`chats.js`, `tags.js`, `stats.js`, `personas.js`) and by bundled extensions. Group ids (`groups[].id`) are server-assigned on create and stable. +8. **`chat_metadata` is bound before `CHAT_CHANGED`** — `public/script.js:7598` assigns `chat_metadata` from the chat header; the event emits at `:7641`. Reading the incoming chat's rotation state inside a `CHAT_CHANGED` handler is therefore safe. +9. **Extensions init before the first chat loads** — `firstLoadInit()` calls `await initExtensions()` (`public/script.js:745`) before `getCharacters()` and everything else, so listeners registered during extension init do receive the first `CHAT_CHANGED`. +10. **`APP_READY` replays to late listeners** — `eventSource = new EventEmitter([APP_READY, APP_INITIALIZED])` (`public/scripts/events.js:113`); `EventEmitter.prototype.on` invokes the listener immediately if the event is in `autoFireAfterEmit` and has already fired (`public/lib/eventemitter.js`). Safe to rely on for catch-up work. --- +## v1.1 – v1.3 — done + +- **v1.1** — floating draggable widget (`src/ui/widget.js`) mirroring the cylinder during chat; glassmorphism + spring-easing pass; RGB-tuple token system. +- **v1.2** — per-slot inline sampler tuning (`src/sampling.js`) overlaid through ST's preset machinery, with managed presets cleaned up on slot/queue removal. +- **v1.3** — per-character queue bindings (`src/characterBinding.js`, `src/ui/bindingPicker.js`) plus the first automated test coverage of the pure core. + ## v1.0 milestone — done The original v1 acceptance criteria all pass (see `TESTING.md` for the manual walkthrough). The modal redesign, glassy cylinder, drag-to-reorder, simulate-20-picks, queue export/import, history view, and user UI prefs all shipped on top. ## Future work -- **Per-character default queues** (auto-activate when a character is loaded). - **Blind mode** — hide which profile generated which message until the user reveals. - **Drag-load chamber metaphor** — drop profile chips directly onto chambers; the queue becomes implicit. Bigger UX change; wait until users ask for it. - **Cross-chat statistics** — total runs per profile, distribution dashboards. diff --git a/README.md b/README.md index 98352f3..be7a43b 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,34 @@ Mode: **Weighted-random** · run length range `1–2`. --- +## Per-Character Queues + +Different characters want different rotations. A hard-boiled detective and a +whimsical fae shopkeeper rarely benefit from the same blend of models. + +Bind a queue to a character once and it starts itself from then on: + +- **In the modal** — open **Chamber** while that character's chat is loaded and + pick a queue from **Auto-start for _character_**. +- **From a queue** — in **Queues**, click the masks icon on a queue card and + tick every character that should use it. +- **From chat** — `/roulette-bind Long-fiction blend`. + +Opening one of that character's chats then starts the queue and switches the +connection profile before the first generation. Three rules keep it out of your +way: + +| Situation | What happens | +|---|---| +| Chat already has a rotation running | Left completely alone — the running rotation always wins | +| You stopped rotation in that chat | Stays stopped, even after reopening it | +| Group chat | Bindings don't apply; a group has several members and no obvious winner | + +A character can have one queue; a queue can serve any number of characters. +Renaming or deleting a character updates or removes its binding automatically. + +--- + ## Modes & Options | Mode | Behaviour | @@ -120,8 +148,8 @@ Mode: **Weighted-random** · run length range `1–2`. | Tab | What it is | |---|---| -| **Chamber** | The live cylinder. Brass collar around glassy chambers; active chamber highlighted with a brass glow + pip ring counting down responses-remaining. Sequential clicks one notch per advance; weighted-random spins with wheel-of-fortune deceleration. | -| **Queues** | Card grid of saved queues, each with a mini-cylinder preview. Click to edit (drag-to-reorder slots, simulate 20 picks live). Import / export as JSON for sharing. | +| **Chamber** | The live cylinder. Brass collar around glassy chambers; active chamber highlighted with a brass glow + pip ring counting down responses-remaining. Sequential clicks one notch per advance; weighted-random spins with wheel-of-fortune deceleration. Below it, an **Auto-start for _character_** picker binds the current character to a queue. | +| **Queues** | Card grid of saved queues, each with a mini-cylinder preview. Click to edit (drag-to-reorder slots, simulate 20 picks live). The masks icon opens a character picker — bind many characters to one queue at once. Import / export as JSON for sharing. | | **History** | Every AI response logged with its profile of origin. Trail strip shows the last twelve, full list newest-first. | | **Settings** | Animation speed slider (0.25× to 2×), accent-colour picker (six metal presets + custom), profile-palette display. | @@ -135,6 +163,8 @@ Mode: **Weighted-random** · run length range `1–2`. | `/roulette-stop` | Deactivate rotation | | `/roulette-status` | Print current state | | `/roulette-skip` | Force-advance to the next slot | +| `/roulette-bind ` | Auto-start that queue whenever the current character is loaded | +| `/roulette-unbind` | Remove the current character's binding | --- @@ -142,7 +172,10 @@ Mode: **Weighted-random** · run length range `1–2`. | Version | Status | Notes | |---|---|---| -| [v1.0](./CHANGELOG.md) | **Current** | Initial public release. Modal redesign, glassy cylinder, drag-to-reorder, simulate-20-picks, queue export/import, history view, animation/accent settings. | +| v1.3 | **Current** | Per-character queue bindings — open a character's chat, their queue starts itself. | +| v1.2 | | Inline sampler tuning per slot, overlaid through SillyTavern's preset system. | +| v1.1 | | Floating draggable widget mirroring the cylinder during chat; glassmorphism pass. | +| v1.0 | | Initial public release. Modal redesign, glassy cylinder, drag-to-reorder, simulate-20-picks, queue export/import, history view, animation/accent settings. | --- diff --git a/TESTING.md b/TESTING.md index ec8f414..3800608 100644 --- a/TESTING.md +++ b/TESTING.md @@ -113,8 +113,10 @@ In the chat input, type each command: - `/roulette-start seq-test` → activates `seq-test`. Pill becomes active. - `/roulette-skip` → forces an immediate slot advance. Pill profile changes; counter resets to the new slot's count. - `/roulette-stop` → deactivates. Pill dims. +- `/roulette-bind seq-test` → binds the current character. Echoes `seq-test`. +- `/roulette-unbind` → removes it. Echoes the queue name that was unbound. -**Pass:** All four commands behave as described, with no console errors. +**Pass:** All six commands behave as described, with no console errors. ## Criterion 14 — No console errors @@ -126,6 +128,70 @@ Use Roulette normally for 5–10 minutes (start, stop, swipe, regen, switch chat Deferred to the v1 release styling pass. The current scaffolded styles use ST CSS variables (`--SmartThemeBodyColor`, `--SmartThemeBlurTintColor`, `--SmartThemeBorderColor`) so they should at minimum be readable in both, but no manual audit has been done yet. +## Criterion 16 — Per-character binding auto-starts + +Requires two character cards (call them **Alice** and **Bob**) and two queues. + +1. Open Alice's chat. In the modal's **Chamber** tab, set **Auto-start for Alice** to `seq-test`. + - Rotation should start immediately — the profile switches and the pill goes active. +2. Switch to Bob's chat (no binding). Rotation should be off. +3. Switch back to Alice, into a **new** chat. + - `seq-test` starts on its own before the first generation. + +**Pass:** Alice's chats auto-start `seq-test`; Bob's don't. + +## Criterion 17 — Bindings never clobber or resurrect a rotation + +The two rules that keep auto-start out of the user's way: + +1. **Running rotation wins.** In an Alice chat, `/roulette-start other-queue`. + Switch away and back. It must still be `other-queue`, not `seq-test`. +2. **A stop sticks.** In an Alice chat, press **Stop**. Switch away and back. + Rotation must stay off — the binding must not restart it. + +**Pass:** Neither rule is violated. (Both are also covered by `npm test`.) + +## Criterion 18 — Binding lifecycle + +1. Bind Alice to a queue, then **delete that queue** in the Queues tab. + Reopen Alice's chat — no rotation starts, no console error, and the Chamber + dropdown reads "Nothing". +2. Bind Alice to a queue, then **rename Alice** in ST. Reopen her chat — + the binding still applies under her new name. +3. Bind Alice, then **delete Alice**. The queue card's binding count drops. + +**Pass:** No orphaned bindings, no errors. + +## Criterion 19 — Group chats are inert + +Open a group chat. + +- The Chamber tab's **Auto-start for…** row is hidden entirely. +- `/roulette-bind seq-test` warns that bindings don't apply to group chats. +- No rotation auto-starts, regardless of member bindings. + +**Pass:** Bindings have no effect in groups, and say so when asked. + +## Criterion 20 — Queue-card character picker + +In the Queues tab, click the masks icon on a queue card. + +- Every character is listed, filterable by the search box. +- Characters bound to a *different* queue show a "currently <queue>" chip. +- Ticking one and saving rebinds it; the card's count chip updates. + +**Pass:** Bindings round-trip correctly and conflicts are visible before saving. + +--- + +## Automated coverage + +`npm test` (node 18+, no dependencies) runs `tests/rotation.test.mjs` over the +pure core: slot sequencing, `noRepeatInRow`, weighted distribution, the +generation-type filter, and the binding auto-activation precedence rules. +It replaces hand-walking criteria 4, 7, 8 and the rule-checks in 17 — the +criteria above remain the manual check that the wiring around them is right. + --- ## Reporting diff --git a/manifest.json b/manifest.json index 954b8f7..0591123 100644 --- a/manifest.json +++ b/manifest.json @@ -6,7 +6,7 @@ "js": "index.js", "css": "style.css", "author": "Hyperion Blackthorne", - "version": "1.2.0", + "version": "1.3.0", "homePage": "https://github.com/hype-hosting/SillyTavern-Roulette", "auto_update": true, "hooks": { diff --git a/package.json b/package.json new file mode 100644 index 0000000..13e0372 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "sillytavern-roulette", + "version": "1.3.0", + "private": true, + "description": "Rotate between SillyTavern connection profiles during roleplay.", + "license": "AGPL-3.0", + "type": "module", + "scripts": { + "test": "node --test tests/*.test.mjs" + } +} diff --git a/src/characterBinding.js b/src/characterBinding.js new file mode 100644 index 0000000..6798332 --- /dev/null +++ b/src/characterBinding.js @@ -0,0 +1,192 @@ +/* SillyTavern-Roulette — AGPL-3.0 + * + * Per-character queue bindings: "when this character is loaded, run this + * queue." Every piece of ST character-identity handling lives here so the + * rest of the extension never has to think about it. + * + * Two rules drive the whole module: + * + * 1. Characters are keyed by AVATAR FILENAME, never by `this_chid`. + * `this_chid` is an index into the live `characters` array — it shifts + * whenever a character is added, deleted, or the list is re-sorted, so + * persisting it would silently re-point bindings at the wrong character. + * The avatar filename is what ST core and every bundled extension + * (quick-reply, gallery, attachments, stats) uses for per-character + * storage, so we match it. + * + * 2. Group chats are excluded. `getCurrentCharacter()` returns null while a + * group is selected, which makes every binding path inert there. This + * matches quick-reply's per-character config, which bails on + * `selected_group` for the same reason: a group has several members and + * no non-arbitrary answer to "whose binding wins?". + * + * Avatar filenames are stable but not immutable — renaming a character + * rewrites it. `remapBinding()` is wired to CHARACTER_RENAMED and + * `purgeBindingsForCharacter()` to CHARACTER_DELETED (see src/events.js) to + * keep the map from rotting. + */ + +import { getContext } from '../../../../../scripts/extensions.js'; +import { getSettings, persistSettings } from './state.js'; + +/** + * The binding map: { [avatarFilename]: queueId }. + * Always reached through getSettings() so the backfill runs. + */ +function bindings() { + return getSettings().characterQueues; +} + +/** + * True while a group chat is selected. + * @returns {boolean} + */ +export function isGroupChat() { + return !!getContext()?.groupId; +} + +/** + * The character the chat is currently pointed at. + * + * Returns null when a group chat is open or no character is loaded — callers + * can treat null as "bindings do not apply right now". + * + * Never cache this. `getContext()` snapshots `this_chid` at call time, and + * the user can change character at any moment. + * + * @returns {{key: string, name: string} | null} + */ +export function getCurrentCharacter() { + const ctx = getContext(); + if (!ctx || ctx.groupId) return null; + const character = ctx.characters?.[ctx.characterId]; + if (!character?.avatar) return null; + return { key: character.avatar, name: character.name || prettifyKey(character.avatar) }; +} + +/** + * Every character ST knows about, for binding pickers. + * Sorted by display name so the picker is scannable. + * + * @returns {Array<{key: string, name: string}>} + */ +export function listCharacters() { + const characters = getContext()?.characters ?? []; + return characters + .filter(c => c?.avatar) + .map(c => ({ key: c.avatar, name: c.name || prettifyKey(c.avatar) })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * The queue bound to a character, if any. + * + * @param {string} characterKey avatar filename + * @returns {string|null} queue id + */ +export function getBoundQueueId(characterKey) { + if (!characterKey) return null; + return bindings()[characterKey] ?? null; +} + +/** + * Bind a character to a queue. Persists. + * + * A character has at most one queue, so this overwrites any previous + * binding for that character. + * + * @param {string} characterKey avatar filename + * @param {string} queueId + */ +export function setBinding(characterKey, queueId) { + if (!characterKey || !queueId) return; + bindings()[characterKey] = queueId; + persistSettings(); +} + +/** + * Remove a character's binding. Persists. Returns true if one existed. + * + * @param {string} characterKey avatar filename + * @returns {boolean} + */ +export function clearBinding(characterKey) { + if (!characterKey) return false; + const map = bindings(); + if (!(characterKey in map)) return false; + delete map[characterKey]; + persistSettings(); + return true; +} + +/** + * Every character bound to a given queue. + * + * Characters that no longer exist in ST still appear (with a name derived + * from the avatar filename) rather than vanishing silently — otherwise a + * stale binding would be invisible and unremovable from the UI. + * + * @param {string} queueId + * @returns {Array<{key: string, name: string, missing: boolean}>} + */ +export function charactersBoundTo(queueId) { + if (!queueId) return []; + const known = new Map(listCharacters().map(c => [c.key, c.name])); + return Object.entries(bindings()) + .filter(([, boundId]) => boundId === queueId) + .map(([key]) => ({ + key, + name: known.get(key) ?? prettifyKey(key), + missing: !known.has(key), + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * How many characters are bound to a queue (for the queue-card chip). + * + * @param {string} queueId + * @returns {number} + */ +export function bindingCountForQueue(queueId) { + if (!queueId) return 0; + return Object.values(bindings()).filter(id => id === queueId).length; +} + +/** + * Drop a deleted character's binding. Wired to CHARACTER_DELETED, which + * emits `{ id, character }`. + * + * @param {string} avatar + * @returns {boolean} true if a binding was removed + */ +export function purgeBindingsForCharacter(avatar) { + return clearBinding(avatar); +} + +/** + * Follow a character through a rename. Wired to CHARACTER_RENAMED, which + * emits `(oldAvatar, newAvatar)` — the avatar filename is derived from the + * character name, so renaming changes our storage key. + * + * @param {string} oldAvatar + * @param {string} newAvatar + * @returns {boolean} true if a binding was moved + */ +export function remapBinding(oldAvatar, newAvatar) { + if (!oldAvatar || !newAvatar || oldAvatar === newAvatar) return false; + const map = bindings(); + if (!(oldAvatar in map)) return false; + map[newAvatar] = map[oldAvatar]; + delete map[oldAvatar]; + persistSettings(); + return true; +} + +/** + * Human-readable fallback for an avatar filename ("Seraphina.png" -> + * "Seraphina"). Used when the character has no name, or no longer exists. + */ +function prettifyKey(key) { + return String(key ?? '').replace(/\.(png|webp|jpe?g|gif)$/i, ''); +} diff --git a/src/events.js b/src/events.js index 6a96659..ab770bf 100644 --- a/src/events.js +++ b/src/events.js @@ -3,8 +3,11 @@ * Event wiring + scheduler. Listens to: * - MESSAGE_RECEIVED decrement counter (on countable types only) * - GENERATION_STARTED switch profile if a switch is pending - * - CHAT_CHANGED reload state + refresh status indicator + * - CHAT_CHANGED reload state, refresh UI, auto-start bound queue + * - APP_READY catch boot-time auto-start if we registered late * - CONNECTION_PROFILE_LOADED detect manual override (ignore our own switches) + * - CHARACTER_DELETED purge that character's queue binding + * - CHARACTER_RENAMED follow the binding to the new avatar key */ import { eventSource, event_types } from '../../../../../script.js'; @@ -16,9 +19,17 @@ import { indexOfSlot, appendHistory, rollSlotResponses, + decideAutoActivation, } from './rotation.js'; import { switchProfile, isInternalSwitch, profileExists } from './profileSwitcher.js'; import { applyTuningOnSwitch } from './sampling.js'; +import { + getCurrentCharacter, + getBoundQueueId, + clearBinding, + purgeBindingsForCharacter, + remapBinding, +} from './characterBinding.js'; /** * Per-rotation transient state, kept in-memory only: @@ -74,6 +85,9 @@ export async function startRotation(queueId) { state.responsesAllotted = pick.responses; state.manuallyOverridden = false; state.lastSwitchMessageId = null; + // Whoever started this chat's rotation — the user or the character + // binding — has answered the auto-start question for this chat. + state.autoBindHandled = true; }); const switched = await switchProfile(slot.profileName); @@ -103,6 +117,10 @@ export function stopRotation() { state.currentSlotId = null; state.responsesRemaining = 0; state.manuallyOverridden = false; + // A stop is a deliberate choice about this chat. Without this the + // character binding would restart the rotation the next time the + // chat is opened, and the user could never turn it off. + state.autoBindHandled = true; }); consecutiveFailures = 0; failedSlotIndices.clear(); @@ -301,14 +319,120 @@ async function onGenerationStarted(type, _options, dryRun) { } } +/** Guards against two chat-load events racing into a double start. */ +let autoActivateInFlight = false; + +/** + * Start the queue bound to the current character, if the situation calls for + * it. Safe to call as often as you like — the precedence rules live in the + * pure decideAutoActivation() and the result is latched per-chat via + * `autoBindHandled`, so repeat calls are no-ops. + * + * @returns {Promise} + */ +export async function maybeAutoActivateForCharacter() { + if (autoActivateInFlight) return; + + const character = getCurrentCharacter(); + const boundQueueId = character ? getBoundQueueId(character.key) : null; + const queue = boundQueueId ? findQueue(boundQueueId) : null; + + const decision = decideAutoActivation({ + hasCharacter: !!character, + boundQueueId, + queueExists: !!queue, + chatState: getChatState(), + }); + + if (decision.action === 'none') return; + + if (decision.action === 'mark-handled') { + updateChatState(s => { s.autoBindHandled = true; }); + return; + } + + if (decision.action === 'clear-binding') { + console.warn(`[Roulette] "${character.name}" was bound to a queue that no longer exists — dropping the binding.`); + clearBinding(character.key); + notifyStateChanged(); + return; + } + + // decision.action === 'start' + autoActivateInFlight = true; + try { + const result = await startRotation(queue.id); + if (result.ok) { + console.log(`[Roulette] auto-started "${queue.name}" for ${character.name}`); + if (typeof toastr !== 'undefined') { + toastr.info(`Roulette: started "${queue.name}" for ${character.name}.`); + } + } else { + console.warn(`[Roulette] auto-start for ${character.name} failed: ${result.error}`); + if (typeof toastr !== 'undefined') { + toastr.warning(`Roulette: could not auto-start "${queue.name}" — ${result.error}`); + } + } + } finally { + autoActivateInFlight = false; + } +} + +/** + * Re-open the auto-start question for the current chat, then answer it. + * + * Called when the user changes a binding from the UI. Without this, binding + * a character while already sitting in their chat would appear to do nothing + * until the chat was reopened — the `autoBindHandled` latch would still be + * set from this chat's earlier load. + * + * Clearing the latch cannot disturb a rotation that is already running: + * decideAutoActivation() returns 'mark-handled' for that case and leaves the + * active queue untouched. + * + * @returns {Promise} + */ +export async function reevaluateAutoActivation() { + updateChatState(s => { s.autoBindHandled = false; }); + await maybeAutoActivateForCharacter(); +} + /** * CHAT_CHANGED handler: per-chat state lives in chat_metadata, which ST - * rebinds when the chat changes — just notify the UI to re-render. + * rebinds *before* emitting this event (script.js sets chat_metadata during + * chat load and emits afterwards), so it is safe to read the new chat's + * rotation state here — and therefore safe to decide whether the incoming + * character's bound queue should auto-start. */ async function onChatChanged(_chatId) { consecutiveFailures = 0; failedSlotIndices.clear(); notifyStateChanged(); + await maybeAutoActivateForCharacter(); +} + +/** + * CHARACTER_DELETED handler. ST emits `{ id, character }`; the character's + * avatar filename is our binding key. + */ +function onCharacterDeleted(payload) { + const avatar = payload?.character?.avatar; + if (!avatar) return; + if (purgeBindingsForCharacter(avatar)) { + console.log(`[Roulette] dropped queue binding for deleted character ${avatar}`); + notifyStateChanged(); + } +} + +/** + * CHARACTER_RENAMED handler. ST emits `(oldAvatar, newAvatar)` — the avatar + * filename tracks the character name, so a rename moves our storage key. + */ +function onCharacterRenamed(oldAvatar, newAvatar) { + if (remapBinding(oldAvatar, newAvatar)) { + console.log(`[Roulette] moved queue binding ${oldAvatar} -> ${newAvatar}`); + notifyStateChanged(); + } } /** @@ -344,6 +468,18 @@ export function registerEventListeners() { eventSource.on(event_types.GENERATION_STARTED, onGenerationStarted); eventSource.on(event_types.CHAT_CHANGED, onChatChanged); eventSource.on(event_types.CONNECTION_PROFILE_LOADED, onConnectionProfileLoaded); + eventSource.on(event_types.CHARACTER_DELETED, onCharacterDeleted); + eventSource.on(event_types.CHARACTER_RENAMED, onCharacterRenamed); + // Safety net for the boot-time case. ST inits extensions before loading + // the first chat, so CHAT_CHANGED normally covers startup on its own — + // but if this extension is ever loaded late, eventSource replays + // APP_READY to listeners that register after it fired (its + // autoFireAfterEmit set covers APP_READY), so we still catch up. + // Idempotent: the per-chat `autoBindHandled` latch absorbs the overlap. + eventSource.on(event_types.APP_READY, () => { + maybeAutoActivateForCharacter().catch(err => + console.error('[Roulette] APP_READY auto-activate failed:', err)); + }); } /** diff --git a/src/rotation.js b/src/rotation.js index ddb0ec6..9160c20 100644 --- a/src/rotation.js +++ b/src/rotation.js @@ -21,7 +21,8 @@ * } * ChatRouletteState = { * activeQueueId, currentSlotId, responsesRemaining, - * lastSwitchMessageId, history, manuallyOverridden + * lastSwitchMessageId, history, manuallyOverridden, + * autoBindHandled * } */ @@ -224,6 +225,10 @@ export function emptyState() { lastSwitchMessageId: null, history: [], manuallyOverridden: false, + // True once this chat has settled the question of whether its + // character's bound queue should auto-start. Prevents a deliberately + // stopped rotation from resurrecting every time the chat is reopened. + autoBindHandled: false, }; } @@ -330,3 +335,46 @@ export function validateQueue(queue, availableProfileNames = null) { } return errors; } + +/** + * Decide what a chat-load should do about a character's bound queue. + * + * Pure on purpose: the surrounding wiring (reading ST's character list, + * firing the switch) is untestable, but these precedence rules are exactly + * the part that is easy to get subtly wrong, so they live here where a test + * can pin them down. + * + * The rules, in order: + * - No character context (group chat, or no character loaded) -> nothing. + * - Already settled for this chat -> nothing. This is what stops a rotation + * the user deliberately stopped from restarting on every chat re-open. + * - Chat already has its own rotation running -> never clobber it; just mark + * the question settled so we stop reconsidering it. + * - Binding points at a queue that no longer exists -> drop the stale binding. + * - Otherwise -> start it. + * + * @param {object} input + * @param {boolean} input.hasCharacter a non-group character is loaded + * @param {string|null} input.boundQueueId queue bound to that character + * @param {boolean} input.queueExists the bound queue still resolves + * @param {{activeQueueId: string|null, autoBindHandled: boolean}} input.chatState + * @returns {{action: 'start'|'mark-handled'|'clear-binding'|'none', reason: string}} + */ +export function decideAutoActivation({ hasCharacter, boundQueueId, queueExists, chatState }) { + if (!hasCharacter) { + return { action: 'none', reason: 'no character context (group chat or nothing loaded)' }; + } + if (!boundQueueId) { + return { action: 'none', reason: 'character has no bound queue' }; + } + if (chatState?.autoBindHandled) { + return { action: 'none', reason: 'binding already settled for this chat' }; + } + if (chatState?.activeQueueId) { + return { action: 'mark-handled', reason: 'chat already has a rotation; leaving it alone' }; + } + if (!queueExists) { + return { action: 'clear-binding', reason: 'bound queue no longer exists' }; + } + return { action: 'start', reason: 'starting bound queue for this character' }; +} diff --git a/src/slashCommands.js b/src/slashCommands.js index b3acd3f..75bfaeb 100644 --- a/src/slashCommands.js +++ b/src/slashCommands.js @@ -4,6 +4,8 @@ * /roulette-stop deactivate rotation * /roulette-status print current rotation state as a system msg * /roulette-skip force-advance to the next slot + * /roulette-bind auto-start a queue for the current character + * /roulette-unbind remove the current character's binding */ import { SlashCommandParser } from '../../../../../scripts/slash-commands/SlashCommandParser.js'; @@ -13,7 +15,14 @@ import { ARGUMENT_TYPE, } from '../../../../../scripts/slash-commands/SlashCommandArgument.js'; import { getSettings, findQueue, getChatState } from './state.js'; -import { startRotation, stopRotation, skipCurrentSlot } from './events.js'; +import { startRotation, stopRotation, skipCurrentSlot, reevaluateAutoActivation } from './events.js'; +import { + getCurrentCharacter, + getBoundQueueId, + setBinding, + clearBinding, + isGroupChat, +} from './characterBinding.js'; function findQueueByName(name) { if (!name) return null; @@ -107,4 +116,59 @@ export function registerSlashCommands() { return slot?.profileName ?? ''; }, })); + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'roulette-bind', + helpString: 'Bind the current character to a queue, so opening their chats ' + + 'starts that queue automatically. Not available in group chats.', + returns: 'name of the bound queue, or empty on failure', + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ + description: 'Queue name (as defined in Roulette settings)', + isRequired: true, + typeList: [ARGUMENT_TYPE.STRING], + enumProvider: queueNameProvider, + }), + ], + callback: async (_args, value) => { + const character = getCurrentCharacter(); + if (!character) { + const why = isGroupChat() + ? 'bindings do not apply to group chats' + : 'no character is loaded'; + if (typeof toastr !== 'undefined') toastr.warning(`Roulette: ${why}.`); + return ''; + } + const name = String(value ?? '').trim(); + const queue = findQueueByName(name); + if (!queue) { + if (typeof toastr !== 'undefined') toastr.error(`Roulette: queue "${name}" not found.`); + return ''; + } + setBinding(character.key, queue.id); + // Take effect in the chat we're already in, not just future loads. + await reevaluateAutoActivation(); + return queue.name; + }, + })); + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'roulette-unbind', + helpString: 'Remove the current character\'s queue binding. Any rotation ' + + 'already running in this chat keeps going.', + returns: 'name of the queue that was unbound, or empty if there was none', + callback: async () => { + const character = getCurrentCharacter(); + if (!character) { + const why = isGroupChat() + ? 'bindings do not apply to group chats' + : 'no character is loaded'; + if (typeof toastr !== 'undefined') toastr.warning(`Roulette: ${why}.`); + return ''; + } + const previous = findQueue(getBoundQueueId(character.key)); + if (!clearBinding(character.key)) return ''; + return previous?.name ?? ''; + }, + })); } diff --git a/src/state.js b/src/state.js index 60e5cfc..24318df 100644 --- a/src/state.js +++ b/src/state.js @@ -23,6 +23,12 @@ function defaultSettings() { version: 1, queues: [], // Queue[] defaultQueueId: null, // optional: queue used as initial pick when starting a new chat + // Per-character queue bindings, keyed by the character's avatar + // filename (ST's canonical stable character id — `this_chid` is a + // volatile array index and must never be persisted). + // { [avatarFilename: string]: queueId } + // Group chats are deliberately excluded; see src/characterBinding.js. + characterQueues: {}, ui: { animScale: 1, // 0.25 - 2 (multiplier for every animation duration) accentColor: null, // null = default brass; otherwise a CSS color string @@ -49,6 +55,10 @@ export function getSettings() { if (!Array.isArray(s.queues)) s.queues = []; if (typeof s.version !== 'number') s.version = 1; if (s.defaultQueueId === undefined) s.defaultQueueId = null; + // Backfill the character-binding map — installs predating v1.3 lack it. + if (!s.characterQueues || typeof s.characterQueues !== 'object' || Array.isArray(s.characterQueues)) { + s.characterQueues = {}; + } if (!s.ui || typeof s.ui !== 'object') s.ui = { animScale: 1, accentColor: null }; if (typeof s.ui.animScale !== 'number') s.ui.animScale = 1; if (s.ui.accentColor === undefined) s.ui.accentColor = null; @@ -103,6 +113,13 @@ export function deleteQueue(queueId) { if (settings.defaultQueueId === queueId) { settings.defaultQueueId = null; } + // Drop any character bindings that pointed at this queue, otherwise the + // map accumulates dangling ids that resolve to nothing on chat load. + // Done inline rather than via characterBinding.js: that module imports + // this one, and routing the prune back through it would form a cycle. + for (const [avatar, boundId] of Object.entries(settings.characterQueues ?? {})) { + if (boundId === queueId) delete settings.characterQueues[avatar]; + } persistSettings(); return true; } diff --git a/src/ui/bindingPicker.js b/src/ui/bindingPicker.js new file mode 100644 index 0000000..e337288 --- /dev/null +++ b/src/ui/bindingPicker.js @@ -0,0 +1,155 @@ +/* SillyTavern-Roulette — AGPL-3.0 + * + * "Which characters auto-start this queue?" picker, opened from a queue card + * in the Queues tab. + * + * The relationship is one-queue-per-character but many-characters-per-queue, + * so checking a character who is already bound elsewhere REBINDS them — the + * row calls that out inline rather than letting the user discover it by + * surprise afterwards. + * + * Rows are built as DOM nodes with textContent rather than interpolated + * HTML: character names are user-supplied and land in this list verbatim. + */ + +import { Popup, POPUP_TYPE, POPUP_RESULT } from '../../../../../../scripts/popup.js'; +import { getSettings } from '../state.js'; +import { + listCharacters, + charactersBoundTo, + getBoundQueueId, + setBinding, + clearBinding, +} from '../characterBinding.js'; + +/** + * Open the character picker for a queue. Resolves once the user has saved or + * cancelled; bindings are written only on save. + * + * @param {object} queue + * @returns {Promise} true if bindings were changed + */ +export async function openBindingPicker(queue) { + const queueNameById = new Map(getSettings().queues.map(q => [q.id, q.name])); + + // Everything ST currently knows about, plus any binding on this queue + // whose character has since vanished — those would otherwise be + // invisible and impossible to clear from the UI. + const known = listCharacters(); + const knownKeys = new Set(known.map(c => c.key)); + const orphans = charactersBoundTo(queue.id).filter(c => !knownKeys.has(c.key)); + const rows = [...known, ...orphans.map(o => ({ ...o, missing: true }))]; + + // key -> checked. Seeded from the queue's current bindings. + const selection = new Map( + rows.map(c => [c.key, getBoundQueueId(c.key) === queue.id]), + ); + + const root = document.createElement('div'); + root.className = 'roulette-extension roulette-binding-picker'; + + const header = document.createElement('div'); + header.className = 'roulette-section-title'; + header.textContent = 'Auto-start this queue for…'; + root.appendChild(header); + + const blurb = document.createElement('p'); + blurb.className = 'roulette-text-muted roulette-binding-blurb'; + blurb.textContent = `Opening a chat with a checked character starts "${queue.name}" automatically, ` + + 'unless that chat already has a rotation running or you stopped one there.'; + root.appendChild(blurb); + + const search = document.createElement('input'); + search.type = 'search'; + search.className = 'roulette-binding-search text_pole'; + search.placeholder = 'Filter characters…'; + root.appendChild(search); + + const list = document.createElement('div'); + list.className = 'roulette-binding-list'; + root.appendChild(list); + + const empty = document.createElement('p'); + empty.className = 'roulette-text-muted'; + empty.textContent = 'No characters match.'; + empty.hidden = true; + root.appendChild(empty); + + function renderList() { + const needle = search.value.trim().toLowerCase(); + list.innerHTML = ''; + let shown = 0; + for (const character of rows) { + if (needle && !character.name.toLowerCase().includes(needle)) continue; + shown++; + list.appendChild(buildRow(character)); + } + empty.hidden = shown > 0; + } + + function buildRow(character) { + const row = document.createElement('label'); + row.className = 'roulette-binding-row'; + + const box = document.createElement('input'); + box.type = 'checkbox'; + box.checked = selection.get(character.key) === true; + box.addEventListener('change', () => selection.set(character.key, box.checked)); + row.appendChild(box); + + const name = document.createElement('span'); + name.className = 'roulette-binding-name'; + name.textContent = character.name; + row.appendChild(name); + + if (character.missing) { + row.appendChild(note('roulette-binding-note-missing', 'character no longer exists')); + } else { + const boundTo = getBoundQueueId(character.key); + if (boundTo && boundTo !== queue.id) { + const otherName = queueNameById.get(boundTo) ?? 'another queue'; + row.appendChild(note('roulette-binding-note-conflict', `currently ${otherName}`)); + } + } + return row; + } + + function note(className, text) { + const el = document.createElement('span'); + el.className = `roulette-binding-note ${className}`; + el.textContent = text; + return el; + } + + search.addEventListener('input', renderList); + renderList(); + + if (rows.length === 0) { + list.remove(); + search.remove(); + empty.hidden = false; + empty.textContent = 'No characters found. Add a character card in SillyTavern first.'; + } + + const popup = new Popup(root, POPUP_TYPE.CONFIRM, '', { + okButton: 'Save bindings', + cancelButton: 'Cancel', + allowVerticalScrolling: true, + }); + + const result = await popup.show(); + if (result !== POPUP_RESULT.AFFIRMATIVE) return false; + + let changed = false; + for (const [key, checked] of selection) { + const current = getBoundQueueId(key); + if (checked && current !== queue.id) { + setBinding(key, queue.id); + changed = true; + } else if (!checked && current === queue.id) { + clearBinding(key); + changed = true; + } + } + return changed; +} diff --git a/src/ui/tabs/chamber.js b/src/ui/tabs/chamber.js index 5051b7f..5009098 100644 --- a/src/ui/tabs/chamber.js +++ b/src/ui/tabs/chamber.js @@ -14,7 +14,8 @@ */ import { findQueue, getChatState, getSettings } from '../../state.js'; -import { startRotation, stopRotation, resumeRotation, skipCurrentSlot } from '../../events.js'; +import { startRotation, stopRotation, resumeRotation, skipCurrentSlot, reevaluateAutoActivation } from '../../events.js'; +import { getCurrentCharacter, getBoundQueueId, setBinding, clearBinding } from '../../characterBinding.js'; import { renderCylinder, spinCylinderTo, rotationForActiveIndex } from '../cylinder.js'; // Per-mounted-tab state. @@ -45,6 +46,13 @@ export function mountChamberTab(container) { + `; @@ -74,6 +82,21 @@ export function mountChamberTab(container) { resumeRotation(); }); + container.querySelector('[data-field="bind-select"]').addEventListener('change', async (e) => { + const character = getCurrentCharacter(); + if (!character) return; + const queueId = e.target.value; + if (queueId) { + setBinding(character.key, queueId); + } else { + clearBinding(character.key); + } + // Apply the binding to the chat we're sitting in, not just to future + // loads — otherwise setting a binding here looks like it did nothing. + await reevaluateAutoActivation(); + refreshChamberTab(container); + }); + refreshChamberTab(container); } @@ -162,6 +185,56 @@ export function refreshChamberTab(container) { } refreshMeta(container, queue, previewQueue, state); + refreshBindRow(container); +} + +/** + * Character-binding control. Hidden entirely when bindings don't apply — + * group chats and "no character loaded" both yield null from + * getCurrentCharacter(). + * + * The