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"]; }