Ship Assistent 0.10.21: slider/remember guards and Generate spinner fix.

Strip controls from Generate patches, skip Auto-Gen on remember turns, clear board spinner when Swarm finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 04:38:45 +03:00
co-authored by Cursor
parent 536637714c
commit 036484c7b7
7 changed files with 195 additions and 40 deletions
+153 -18
View File
@@ -720,8 +720,10 @@
if (/\b(horny|controls?|preference[_\s-]?bias)\b/i.test(t)) { if (/\b(horny|controls?|preference[_\s-]?bias)\b/i.test(t)) {
return true; return true;
} }
return cyrTokenRe('хорни|остынь|вкус|слайдер').test(t) // Do not match bare «вкус» — too common in RU chat and was disabling the echo filter.
|| /\/\s*(остынь|ostyn|horny-game)/i.test(t); return cyrTokenRe('хорни|остынь|слайдер').test(t)
|| /\/\s*(остынь|ostyn|horny-game)/i.test(t)
|| /слайдер\s*вкус|вкус\s*(на|в)\s*\d|поставь\s*вкус|крутани\s*вкус/i.test(t);
} }
function patchLooksLikeGeneration(patch) { function patchLooksLikeGeneration(patch) {
@@ -736,14 +738,17 @@
return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'); 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) { function filterControlPatch(incoming, patch) {
const schema = state.config?.controls || {}; const schema = state.config?.controls || {};
const out = {}; const out = {};
if (!incoming || typeof incoming !== 'object') { if (!incoming || typeof incoming !== 'object') {
return out; 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)) { for (const [id, raw] of Object.entries(incoming)) {
if (!schema[id]) { if (!schema[id]) {
continue; continue;
@@ -757,7 +762,10 @@
if (Math.abs(n - cur) < 0.0005) { if (Math.abs(n - cur) < 0.0005) {
continue; 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; continue;
} }
out[id] = n; out[id] = n;
@@ -810,6 +818,57 @@
).test(t); ).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) { function rememberLastPatch(patch) {
if (patch && typeof patch === 'object' && !isCardObject(patch)) { if (patch && typeof patch === 'object' && !isCardObject(patch)) {
state.lastPatch = patch; state.lastPatch = patch;
@@ -1305,10 +1364,15 @@
return null; return null;
} }
function isGenerateUnavailable() { function isSwarmGenerateRunning() {
if (state.generating || state.busy) { try {
if (typeof num_live_gens === 'number' && num_live_gens > 0) {
return true; return true;
} }
if (typeof num_waiting_gens === 'number' && num_waiting_gens > 0) {
return true;
}
} catch (e) { /* ignore */ }
try { try {
if (typeof mainGenHandler !== 'undefined' && mainGenHandler) { if (typeof mainGenHandler !== 'undefined' && mainGenHandler) {
if (mainGenHandler.isGenerating === true || mainGenHandler.running === true) { if (mainGenHandler.isGenerating === true || mainGenHandler.running === true) {
@@ -1328,10 +1392,20 @@
return false; return false;
} }
function isGenerateUnavailable() {
if (state.generating || state.busy) {
return true;
}
return isSwarmGenerateRunning();
}
function syncGenerateBusy() { function syncGenerateBusy() {
const overlay = document.querySelector('.sa-slot-gen .sa-slot-busy'); const overlay = document.querySelector('.sa-slot-gen .sa-slot-busy');
if (overlay) { 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; empty.hidden = true;
} }
frame?.classList.add('sa-has-image'); frame?.classList.add('sa-has-image');
} else if (src && slot.src === src) {
// Same URL, possibly new bytes after overwrite — nudge <img> 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) { } else if (!src && !slot.src) {
const empty = document.querySelector('.sa-slot-gen .sa-image-empty'); const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
const frame = document.querySelector('.sa-slot-gen'); const frame = document.querySelector('.sa-slot-gen');
@@ -3911,21 +3994,46 @@
function waitForNewImage(prevSrc, timeoutMs = 180000) { function waitForNewImage(prevSrc, timeoutMs = 180000) {
cancelWaitForNewImage(); cancelWaitForNewImage();
const epoch = state.chatEpoch; const epoch = state.chatEpoch;
const prev = String(prevSrc || '');
return new Promise((resolve) => { return new Promise((resolve) => {
const start = Date.now(); const start = Date.now();
let sawRunning = false;
let idleTicks = 0;
let candidate = null;
state.waitImageTimer = setInterval(() => { state.waitImageTimer = setInterval(() => {
if (epoch !== state.chatEpoch) { if (epoch !== state.chatEpoch) {
cancelWaitForNewImage(); cancelWaitForNewImage();
resolve(null); resolve(null);
return; return;
} }
const src = findCurrentGenerateSrc(); const running = isSwarmGenerateRunning();
if (src && src !== prevSrc && !looksLikeModelPreview(src)) { 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(); cancelWaitForNewImage();
resolve(src); resolve(candidate || src || null);
} else if (Date.now() - start > timeoutMs) { return;
}
// Missed the running flag (very fast Turbo): URL changed and Swarm is idle.
if (candidate && !running && Date.now() - start > 500) {
cancelWaitForNewImage(); cancelWaitForNewImage();
resolve(null); resolve(candidate);
return;
}
if (Date.now() - start > timeoutMs) {
cancelWaitForNewImage();
resolve(candidate || src || null);
} }
}, 400); }, 400);
}); });
@@ -4771,6 +4879,10 @@
if (!data || data.error) { if (!data || data.error) {
return; 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; state.config = data;
if (data.exact && typeof data.exact === 'object') { if (data.exact && typeof data.exact === 'object') {
state.exact = data.exact; state.exact = data.exact;
@@ -4849,7 +4961,17 @@
if (applyDefaults || data.exact) { if (applyDefaults || data.exact) {
fillEmptyParamsFromExact(); 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); syncPersonaDeleteButton(data.persona_source || data.personas?.find((p) => p.id === (data.persona || $('sa_persona')?.value))?.source);
} }
@@ -7110,7 +7232,15 @@
downloadCivitaiLoRA(pick, null); 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 || []); const hopped = await maybeVisionHop(effective, opts.attachedSlotIds || []);
if (hopped) { if (hopped) {
return; return;
@@ -7121,7 +7251,7 @@
state.pendingSilentGen = false; state.pendingSilentGen = false;
return; return;
} }
const wantsGen = !!(opts.userWantsGenerate || state.pendingSilentGen const wantsGen = !suppressGen && !!(opts.userWantsGenerate || state.pendingSilentGen
|| (Array.isArray(effective?.actions) && effective.actions.map(String).includes('generate'))); || (Array.isArray(effective?.actions) && effective.actions.map(String).includes('generate')));
const doApply = !!(effective && (wantsGen || $('sa_auto_apply')?.checked)); const doApply = !!(effective && (wantsGen || $('sa_auto_apply')?.checked));
if (doApply) { if (doApply) {
@@ -7133,7 +7263,9 @@
await applyPatch(effective, 'all'); await applyPatch(effective, 'all');
syncLiveParamsBar(); syncLiveParamsBar();
updateTasteFromPatch(effective, opts.userText || ''); 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( const src = await runGenerateFromPatch(
{ ...effective, actions: Array.isArray(effective.actions) ? effective.actions : ['generate'] }, { ...effective, actions: Array.isArray(effective.actions) ? effective.actions : ['generate'] },
{ force: wantsGen }, { force: wantsGen },
@@ -7143,7 +7275,7 @@
await maybeAutoVisionLook(src); await maybeAutoVisionLook(src);
} }
} else if (!state.generating) { } else if (!state.generating) {
stopBusyUi(wantsGen ? 'Применено' : ''); stopBusyUi(suppressGen ? 'Запомнил · без Generate' : (wantsGen ? 'Применено' : ''));
} }
} else if (replyMissingJsonPatch(reply) && !fromAutoCritique && !fromVisionHop) { } else if (replyMissingJsonPatch(reply) && !fromAutoCritique && !fromVisionHop) {
setStatus('Ответ без JSON-патча — ничего не применено'); setStatus('Ответ без JSON-патча — ничего не применено');
@@ -7569,6 +7701,9 @@
state.lastUserParamIntent = userTextMentionsParams(text); state.lastUserParamIntent = userTextMentionsParams(text);
state.lastUserControlIntent = userTextMentionsControls(text); state.lastUserControlIntent = userTextMentionsControls(text);
state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text); state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text);
if (userAsksNoGenerate(text)) {
state.pendingSilentGen = false;
}
} }
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.skipSlash) { if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.skipSlash) {
+9 -2
View File
@@ -1003,14 +1003,20 @@ public partial class SwarmAssistentExtension
patch["_persona_written"] = target; 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) if (patch["controls"] is JObject ctrlVals)
{
if (AssistentConfig.PatchLooksLikeGeneration(patch))
{
patch.Remove("controls");
}
else
{ {
string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId; string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId;
JObject schema = Config.LoadControlsSchema(ctrlPid); JObject schema = Config.LoadControlsSchema(ctrlPid);
JObject current = Config.LoadControlValues(ctrlPid); JObject current = Config.LoadControlValues(ctrlPid);
JObject filtered = AssistentConfig.FilterEchoedControlDefaults( JObject filtered = AssistentConfig.FilterEchoedControlDefaults(
schema, current, ctrlVals, AssistentConfig.PatchLooksLikeGeneration(patch)); schema, current, ctrlVals, patchLooksLikeGen: false);
if (filtered.Count > 0) if (filtered.Count > 0)
{ {
Config.SaveControlValues(ctrlPid, filtered); Config.SaveControlValues(ctrlPid, filtered);
@@ -1023,6 +1029,7 @@ public partial class SwarmAssistentExtension
} }
} }
} }
}
catch (Exception ex) catch (Exception ex)
{ {
Logs.Warning($"Assistent persona actions: {ex.Message}"); Logs.Warning($"Assistent persona actions: {ex.Message}");
+12 -6
View File
@@ -468,10 +468,11 @@ public sealed class AssistentConfig
/// <summary> /// <summary>
/// Drop control keys that merely restate schema defaults while the user already has a /// 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 /// different saved value — models often echo Exact defaults inside patches and that was
/// that was resetting Хорни / Вкус after every reply. /// resetting Хорни / Вкус after every reply.
/// Keep intentional changes: non-default values, or default when it matches current, or /// Always ignore default-echo when current differs (Generate or not). Intentional resets
/// when the patch is controls-only (e.g. /horny-game). /// go through AssistentSaveControls / controls-only patches with a non-default target, or
/// Generate patches are stripped of controls entirely in ApplyPersonaActions.
/// </summary> /// </summary>
public static JObject FilterEchoedControlDefaults(JObject schema, JObject current, JObject incoming, bool patchLooksLikeGen) public static JObject FilterEchoedControlDefaults(JObject schema, JObject current, JObject incoming, bool patchLooksLikeGen)
{ {
@@ -480,6 +481,11 @@ public sealed class AssistentConfig
{ {
return result; return result;
} }
// Generate patches must not mutate persona sliders — strip everything.
if (patchLooksLikeGen)
{
return result;
}
current ??= new JObject(); current ??= new JObject();
foreach (JProperty prop in incoming.Properties()) foreach (JProperty prop in incoming.Properties())
{ {
@@ -503,8 +509,8 @@ public sealed class AssistentConfig
{ {
continue; continue;
} }
if (patchLooksLikeGen // Default echo while the user has a tuned value — ignore even on controls-only patches.
&& !double.IsNaN(defVal) if (!double.IsNaN(defVal)
&& Math.Abs(incomingVal - defVal) < 0.0005 && Math.Abs(incomingVal - defVal) < 0.0005
&& !double.IsNaN(curVal) && !double.IsNaN(curVal)
&& Math.Abs(curVal - defVal) > 0.0005) && Math.Abs(curVal - defVal) > 0.0005)
+1
View File
@@ -64,6 +64,7 @@ Never write a “JSON Patch” section in prose without an actual fenced ```json
### Actions / hops ### Actions / hops
- `"generate"` — Apply + start generation when the user wants a new image. - `"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). - `"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. - `"interrupt"` — stop generation.
- `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops. - `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops.
+2 -2
View File
@@ -6,11 +6,11 @@
"Craft first: triggers, aspect, Turbo — horny never replaces technique", "Craft first: triggers, aspect, Turbo — horny never replaces technique",
"Scale appearance/outfits by controls.preference_bias (1…1)", "Scale appearance/outfits by controls.preference_bias (1…1)",
"Scale sexual tone + roleplay fetishes by controls.horny (0…100)", "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", "Explicit user look/outfit/plot beats personal taste",
"«девушка которая тебе нравится» = YOUR (Leonid) taste, not the user's — unless they said otherwise", "«девушка которая тебе нравится» = 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", "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 (0100), 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": [ "never": [
"Call the user Leonid/Леонид just because that is your persona title", "Call the user Leonid/Леонид just because that is your persona title",
+6
View File
@@ -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. 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. **Version 0.10.18** — Settings is a full subtab (Чат | Карточки | Настройки), not a header overlay. Builds on 0.10.17 post-Generate warm.
## Layout ## Layout
+1 -1
View File
@@ -36,7 +36,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid"; ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT"; License = "MIT";
Version = "0.10.18"; Version = "0.10.21";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
} }