diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index d3cb74c..4956444 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -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'] diff --git a/CLAUDE.md b/CLAUDE.md index d051e0c..398d8bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `''`). 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: @@ -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 }; ``` @@ -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 @@ -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) @@ -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 @@ -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 `` 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. @@ -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. diff --git a/README.md b/README.md index 3fe6b0f..c87ddea 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![Roulette Banner](assets/banner.png) +![Roulette Banner](assets/banner.jpg) # SillyTavern-Roulette @@ -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//extensions/SillyTavern-Roulette/` (per-user) or `public/scripts/extensions/third-party/SillyTavern-Roulette/` (global), then enable in the Extensions panel. diff --git a/TESTING.md b/TESTING.md index 554debc..4985a88 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,7 +2,7 @@ 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 @@ -10,7 +10,7 @@ These walkthrough steps verify each acceptance criterion in a live SillyTavern. 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". diff --git a/assets/.gitkeep b/assets/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/assets/banner.jpg b/assets/banner.jpg new file mode 100644 index 0000000..77aa45f Binary files /dev/null and b/assets/banner.jpg differ diff --git a/assets/banner.png b/assets/banner.png deleted file mode 100644 index 08b8133..0000000 Binary files a/assets/banner.png and /dev/null differ diff --git a/index.js b/index.js index 70844cd..b5e6683 100644 --- a/index.js +++ b/index.js @@ -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 diff --git a/package.json b/package.json index 2874292..739a42d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/events.js b/src/events.js index 5e57cd9..c5c23d4 100644 --- a/src/events.js +++ b/src/events.js @@ -18,10 +18,9 @@ import { advanceSlot, indexOfSlot, appendHistory, - rollSlotResponses, decideAutoActivation, } from './rotation.js'; -import { switchProfile, isInternalSwitch, profileExists } from './profileSwitcher.js'; +import { switchProfile, isInternalSwitch, profileExists, currentProfileName } from './profileSwitcher.js'; import { applyTuningOnSwitch } from './sampling.js'; import { getCurrentCharacter, @@ -41,6 +40,19 @@ const MAX_CONSECUTIVE_FAILURES = 3; let switchInFlight = null; // promise guard — only one switch at a time +/** + * Generation type of the most recent (non-dry) GENERATION_STARTED. + * + * Needed because ST's MESSAGE_RECEIVED type can lie: a non-streaming + * regenerate deletes the AI message being redone *before* generating, so by + * the time saveReply emits MESSAGE_RECEIVED the last message is the user's + * and the type is coerced to 'normal'. The GENERATION_STARTED that produced + * that message still carried 'regenerate', so we remember it and consult it + * when counting. (Assumes generations don't interleave — ST serializes the + * main generation pipeline.) + */ +let lastGenerationType; + /** Subscribers notified on any rotation-state change (used by the UI). */ const stateChangeListeners = new Set(); export function onRotationStateChanged(listener) { @@ -141,10 +153,24 @@ export function stopRotation() { /** * Resume rotation after a manual override. + * + * Spec (CLAUDE.md §5): if the profile the user is sitting on matches the + * current slot, just continue. Otherwise the responses generated while + * paused belong to a foreign profile — zero the counter so the next + * generation starts a fresh rotation cycle (advance/re-pick + switch) + * instead of counting them against the slot. */ export function resumeRotation() { - updateChatState(state => { - state.manuallyOverridden = false; + const state = getChatState(); + const queue = state.activeQueueId ? findQueue(state.activeQueueId) : null; + const slot = queue?.slots.find(s => s.id === state.currentSlotId); + const loadedProfile = currentProfileName(); + const diverged = !!(slot && loadedProfile && slot.profileName !== loadedProfile); + updateChatState(s => { + s.manuallyOverridden = false; + if (diverged) { + s.responsesRemaining = 0; + } }); consecutiveFailures = 0; failedSlotIndices.clear(); @@ -192,6 +218,8 @@ export async function skipCurrentSlot() { return { ok: false, error: `Skip failed and recovery exhausted.` }; } } else { + consecutiveFailures = 0; + failedSlotIndices.clear(); await applyTuningOnSwitch(slot, queue); } notifyStateChanged(); @@ -249,6 +277,11 @@ async function tryAdvanceFromFailure(queue, lastFailedIndex) { */ async function onMessageReceived(messageId, type) { if (!isCountableGeneration(type)) return; + // The emitted type can be coerced to 'normal' (see lastGenerationType); + // trust the type the generation actually started with. Messages that + // never fire GENERATION_STARTED ('command', 'extension', 'first_message') + // are already rejected by their own type above. + if (!isCountableGeneration(lastGenerationType)) return; const state = getChatState(); if (!state.activeQueueId || state.manuallyOverridden) return; const queue = findQueue(state.activeQueueId); @@ -281,6 +314,7 @@ async function onMessageReceived(messageId, type) { */ async function onGenerationStarted(type, _options, dryRun) { if (dryRun) return; + lastGenerationType = type; if (!isCountableGeneration(type)) return; const state = getChatState(); if (!state.activeQueueId || state.manuallyOverridden) return; @@ -318,8 +352,16 @@ async function onGenerationStarted(type, _options, dryRun) { if (!ok) { failedSlotIndices.add(next.slotIndex); consecutiveFailures++; - await tryAdvanceFromFailure(queue, next.slotIndex); + const recovered = await tryAdvanceFromFailure(queue, next.slotIndex); + if (!recovered) { + // Recovery exhausted (3 consecutive failures, or no eligible + // slot left): actually halt, per spec — otherwise the bar + // keeps showing a "running" rotation whose switches all fail. + stopRotation(); + } } else { + consecutiveFailures = 0; + failedSlotIndices.clear(); await applyTuningOnSwitch(slot, queue); } notifyStateChanged(); @@ -419,6 +461,7 @@ export async function reevaluateAutoActivation() { async function onChatChanged(_chatId) { consecutiveFailures = 0; failedSlotIndices.clear(); + lastGenerationType = undefined; notifyStateChanged(); await maybeAutoActivateForCharacter(); } @@ -493,22 +536,3 @@ export function registerEventListeners() { console.error('[Roulette] APP_READY auto-activate failed:', err)); }); } - -/** - * Re-roll the responses-remaining counter from the current slot's config. - * Used when activating a queue if state is corrupt or after manual overrides. - */ -export function rerollCurrentSlotCounter() { - const state = getChatState(); - if (!state.activeQueueId) return; - const queue = findQueue(state.activeQueueId); - if (!queue) return; - const slot = queue.slots.find(s => s.id === state.currentSlotId); - if (!slot) return; - const responses = rollSlotResponses(slot, queue); - updateChatState(s => { - s.responsesRemaining = responses; - s.responsesAllotted = responses; - }); - notifyStateChanged(); -} diff --git a/src/exportImport.js b/src/exportImport.js index d5da846..2c85814 100644 --- a/src/exportImport.js +++ b/src/exportImport.js @@ -82,7 +82,22 @@ function sanitiseImported(q) { id: uuid(), name: typeof q.name === 'string' && q.name.trim() ? q.name : 'Imported Queue', slots: Array.isArray(q.slots) - ? q.slots.map(s => ({ ...s, id: s.id || uuid() })) + // Slot ids are ALWAYS reissued: managed sampler presets are named + // by (queue name, profile, slot-id prefix), so re-importing an + // export of a queue that still exists locally would otherwise + // share preset names with it — edits in one queue would stomp the + // other's preset and deleting either would delete the shared one. + // Nothing can reference the old ids yet (the queue id is fresh + // too), so this is loss-free. tuning.presetName is a pointer to a + // preset on the EXPORTING install; drop it so cleanup here can + // never target a same-named local preset it doesn't own. + ? q.slots.map(s => ({ + ...s, + id: uuid(), + tuning: s.tuning && typeof s.tuning === 'object' + ? { ...s.tuning, presetName: null } + : s.tuning, + })) : [], }; } diff --git a/src/profileSwitcher.js b/src/profileSwitcher.js index 809cfa0..baf83e2 100644 --- a/src/profileSwitcher.js +++ b/src/profileSwitcher.js @@ -39,6 +39,21 @@ export function profileExists(name) { return listProfileNames().includes(name); } +/** + * Name of the currently-selected connection profile, or null if none is + * selected or it can't be resolved. ST tracks the selection as a profile + * *id* (extension_settings.connectionManager.selectedProfile), so this maps + * it back to a name for comparison against slot.profileName. + * + * @returns {string|null} + */ +export function currentProfileName() { + const cm = extension_settings?.connectionManager; + if (!cm?.selectedProfile || !Array.isArray(cm.profiles)) return null; + const profile = cm.profiles.find(p => p?.id === cm.selectedProfile); + return typeof profile?.name === 'string' && profile.name.length > 0 ? profile.name : null; +} + /** * Switch to the named connection profile. Returns true on success. * diff --git a/src/rotation.js b/src/rotation.js index 9160c20..1c8a68e 100644 --- a/src/rotation.js +++ b/src/rotation.js @@ -20,32 +20,30 @@ * weightedRunCount: { mode, fixed, min, max } * } * ChatRouletteState = { - * activeQueueId, currentSlotId, responsesRemaining, + * activeQueueId, currentSlotId, responsesRemaining, responsesAllotted, * lastSwitchMessageId, history, manuallyOverridden, * autoBindHandled * } */ -export const GENERATION_TYPES_TO_IGNORE = new Set([ - 'swipe', - 'regenerate', - 'continue', - 'append', - 'appendFinal', - 'impersonate', - 'quiet', - 'first_message', -]); - /** * True iff a generation/message of this type should advance the rotation * counter (and trigger a profile switch on GENERATION_STARTED). * + * Strict whitelist: only a normal user-triggered generation counts. + * SillyTavern passes no type for those (some paths label them 'normal'). + * Everything else — 'swipe', 'regenerate', 'continue', 'append', + * 'appendFinal', 'impersonate', 'quiet', 'first_message', plus types that + * never reach GENERATION_STARTED at all like 'command' (/sendas) and + * 'extension' (extension-inserted messages), and any type SillyTavern adds + * in the future — must not consume rotation slots, so unknown types fail + * closed. + * * @param {string|undefined} type generation type from MESSAGE_RECEIVED / GENERATION_STARTED * @returns {boolean} */ export function isCountableGeneration(type) { - return type === undefined || type === null || type === 'normal' || !GENERATION_TYPES_TO_IGNORE.has(type); + return type === undefined || type === null || type === 'normal'; } /** @@ -278,6 +276,16 @@ export function validateQueue(queue, availableProfileNames = null) { } else if (availableProfileNames && !availableProfileNames.includes(slot.profileName)) { errors.push(`Slot ${i + 1}: profile "${slot.profileName}" does not exist.`); } + // Every count field that is present must be a finite number, + // whichever countMode/queue mode is active. The editor renders + // the inactive fields too, and imported JSON can carry anything — + // a non-numeric value here would otherwise flow into the editor's + // DOM untouched. + for (const field of ['fixedCount', 'minCount', 'maxCount', 'weight']) { + if (slot[field] != null && !Number.isFinite(slot[field])) { + errors.push(`Slot ${i + 1}: ${field} must be a number.`); + } + } if (queue.mode === 'sequential') { if (slot.countMode === 'fixed') { if (!Number.isFinite(slot.fixedCount) || slot.fixedCount < 1) { diff --git a/src/sampling.js b/src/sampling.js index 987c931..1ae2e17 100644 --- a/src/sampling.js +++ b/src/sampling.js @@ -31,7 +31,6 @@ import { nai_settings } from '../../../../../scripts/nai-settings.js'; import { textgenerationwebui_settings } from '../../../../../scripts/textgen-settings.js'; import { getPresetManager } from '../../../../../scripts/preset-manager.js'; import { executeSlashCommandsWithOptions, CONNECT_API_MAP } from '../../../../../scripts/slash-commands.js'; -import { isInternalSwitch } from './profileSwitcher.js'; /** * Declarative mapping: conceptual sampler param → API-specific key on the @@ -150,41 +149,6 @@ export function apiSupportsParam(apiId, paramId) { return !!(fields && fields[paramId]); } -/** - * Build a fresh empty tuning block. Caller fills in params + flags. - */ -export function emptyTuning() { - return { - enabled: false, - presetName: null, - params: {}, - }; -} - -/** - * Validate that a tuning block is structurally sound. Returns array of - * error strings (empty if valid). Used by validateQueue. - */ -export function validateTuning(tuning) { - if (tuning == null) return []; - if (typeof tuning !== 'object') return ['tuning must be an object or absent']; - const errs = []; - if (typeof tuning.enabled !== 'boolean') errs.push('tuning.enabled must be a boolean'); - if (tuning.presetName != null && typeof tuning.presetName !== 'string') { - errs.push('tuning.presetName must be a string or null'); - } - if (tuning.params == null || typeof tuning.params !== 'object') { - errs.push('tuning.params must be an object'); - } else { - for (const id of TUNING_PARAM_IDS) { - if (tuning.params[id] != null && !Number.isFinite(tuning.params[id])) { - errs.push(`tuning.params.${id} must be a finite number`); - } - } - } - return errs; -} - /** * Stable-ish managed-preset name. Includes the queue name + profile name * for legibility, and a 6-char slice of the slot's UUID so reorders / @@ -252,11 +216,13 @@ export async function ensureManagedPreset(slot, queueName) { if (!snapshot) return null; const tunedSettings = overlayParams(snapshot, slot.tuning.params, apiId); - // savePreset() handles both create and update by name. skipUpdate - // keeps it from triggering a UI refresh (we don't want to flash the - // preset selector during rotation). + // savePreset() handles both create and update by name. Do NOT pass + // skipUpdate: the preset must be registered in ST's in-memory list — + // /preset exact-matches against that list (and falls back to Fuse fuzzy + // matching on a miss, which could activate an unrelated preset), and + // cleanupManagedPresetForSlot's existence check reads the same list. try { - await presetMgr.savePreset(desiredName, tunedSettings, { skipUpdate: true }); + await presetMgr.savePreset(desiredName, tunedSettings); } catch (err) { console.error('[Roulette] managed preset save failed:', err); return null; @@ -269,9 +235,9 @@ export async function ensureManagedPreset(slot, queueName) { /** * Public entry point: called from events.js right after a successful * switchProfile. If the slot has tuning enabled, this ensures the managed - * preset exists and applies it via /preset. The internal-switch flag is - * set during the /preset call so our CONNECTION_PROFILE_LOADED listener - * doesn't trip a manual-override. + * preset exists and applies it via /preset. No internal-switch flag is + * needed here: /preset never emits CONNECTION_PROFILE_LOADED (only the + * connection manager does), so this cannot trip the manual-override path. * * @param {object} slot * @param {object} queue @@ -307,16 +273,22 @@ export async function cleanupManagedPresetForSlot(slot) { const apiId = resolveApiIdForProfile(slot.profileName); if (!apiId) { // Profile no longer resolvable — best effort: try every API's - // preset manager and let one pick it up. + // preset manager and let one pick it up. The existence check + // matters: deletePreset on a keyed API splices by indexOf, so + // calling it with an unknown name would eat an unrelated preset. + let deleted = false; for (const id of Object.keys(API_PARAM_MAP)) { const mgr = getPresetManager(id); if (mgr && mgr.getAllPresets().includes(presetName)) { - try { await mgr.deletePreset(presetName); } catch (_) { /* ignore */ } + try { + await mgr.deletePreset(presetName); + deleted = true; + } catch (_) { /* ignore */ } break; } } slot.tuning.presetName = null; - return true; + return deleted; } const mgr = getPresetManager(apiId); if (!mgr) { @@ -348,21 +320,3 @@ export async function cleanupManagedPresetsForQueue(queue) { } } -/** - * For the UI: read the current live value of a param from the API's - * settings object. Used as the slider initial value when the user opens - * tuning for the first time. - */ -export function readLiveParam(apiId, paramId) { - const fields = fieldsForApi(apiId); - const spec = fields?.[paramId]; - const live = API_PARAM_MAP[apiId]?.settingsObj?.(); - if (!spec || !live) return null; - const v = live[spec.key]; - return Number.isFinite(v) ? Number(v) : null; -} - -// Quiet warning suppress: isInternalSwitch is currently imported for future -// use (tuning may need to flag its own preset switch as internal). Keeping -// the import to avoid churn when that lands. -void isInternalSwitch; diff --git a/src/slashCommands.js b/src/slashCommands.js index 75bfaeb..dffc26a 100644 --- a/src/slashCommands.js +++ b/src/slashCommands.js @@ -14,6 +14,7 @@ import { SlashCommandArgument, ARGUMENT_TYPE, } from '../../../../../scripts/slash-commands/SlashCommandArgument.js'; +import { sendSystemMessage, system_message_types } from '../../../../../scripts/system-messages.js'; import { getSettings, findQueue, getChatState } from './state.js'; import { startRotation, stopRotation, skipCurrentSlot, reevaluateAutoActivation } from './events.js'; import { @@ -89,14 +90,27 @@ export function registerSlashCommands() { returns: 'human-readable status line', callback: async () => { const state = getChatState(); - if (!state.activeQueueId) return 'Roulette: off'; - const queue = findQueue(state.activeQueueId); - if (!queue) return 'Roulette: active queue missing'; - const slot = queue.slots.find(s => s.id === state.currentSlotId); - const profile = slot?.profileName ?? '?'; - const remaining = state.responsesRemaining; - const flag = state.manuallyOverridden ? ' (paused: manual override)' : ''; - return `Roulette: ${queue.name} · ${profile} · ${remaining} left${flag}`; + let status; + if (!state.activeQueueId) { + status = 'Roulette: off'; + } else { + const queue = findQueue(state.activeQueueId); + if (!queue) { + status = 'Roulette: active queue missing'; + } else { + const slot = queue.slots.find(s => s.id === state.currentSlotId); + const profile = slot?.profileName ?? '?'; + const remaining = state.responsesRemaining; + const flag = state.manuallyOverridden ? ' (paused: manual override)' : ''; + status = `Roulette: ${queue.name} · ${profile} · ${remaining} left${flag}`; + } + } + // Typed bare in the chat input, a command's return value is never + // displayed — ST discards the pipe. Emit a system message so the + // user actually sees something; the return stays for piping + // (e.g. `/roulette-status | /echo {{pipe}}`). + sendSystemMessage(system_message_types.GENERIC, status); + return status; }, })); diff --git a/src/state.js b/src/state.js index bee1ba7..ac220f2 100644 --- a/src/state.js +++ b/src/state.js @@ -231,7 +231,11 @@ export function applyUiSettings() { style.id = 'roulette-ui-overrides'; document.head.appendChild(style); } - const lines = ['.roulette-extension {']; + // Same selector pair as the token block in style.css: the popup-chrome + // override rules read these variables from the .popup element itself, + // which is an ANCESTOR of .roulette-extension — custom properties only + // inherit downward, so the popup needs its own definition. + const lines = ['.roulette-extension, .popup:has(.roulette-extension) {']; if (Number.isFinite(ui.animScale) && ui.animScale > 0) { lines.push(` --roulette-anim-scale: ${ui.animScale};`); } @@ -266,7 +270,9 @@ function parseColor(input) { b: parseInt(hex.slice(4, 6), 16), }; } - const rgbMatch = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); + // Anchored to the full string on purpose: the value is interpolated into + // a