Ship Assistent 0.11.6: opt-in vision, negative pass-through, generate from context.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 05:21:48 +03:00
co-authored by Cursor
parent 56e089d8da
commit 784426c9bf
11 changed files with 265 additions and 40 deletions
+236 -18
View File
@@ -16,6 +16,8 @@
const LS_AUTO_APPLY = 'swarm_assistent_auto_apply';
const LS_AUTO_GENERATE = 'swarm_assistent_auto_generate';
const LS_AUTO_CRITIQUE = 'swarm_assistent_auto_critique';
/** One-shot: turn off auto look_at / auto-critique (vision is button / /look / model look_at). */
const LS_VISION_OPTIN_MIG = 'swarm_assistent_vision_optin_v1';
const LS_AUTO_DOWNLOAD = 'swarm_assistent_auto_download';
/** When '1', unload chat LLM before Generate (frees VRAM; VL reload can take 12 min). Default off. */
const LS_PARK_LLM = 'swarm_assistent_park_llm';
@@ -89,7 +91,7 @@
/debug ask то же + короткий ответ модели
/why сразу /debug ask
/gen Generate сейчас
/look generate|refN прикрепить окно к vision
/look generate|refN показать кадр модели (vision)
/init /mask /clear Init / Mask / Clear Init
/interrupt остановить генерацию
/aspect 16:9 размер из таблицы 1K
@@ -656,6 +658,43 @@
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function liveNegativePrompt() {
return String(val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '').trim();
}
function exactDefaultNegative() {
const { exact } = resolveExactBundle();
const n = exact?.generation?.negative ?? exact?.negative;
return n != null ? String(n).trim() : '';
}
function setNegativePrompt(text) {
const s = text != null ? String(text) : '';
if (document.getElementById('input_negativeprompt')) {
setVal('input_negativeprompt', s);
}
if (document.getElementById('alt_negativeprompt_textbox')) {
setVal('alt_negativeprompt_textbox', s);
}
}
/** Create / keep / pass-through negative so Generate never drops the Swarm box. */
function ensureNegativeForGenerate(patch) {
let neg = '';
if (patch && patch.negative != null && String(patch.negative).trim() !== '') {
neg = String(patch.negative).trim();
} else {
neg = liveNegativePrompt() || exactDefaultNegative();
}
if (neg) {
setNegativePrompt(neg);
if (patch && (patch.negative == null || String(patch.negative).trim() === '')) {
patch.negative = neg;
}
}
return neg;
}
function isEmptyParamField(raw, { treatZeroEmpty = false } = {}) {
if (raw == null) {
return true;
@@ -875,7 +914,7 @@
/** When the model wrote ### JSON Patch with no fence — build Apply+Generate from prose / last patch. */
function synthesizePatchAfterEmptyFence(reply, userText, opts = {}) {
const wants = !!(opts.userWantsGenerate || state.pendingSilentGen
|| userAsksGenerate(userText) || userAsksContinue(userText));
|| userAsksGenerate(userText) || userAsksContinue(userText) || userImpliesGenerate(userText));
const missing = replyMissingJsonPatch(reply);
if (!wants && !missing) {
return null;
@@ -930,6 +969,14 @@
if (patch.aspect) {
keep.aspect = patch.aspect;
}
if (patch.negative != null && String(patch.negative).trim() !== '') {
keep.negative = patch.negative;
} else {
const liveNeg = liveNegativePrompt() || exactDefaultNegative();
if (liveNeg) {
keep.negative = liveNeg;
}
}
if (Array.isArray(patch.loras)) {
keep.loras = patch.loras;
}
@@ -947,9 +994,10 @@
+ '- Structure & front-load: subject → pose/action → body/wardrobe → setting → materials/textures → camera/framing → lighting → medium/mood.\n'
+ '- Expand thin ideas; fix anti-patterns; one coherent scene.\n'
+ '- Keep LoRA trigger phrases in English near the subject they affect.\n'
+ '- Prefer positives over negatives; preserve meaning and NSFW level from SOURCE.\n'
+ '- Always include JSON "negative": keep/supplement SOURCE+live negative, or use Exact default if empty. Never drop it.\n'
+ '- Put “no blur / empty street” ideas as positives in prompt, not as a huge negative dump.\n'
+ '- One short ack in the user language max, then ONE fenced JSON merging these keys: '
+ `${JSON.stringify(keep)} plus the new English "prompt".\n`
+ `${JSON.stringify(keep)} plus the new English "prompt" and "negative".\n`
+ '- Include actions:["generate"] when an image was requested.\n\n'
+ `SOURCE:\n${patch.prompt}`
);
@@ -965,6 +1013,9 @@
...base,
...effective,
prompt: effective.prompt || base.prompt,
negative: (effective.negative != null && String(effective.negative).trim() !== '')
? effective.negative
: (base.negative || liveNegativePrompt() || exactDefaultNegative() || undefined),
actions: (Array.isArray(effective.actions) && effective.actions.length)
? effective.actions
: (base.actions || ['generate']),
@@ -990,6 +1041,137 @@
).test(t);
}
/** Button / /look / «посмотри на кадр» — not casual «смотри какая». */
function userAsksLook(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/\b(look\s+at|critique|criticize|describe\s+(this|the|ref|image)|what\s+do\s+you\s+see)\b/i.test(t)) {
return true;
}
if (cyrTokenRe('критик[а-яё]*|что\\s+не\\s+так|разбери').test(t)) {
return true;
}
if (cyrTokenRe('опиши\\s+(это|эту|реф|изображ[а-яё]*|картинк[а-яё]*|кадр|результат|референс)').test(t)) {
return true;
}
return /(?:^|[^а-яёa-z0-9_])(посмотри|смотри|глянь)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген|реф)/i.test(t);
}
/**
* Scene / edit / «ещё» Generate without the magic word «генерируй».
* Bare «давай» and trivia questions stay false.
*/
function userImpliesGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksNoGenerate(t)) {
return false;
}
if (userAsksGenerate(t) || userAsksContinue(t) || isSameButAspectRequest(t)) {
return true;
}
if (t.length < 8) {
return false;
}
const wantsLook = userAsksLook(t);
const wantsRedraw = cyrTokenRe('поправь|исправь|перегенерир|перерисуй|улучши|переделай').test(t)
|| /\b(fix|redo|redraw|improve)\b/i.test(t);
if (wantsLook && !wantsRedraw && !userAsksGenerate(t)) {
return false;
}
if (cyrTokenRe(
'что\\s+такое|как\\s+работает|зачем\\s+|какие\\s+(лор|модел|чекпоинт)|'
+ 'список\\s+лор|где\\s+настрой|что\\s+значит',
).test(t) && !cyrTokenRe('нарису|сгенер|картинк').test(t)) {
return false;
}
if (cyrTokenRe(
'нарису|сгенер|перерису|картинк|изображен|'
+ 'хочу\\s+(увидеть|видеть)|покажи\\s+(её|ее|его|как)|'
+ 'сделай\\s+(её|ее|его|мне)|пусть\\s+будет|'
+ 'давай\\s+(её|ее|его|с\\s|в\\s|на\\s)|'
+ 'в\\s+(студии|лесу|интерьер|постел)|'
+ 'другой\\s+(ракурс|свет|наряд|поза)|'
+ 'поменяй\\s+(позу|свет|одежд|фон)|добавь\\s+(свет|детал)|'
+ 'ещё\\s+одн|еще\\s+одн|вариант',
).test(t)) {
return true;
}
if (/\b(draw|paint|render|make her|make him|another one|new frame|in the (studio|forest))\b/i.test(t)) {
return true;
}
const isQuestion = /[?]\s*$/.test(t);
if (isQuestion) {
return cyrTokenRe('нарису|сгенер|можешь\\s+(сделать|нарисовать)|можно\\s+(картинк|сгенер)').test(t);
}
return t.length >= 40 && cyrTokenRe(
'девушк|женщин|парн|мужчин|стоит|сидит|лежит|обнаж|поза|интерьер|студи',
).test(t);
}
function packBlocksAutoGenerate(pack) {
const p = String(pack || '');
return p === 'describe_ref' || p === 'catalog_card' || p === 'author_persona';
}
/** If the turn is a frame, put actions:["generate"] on the patch even when the model omitted it. */
function ensureGenerateAction(patch, userText) {
if (!patch || typeof patch !== 'object') {
return patch;
}
if (userAsksNoGenerate(userText)) {
return patch;
}
if (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')) {
return patch;
}
const pack = $('sa_pack')?.value || '';
if (packBlocksAutoGenerate(pack) && !userAsksGenerate(userText) && !userAsksContinue(userText)) {
return patch;
}
const hasFrame = patch.prompt != null
|| (Array.isArray(patch.variants) && patch.variants.length > 0);
const implied = userImpliesGenerate(userText);
if (!hasFrame && !implied) {
return patch;
}
const out = { ...patch };
const acts = Array.isArray(patch.actions) ? patch.actions.map(String).filter((a) => a && a !== 'generate') : [];
acts.push('generate');
out.actions = acts;
return out;
}
function packWantsVision(pack) {
const p = String(pack || '');
return p === 'critique_image' || p === 'describe_ref' || p === 'compose_scene' || p === 'inpaint_edit';
}
/** Honor model look_at only when asked, pack needs it, or look-only (no Generate this turn). */
function shouldHonorLookAt(patch, opts = {}) {
if (!patch || typeof patch !== 'object') {
return false;
}
if (patch.look_at == null && patch.vision_from == null && patch.vision_slots == null) {
return false;
}
if (opts.fromAutoCritique || opts.fromVisionHop) {
return true;
}
if (userAsksLook(opts.userText || '')) {
return true;
}
if (packWantsVision($('sa_pack')?.value)) {
return true;
}
// look_at + generate on a write turn stares at the old frame and delays the new one.
if (patchHasGenTrigger(patch)) {
return false;
}
return true;
}
function stripGenerateAction(patch) {
if (!patch || typeof patch !== 'object') {
return patch;
@@ -1204,6 +1386,12 @@
setVal(batchId, String(batch));
}
}
if (!liveNegativePrompt()) {
const neg = exactDefaultNegative();
if (neg) {
setNegativePrompt(neg);
}
}
}
function shouldSkipSessionRollback(key, patchValue) {
@@ -2274,6 +2462,9 @@
promptBox.dispatchEvent(new Event('change', { bubbles: true }));
}
setVal('input_negativeprompt', params.negative != null ? String(params.negative) : '');
if (document.getElementById('alt_negativeprompt_textbox')) {
setVal('alt_negativeprompt_textbox', params.negative != null ? String(params.negative) : '');
}
// Force-write numerics when present so prior chat values cannot stick.
if (params.width != null) {
setVal('input_width', String(params.width));
@@ -3965,9 +4156,6 @@
box.dispatchEvent(new Event('input', { bubbles: true }));
box.dispatchEvent(new Event('change', { bubbles: true }));
}
if (patch.negative != null) {
setVal('input_negativeprompt', patch.negative);
}
if (Array.isArray(patch.loras)) {
for (const l of patch.loras) {
const triggers = l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []);
@@ -3980,6 +4168,13 @@
}
}
}
if (doPrompt) {
if (patch.negative != null) {
setNegativePrompt(patch.negative);
} else if (patchHasGenTrigger(patch)) {
ensureNegativeForGenerate(patch);
}
}
if (doLoras && Array.isArray(patch.loras) && typeof loraHelper !== 'undefined' && loraHelper) {
try {
@@ -4611,6 +4806,7 @@
await applyPatch(job.patch, 'all');
syncLiveParamsBar();
}
ensureNegativeForGenerate(job.patch);
if (epoch !== state.chatEpoch) {
break;
@@ -5332,6 +5528,18 @@
}
localStorage.setItem(LS_PACK_ORDINARY_MIG, '1');
}
// One-shot: stop staring at every Generate. Vision = button / /look / model look_at.
if (!localStorage.getItem(LS_VISION_OPTIN_MIG)) {
localStorage.setItem(LS_AUTO_VISION, '0');
localStorage.setItem(LS_AUTO_CRITIQUE, '0');
localStorage.setItem(LS_VISION_OPTIN_MIG, '1');
if ($('sa_auto_vision')) {
$('sa_auto_vision').checked = false;
}
if ($('sa_auto_critique')) {
$('sa_auto_critique').checked = false;
}
}
const base = localStorage.getItem(LS_BASE);
const model = localStorage.getItem(LS_MODEL);
const pack = localStorage.getItem(LS_PACK);
@@ -7865,19 +8073,18 @@
}
if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug
&& !opts.fromEmptyPatchRetry
&& replyMissingJsonPatch(reply)
&& (opts.userWantsGenerate || state.pendingSilentGen || userAsksContinue(opts.userText || ''))) {
appendSystemNote('Патч пустой — прошу модель дописать JSON.');
&& userImpliesGenerate(opts.userText || '')) {
appendSystemNote('Нужен кадр — прошу JSON с prompt + generate.');
await sendChat({
skipSlash: true,
skipAutoPack: true,
fromEmptyPatchRetry: true,
userWantsGenerate: true,
forcedUserText:
'Ты написал «JSON Patch» без fenced ```json```. '
+ 'Сейчас ответь ТОЛЬКО одним fenced JSON объектом: '
+ '{"prompt":"<English Krea prompt for Qwen3-VL>","actions":["generate"]}. '
+ 'prompt — только английский (skill prompting). Без прозы, без ### заголовков.',
'Пользователь уже просит кадр (это следует из сообщения, даже без слова «генерируй»). '
+ 'Ответь ТОЛЬКО одним fenced JSON: '
+ '{"prompt":"<English Krea prompt>","negative":"<echo or short quality neg>","actions":["generate"]}. '
+ 'prompt — английский. Без прозы, без «скажи сгенерируй».',
});
return;
}
@@ -7905,7 +8112,17 @@
rememberLastPatch(effective);
}
}
if (effective && !fromVisionHop && !fromAutoCritique && !suppressGen) {
if (effective && !fromDebug && !suppressGen) {
effective = ensureGenerateAction(effective, opts.userText || '');
rememberLastPatch(effective);
}
if (effective && !fromVisionHop && !fromAutoCritique && !fromDebug && !shouldHonorLookAt(effective, opts)) {
effective = stripLookAt(effective);
if (effective) {
rememberLastPatch(effective);
}
}
if (effective && !fromVisionHop && !fromAutoCritique && !suppressGen && shouldHonorLookAt(effective, opts)) {
const hopped = await maybeVisionHop(effective, opts.attachedSlotIds || []);
if (hopped) {
return;
@@ -7917,6 +8134,7 @@
return;
}
const wantsGen = !suppressGen && !!(opts.userWantsGenerate || state.pendingSilentGen
|| userImpliesGenerate(opts.userText || '')
|| (Array.isArray(effective?.actions) && effective.actions.map(String).includes('generate')));
const willGen = !!(effective && !fromAutoCritique && !suppressGen
&& (wantsGen || $('sa_auto_generate')?.checked));
@@ -8385,7 +8603,7 @@
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) {
state.lastUserParamIntent = userTextMentionsParams(text);
state.lastUserControlIntent = userTextMentionsControls(text);
state.pendingSilentGen = userAsksGenerate(text) || userAsksContinue(text) || isSameButAspectRequest(text);
state.pendingSilentGen = userImpliesGenerate(text);
if (userAsksNoGenerate(text)) {
state.pendingSilentGen = false;
}
@@ -8602,12 +8820,12 @@
const prose = extractPatch(reply).prose || reply;
state.history.push({ role: 'assistant', content: prose, persona, pack });
persistHistory();
setBusyPhase(state.pendingSilentGen || userAsksGenerate(text) || userAsksContinue(text) ? 'silent_gen' : 'thinking');
setBusyPhase(state.pendingSilentGen || userImpliesGenerate(text) ? 'silent_gen' : 'thinking');
try {
await handleReplySideEffects(reply, civitaiResults, {
...opts,
userText: text,
userWantsGenerate: !!state.pendingSilentGen || userAsksGenerate(text) || userAsksContinue(text),
userWantsGenerate: !!state.pendingSilentGen || userImpliesGenerate(text),
attachedSlotIds: visionSlots.map((s) => s.id),
});
} finally {
+3 -3
View File
@@ -6,7 +6,7 @@ window.SA = window.SA || {};
(function () {
const PATCH_KEYS = [
'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
'prompt', 'negative', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
'actions', 'search_query', 'civitai_query',
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
@@ -31,8 +31,8 @@ window.SA = window.SA || {};
return false;
}
const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint);
const genish = !!(obj.prompt != null || obj.loras || obj.actions || obj.width || obj.height
|| obj.steps || obj.cfg || obj.aspect || obj.seed != null
const genish = !!(obj.prompt != null || obj.negative != null || obj.loras || obj.actions
|| obj.width || obj.height || obj.steps || obj.cfg || obj.aspect || obj.seed != null
|| obj.search_query || obj.civitai_query || obj.look_at || obj.controls);
if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
return true;