diff --git a/Assets/assistent.js b/Assets/assistent.js
index 0e63c82..4a33d0b 100644
--- a/Assets/assistent.js
+++ b/Assets/assistent.js
@@ -720,8 +720,10 @@
if (/\b(horny|controls?|preference[_\s-]?bias)\b/i.test(t)) {
return true;
}
- return cyrTokenRe('хорни|остынь|вкус|слайдер').test(t)
- || /\/\s*(остынь|ostyn|horny-game)/i.test(t);
+ // Do not match bare «вкус» — too common in RU chat and was disabling the echo filter.
+ return cyrTokenRe('хорни|остынь|слайдер').test(t)
+ || /\/\s*(остынь|ostyn|horny-game)/i.test(t)
+ || /слайдер\s*вкус|вкус\s*(на|в)\s*\d|поставь\s*вкус|крутани\s*вкус/i.test(t);
}
function patchLooksLikeGeneration(patch) {
@@ -736,14 +738,17 @@
return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate');
}
- /** Drop Generate-patch echo of schema defaults that would wipe a user-tuned slider. */
+ /** Drop Generate-patch control noise; drop default-echo that would wipe a user-tuned slider. */
function filterControlPatch(incoming, patch) {
const schema = state.config?.controls || {};
const out = {};
if (!incoming || typeof incoming !== 'object') {
return out;
}
- const likeGen = patchLooksLikeGeneration(patch) && !state.lastUserControlIntent;
+ // Image patches must not move Хорни / Вкус unless the user asked this turn.
+ if (patchLooksLikeGeneration(patch) && !state.lastUserControlIntent) {
+ return out;
+ }
for (const [id, raw] of Object.entries(incoming)) {
if (!schema[id]) {
continue;
@@ -757,7 +762,10 @@
if (Math.abs(n - cur) < 0.0005) {
continue;
}
- if (likeGen && Number.isFinite(def) && Math.abs(n - def) < 0.0005 && Math.abs(cur - def) > 0.0005) {
+ if (!state.lastUserControlIntent
+ && Number.isFinite(def)
+ && Math.abs(n - def) < 0.0005
+ && Math.abs(cur - def) > 0.0005) {
continue;
}
out[id] = n;
@@ -810,6 +818,57 @@
).test(t);
}
+ /** «запомни как базовый промпт» — apply/save only, never Generate / auto look_at. */
+ function userAsksNoGenerate(text) {
+ const t = String(text || '').trim();
+ if (!t || userAsksGenerate(t)) {
+ return false;
+ }
+ if (/\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b/i.test(t)) {
+ return true;
+ }
+ return cyrTokenRe(
+ 'запомн|запомни|запомним|сохрани|сохраним|шаблон|'
+ + 'базов(ый|ого|ому|ым|ая|ую|ое)?\\s+промпт|'
+ + 'не\\s+генерир|без\\s+генерац|не\\s+надо\\s+генер|только\\s+запомн|пока\\s+запомн|'
+ + 'не\\s+рисуй|не\\s+запускай\\s+генер',
+ ).test(t);
+ }
+
+ function stripGenerateAction(patch) {
+ if (!patch || typeof patch !== 'object') {
+ return patch;
+ }
+ if (!Array.isArray(patch.actions)) {
+ return patch;
+ }
+ const next = patch.actions.map(String).filter((a) => a !== 'generate');
+ if (next.length === patch.actions.length) {
+ return patch;
+ }
+ const out = { ...patch };
+ if (next.length) {
+ out.actions = next;
+ } else {
+ delete out.actions;
+ }
+ return out;
+ }
+
+ function stripLookAt(patch) {
+ if (!patch || typeof patch !== 'object') {
+ return patch;
+ }
+ if (patch.look_at == null && patch.vision_from == null && patch.vision_slots == null) {
+ return patch;
+ }
+ const out = { ...patch };
+ delete out.look_at;
+ delete out.vision_from;
+ delete out.vision_slots;
+ return out;
+ }
+
function rememberLastPatch(patch) {
if (patch && typeof patch === 'object' && !isCardObject(patch)) {
state.lastPatch = patch;
@@ -1305,10 +1364,15 @@
return null;
}
- function isGenerateUnavailable() {
- if (state.generating || state.busy) {
- return true;
- }
+ function isSwarmGenerateRunning() {
+ try {
+ if (typeof num_live_gens === 'number' && num_live_gens > 0) {
+ return true;
+ }
+ if (typeof num_waiting_gens === 'number' && num_waiting_gens > 0) {
+ return true;
+ }
+ } catch (e) { /* ignore */ }
try {
if (typeof mainGenHandler !== 'undefined' && mainGenHandler) {
if (mainGenHandler.isGenerating === true || mainGenHandler.running === true) {
@@ -1328,10 +1392,20 @@
return false;
}
+ function isGenerateUnavailable() {
+ if (state.generating || state.busy) {
+ return true;
+ }
+ return isSwarmGenerateRunning();
+ }
+
function syncGenerateBusy() {
const overlay = document.querySelector('.sa-slot-gen .sa-slot-busy');
if (overlay) {
- overlay.hidden = !state.generating && state.busyPhase !== 'generating';
+ // If Swarm already finished but our waiter is stuck on same URL, drop the overlay
+ // as soon as the live frame is on the board.
+ const stuck = state.generating && !isSwarmGenerateRunning();
+ overlay.hidden = (!state.generating && state.busyPhase !== 'generating') || stuck;
}
}
@@ -1658,6 +1732,15 @@
empty.hidden = true;
}
frame?.classList.add('sa-has-image');
+ } else if (src && slot.src === src) {
+ // Same URL, possibly new bytes after overwrite — nudge
once Swarm is idle.
+ if (state.generating && !isSwarmGenerateRunning()) {
+ const img = document.querySelector('.sa-slot-gen img');
+ if (img) {
+ const bump = src.includes('?') ? `${src}&sa_t=${Date.now()}` : `${src}?sa_t=${Date.now()}`;
+ img.src = bump;
+ }
+ }
} else if (!src && !slot.src) {
const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
const frame = document.querySelector('.sa-slot-gen');
@@ -3911,21 +3994,46 @@
function waitForNewImage(prevSrc, timeoutMs = 180000) {
cancelWaitForNewImage();
const epoch = state.chatEpoch;
+ const prev = String(prevSrc || '');
return new Promise((resolve) => {
const start = Date.now();
+ let sawRunning = false;
+ let idleTicks = 0;
+ let candidate = null;
state.waitImageTimer = setInterval(() => {
if (epoch !== state.chatEpoch) {
cancelWaitForNewImage();
resolve(null);
return;
}
- const src = findCurrentGenerateSrc();
- if (src && src !== prevSrc && !looksLikeModelPreview(src)) {
+ const running = isSwarmGenerateRunning();
+ if (running) {
+ sawRunning = true;
+ idleTicks = 0;
+ } else if (sawRunning) {
+ idleTicks += 1;
+ }
+ const raw = findCurrentGenerateSrc();
+ const src = raw && !looksLikeModelPreview(raw) ? raw : null;
+ if (src && src !== prev) {
+ candidate = src;
+ }
+ // Primary: Swarm finished after we saw it run — accept current/changed frame
+ // even when ViewImage URL was reused (same string, new bytes).
+ if (sawRunning && !running && idleTicks >= 2) {
cancelWaitForNewImage();
- resolve(src);
- } else if (Date.now() - start > timeoutMs) {
+ resolve(candidate || src || null);
+ return;
+ }
+ // Missed the running flag (very fast Turbo): URL changed and Swarm is idle.
+ if (candidate && !running && Date.now() - start > 500) {
cancelWaitForNewImage();
- resolve(null);
+ resolve(candidate);
+ return;
+ }
+ if (Date.now() - start > timeoutMs) {
+ cancelWaitForNewImage();
+ resolve(candidate || src || null);
}
}, 400);
});
@@ -4771,6 +4879,10 @@
if (!data || data.error) {
return;
}
+ const prevPersona = state.config?.persona || $('sa_persona')?.value || '';
+ const prevControls = state.config?.control_values && typeof state.config.control_values === 'object'
+ ? { ...state.config.control_values }
+ : null;
state.config = data;
if (data.exact && typeof data.exact === 'object') {
state.exact = data.exact;
@@ -4849,7 +4961,17 @@
if (applyDefaults || data.exact) {
fillEmptyParamsFromExact();
}
- renderPersonaControls(data.controls || {}, data.control_values || data.exact?.controls || {});
+ const nextPersona = data.persona || $('sa_persona')?.value || '';
+ let controlValues = data.control_values || data.exact?.controls || {};
+ // Same persona refresh must not wipe in-flight slider drags / optimistic saves with disk defaults.
+ if (!applyDefaults && prevControls && nextPersona === prevPersona) {
+ controlValues = { ...controlValues, ...prevControls };
+ state.config.control_values = controlValues;
+ if (state.exact) {
+ state.exact.controls = { ...(state.exact.controls || {}), ...prevControls };
+ }
+ }
+ renderPersonaControls(data.controls || {}, controlValues);
syncPersonaDeleteButton(data.persona_source || data.personas?.find((p) => p.id === (data.persona || $('sa_persona')?.value))?.source);
}
@@ -7110,7 +7232,15 @@
downloadCivitaiLoRA(pick, null);
}
}
- if (effective && !fromVisionHop && !fromAutoCritique) {
+ const suppressGen = !fromVisionHop && !fromAutoCritique && userAsksNoGenerate(opts.userText || '');
+ if (suppressGen && effective) {
+ effective = stripLookAt(stripGenerateAction(effective));
+ state.pendingSilentGen = false;
+ if (effective) {
+ rememberLastPatch(effective);
+ }
+ }
+ if (effective && !fromVisionHop && !fromAutoCritique && !suppressGen) {
const hopped = await maybeVisionHop(effective, opts.attachedSlotIds || []);
if (hopped) {
return;
@@ -7121,7 +7251,7 @@
state.pendingSilentGen = false;
return;
}
- const wantsGen = !!(opts.userWantsGenerate || state.pendingSilentGen
+ const wantsGen = !suppressGen && !!(opts.userWantsGenerate || state.pendingSilentGen
|| (Array.isArray(effective?.actions) && effective.actions.map(String).includes('generate')));
const doApply = !!(effective && (wantsGen || $('sa_auto_apply')?.checked));
if (doApply) {
@@ -7133,7 +7263,9 @@
await applyPatch(effective, 'all');
syncLiveParamsBar();
updateTasteFromPatch(effective, opts.userText || '');
- if (!fromAutoCritique && (wantsGen || $('sa_auto_generate')?.checked)) {
+ // Auto-Generate must not fire on «запомни / шаблон» turns — even if the model
+ // echoed a prompt patch or sneaked actions:["generate"].
+ if (!fromAutoCritique && !suppressGen && (wantsGen || $('sa_auto_generate')?.checked)) {
const src = await runGenerateFromPatch(
{ ...effective, actions: Array.isArray(effective.actions) ? effective.actions : ['generate'] },
{ force: wantsGen },
@@ -7143,7 +7275,7 @@
await maybeAutoVisionLook(src);
}
} else if (!state.generating) {
- stopBusyUi(wantsGen ? 'Применено' : '');
+ stopBusyUi(suppressGen ? 'Запомнил · без Generate' : (wantsGen ? 'Применено' : ''));
}
} else if (replyMissingJsonPatch(reply) && !fromAutoCritique && !fromVisionHop) {
setStatus('Ответ без JSON-патча — ничего не применено');
@@ -7569,6 +7701,9 @@
state.lastUserParamIntent = userTextMentionsParams(text);
state.lastUserControlIntent = userTextMentionsControls(text);
state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text);
+ if (userAsksNoGenerate(text)) {
+ state.pendingSilentGen = false;
+ }
}
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.skipSlash) {
diff --git a/AssistentChatPipeline.cs b/AssistentChatPipeline.cs
index 1402c81..3ec6702 100644
--- a/AssistentChatPipeline.cs
+++ b/AssistentChatPipeline.cs
@@ -1003,23 +1003,30 @@ public partial class SwarmAssistentExtension
patch["_persona_written"] = target;
}
}
- // Control values from model patch (Exact). Ignore default-echo inside Generate patches.
+ // Control values from model patch (Exact). Generate patches never touch sliders.
if (patch["controls"] is JObject ctrlVals)
{
- string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId;
- JObject schema = Config.LoadControlsSchema(ctrlPid);
- JObject current = Config.LoadControlValues(ctrlPid);
- JObject filtered = AssistentConfig.FilterEchoedControlDefaults(
- schema, current, ctrlVals, AssistentConfig.PatchLooksLikeGeneration(patch));
- if (filtered.Count > 0)
+ if (AssistentConfig.PatchLooksLikeGeneration(patch))
{
- Config.SaveControlValues(ctrlPid, filtered);
- patch["controls"] = filtered;
- patch["_controls_saved"] = true;
+ patch.Remove("controls");
}
else
{
- patch.Remove("controls");
+ string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId;
+ JObject schema = Config.LoadControlsSchema(ctrlPid);
+ JObject current = Config.LoadControlValues(ctrlPid);
+ JObject filtered = AssistentConfig.FilterEchoedControlDefaults(
+ schema, current, ctrlVals, patchLooksLikeGen: false);
+ if (filtered.Count > 0)
+ {
+ Config.SaveControlValues(ctrlPid, filtered);
+ patch["controls"] = filtered;
+ patch["_controls_saved"] = true;
+ }
+ else
+ {
+ patch.Remove("controls");
+ }
}
}
}
diff --git a/AssistentConfig.cs b/AssistentConfig.cs
index 16cc2d4..aa2105d 100644
--- a/AssistentConfig.cs
+++ b/AssistentConfig.cs
@@ -468,10 +468,11 @@ public sealed class AssistentConfig
///
/// Drop control keys that merely restate schema defaults while the user already has a
- /// different saved value — models often echo Exact defaults inside Generate patches and
- /// that was resetting Хорни / Вкус after every reply.
- /// Keep intentional changes: non-default values, or default when it matches current, or
- /// when the patch is controls-only (e.g. /horny-game).
+ /// different saved value — models often echo Exact defaults inside patches and that was
+ /// resetting Хорни / Вкус after every reply.
+ /// Always ignore default-echo when current differs (Generate or not). Intentional resets
+ /// go through AssistentSaveControls / controls-only patches with a non-default target, or
+ /// Generate patches are stripped of controls entirely in ApplyPersonaActions.
///
public static JObject FilterEchoedControlDefaults(JObject schema, JObject current, JObject incoming, bool patchLooksLikeGen)
{
@@ -480,6 +481,11 @@ public sealed class AssistentConfig
{
return result;
}
+ // Generate patches must not mutate persona sliders — strip everything.
+ if (patchLooksLikeGen)
+ {
+ return result;
+ }
current ??= new JObject();
foreach (JProperty prop in incoming.Properties())
{
@@ -503,8 +509,8 @@ public sealed class AssistentConfig
{
continue;
}
- if (patchLooksLikeGen
- && !double.IsNaN(defVal)
+ // Default echo while the user has a tuned value — ignore even on controls-only patches.
+ if (!double.IsNaN(defVal)
&& Math.Abs(incomingVal - defVal) < 0.0005
&& !double.IsNaN(curVal)
&& Math.Abs(curVal - defVal) > 0.0005)
diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md
index 9308f10..c36a8f6 100644
--- a/Config/_base/core/core.md
+++ b/Config/_base/core/core.md
@@ -64,6 +64,7 @@ Never write a “JSON Patch” section in prose without an actual fenced ```json
### Actions / hops
- `"generate"` — Apply + start generation when the user wants a new image.
+- If the user only asks to **remember / save** a prompt as base/template («запомни», «как базовый промпт», «шаблон») and did **not** ask for a new image: **omit** `actions: ["generate"]`, do **not** `look_at`. Prefer `memory_upsert` (kind `note`, key like `base_prompt`) or a short ack; you may echo `prompt` in the patch only to sync the SwarmUI box — the UI will not Generate on remember turns.
- `"search_civitai"` + **required** short `search_query` — Civitai hop (user Confirms downloads). Without `search_query` the hop is skipped (never search the whole user message).
- `"interrupt"` — stop generation.
- `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops.
diff --git a/Config/personas/leonid/rules.json b/Config/personas/leonid/rules.json
index 30fc3e9..2e4a227 100644
--- a/Config/personas/leonid/rules.json
+++ b/Config/personas/leonid/rules.json
@@ -6,11 +6,11 @@
"Craft first: triggers, aspect, Turbo — horny never replaces technique",
"Scale appearance/outfits by controls.preference_bias (−1…1)",
"Scale sexual tone + roleplay fetishes by controls.horny (0…100)",
- "Never re-emit controls.horny / preference_bias in a Generate patch unless the user asked to change them — echoing defaults resets the UI",
+ "Never re-emit controls.horny / preference_bias in a Generate patch — UI sliders are authoritative; echoing defaults resets them",
"Explicit user look/outfit/plot beats personal taste",
"«девушка которая тебе нравится» = YOUR (Leonid) taste, not the user's — unless they said otherwise",
"High horny / NSFW plot / craft detail: persona_read shelves [\"roleplay\"] (and humor/craft if needed) — they are not always-on",
- "/horny-game: score taste match → patch controls.horny (0–100), say new % in prose; no generate unless asked"
+ "If user asks to remember/save a base prompt/template without asking for a new image: no actions generate, no look_at — memory_upsert or ack only",
],
"never": [
"Call the user Leonid/Леонид just because that is your persona title",
diff --git a/README.md b/README.md
index 63520b8..6f1c2cd 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,12 @@
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **About the user** prefs + craft vector memory, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
+**Version 0.10.21** — Board Generate spinner clears when Swarm finishes (`num_live_gens`), not only when the image URL string changes. Builds on 0.10.20 remember-without-gen.
+
+**Version 0.10.20** — «Запомни / базовый промпт» no longer triggers Auto-Generate or auto look_at (even if the model sneaks `actions:["generate"]`). Builds on 0.10.19 slider echo fix.
+
+**Version 0.10.19** — Вкус/Хорни no longer reset: Generate patches never apply `controls`; default-echo filtered always; bare «вкус» no longer disables the filter. Builds on 0.10.18 settings tab.
+
**Version 0.10.18** — Settings is a full subtab (Чат | Карточки | Настройки), not a header overlay. Builds on 0.10.17 post-Generate warm.
## Layout
diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs
index ac07ff7..62597a6 100644
--- a/SwarmAssistentExtension.cs
+++ b/SwarmAssistentExtension.cs
@@ -36,7 +36,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT";
- Version = "0.10.18";
+ Version = "0.10.21";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
}