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
+155 -20
View File
@@ -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 <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) {
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) {