Ship Assistent 0.10.8 cheap-bug sweep for RU intent, VRAM park, and identity.

Silent Generate, pack selection, and loading UI no longer trip on Cyrillic or fake GPU loads; persona title is the assistant, not the user.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 03:22:01 +03:00
co-authored by Cursor
parent 3e0912b732
commit 6e7272ffc9
12 changed files with 394 additions and 145 deletions
+240 -56
View File
@@ -14,6 +14,8 @@
const LS_AUTO_GENERATE = 'swarm_assistent_auto_generate';
const LS_AUTO_CRITIQUE = 'swarm_assistent_auto_critique';
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';
const LS_PANE_WIDTH = 'swarm_assistent_pane_width';
const LS_WELCOMED = 'swarm_assistent_welcomed';
const LS_TASTE = 'swarm_assistent_taste';
@@ -163,6 +165,7 @@
chatsSearchHits: null,
slashIndex: 0,
llmParked: false,
expectColdLoad: false,
memoryRows: [],
userPrefs: [],
settingsTab: 'behavior',
@@ -224,14 +227,17 @@
return;
}
const elapsed = Date.now() - (state.busyStarted || Date.now());
// Only claim "Loading into GPU" when the model was parked / known cold.
// Otherwise a slow first token on an already-resident VL looks like a 2min reload.
if (!state.gotDelta && (state.busyPhase === 'thinking' || state.busyPhase === 'waiting') && elapsed > 1600) {
state.busyPhase = 'loading';
state.busyPhase = state.llmParked || state.expectColdLoad ? 'loading' : 'waiting';
}
const model = modelShort($('sa_model')?.value);
const labels = {
encoding: 'Encoding image…',
waiting: 'Waiting for Ollama…',
loading: `Loading ${model} into GPU… first load can take a minute`,
waiting: 'Жду Ollama / первый токен…',
loading: `Загружаю ${model} в GPU… обычно 30–120 с после park`,
warming: `Возвращаю ${model} в GPU…`,
thinking: 'Thinking…',
streaming: 'Writing…',
generating: 'Generating image…',
@@ -482,19 +488,42 @@
return false;
}
/** JS \\b/\\w are ASCII-only — use this for RU tokens. `alts` = regex alternatives without outer parens. */
function cyrTokenRe(alts) {
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
const end = '(?=$|[^0-9A-Za-z_А-Яа-яЁё])';
return new RegExp(`${boundary}(?:${alts})${end}`, 'i');
}
function userTextMentionsParams(text) {
return /\b(steps?|cfg|seed|sigma|размер|aspect|ширин|высот|resolution|batch|турбо|turbo|raw)\b/i.test(String(text || ''));
const t = String(text || '');
if (/\b(steps?|cfg|seed|sigma|aspect|resolution|batch|turbo|raw)\b/i.test(t)) {
return true;
}
return cyrTokenRe('размер|ширин[а-яё]*|высот[а-яё]*|турбо').test(t);
}
function userAsksGenerate(text) {
const t = String(text || '');
if (!t.trim()) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/^(gen|generate|go|рисуй|давай|ещё|еще)\s*[!.…]*$/i.test(t.trim())) {
// Short imperatives only — do NOT treat bare «давай» as Generate (false positive on chat).
if (/^(gen|generate|go|рисуй|нарисуй)([!.…\s]|$)/i.test(t)) {
return true;
}
return /\b(сгенерируй|сгенерировать|генерируй|generate|нарисуй|перегенерируй|перерисуй|сделай\s+(картинк|изображен|фото)|run\s+generat|\/gen)\b/i.test(t);
if (/^(ещё|еще)(\s+раз)?([!.…\s]|$)/i.test(t)) {
return true;
}
// Do NOT use \b or \w — ASCII-only in JS; breaks «сделай картинку».
const letter = '[0-9A-Za-z_А-Яа-яЁё]';
const stem = `${letter}*`;
return cyrTokenRe(
'сгенерируй|сгенерировать|генерируй|generate|нарисуй|перегенерируй|перерисуй|'
+ `сделай\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
+ `хочу\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
+ 'run\\s+generat|/gen',
).test(t);
}
function rememberLastPatch(patch) {
@@ -1374,16 +1403,29 @@
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function stripJsonFencesForHistory(content) {
return String(content || '')
.replace(/```(?:json)?\s*[\s\S]*?```/gi, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function slimHistoryMessages(list) {
return (list || [])
.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish)
.slice(-MAX_CHAT_MSGS)
.map((m) => ({
role: m.role,
content: String(m.content || '').slice(0, 4000),
persona: m.persona || undefined,
pack: m.pack || undefined,
}));
.map((m) => {
let content = String(m.content || '');
if (m.role === 'assistant') {
content = stripJsonFencesForHistory(content);
}
return {
role: m.role,
content: content.slice(0, 4000),
persona: m.persona || undefined,
pack: m.pack || undefined,
};
});
}
function titleFromMessages(messages) {
@@ -2799,17 +2841,24 @@
if (!obj || typeof obj !== 'object') {
return false;
}
if (isCardObject(obj)) {
return false;
}
return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null);
}
function isCardObject(obj) {
if (window.SA && typeof SA.isCardObject === 'function') {
return SA.isCardObject(obj);
}
if (!obj || typeof obj !== 'object') {
return false;
}
// Prefer card shape over gen patch when both could match.
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);
|| 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;
}
@@ -2992,23 +3041,31 @@
if (!t.trim()) {
return null;
}
if (/\b(поправь|исправь|перепиши|улучши|fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) {
if (cyrTokenRe('поправь|исправь|перепиши|улучши').test(t)
|| /\b(fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) {
return 'write_prompt';
}
if (/\b(опиши\s+реф|опиши\s+изображ|prompt\s+from\s+image|describe\s+(this|the|ref|image)|reverse\s*prompt)\b/i.test(t)
if (cyrTokenRe('опиши\\s+(реф|изображ[а-яё]*|этот|эту|картинк[а-яё]*|референс)').test(t)
|| /\b(prompt\s+from\s+image|describe\s+(this|the|ref|image)|reverse\s*prompt)\b/i.test(t)
|| /опиши\s+(этот|эту|картинк|референс)/i.test(t)) {
return 'describe_ref';
}
if (/\b(critique|что\s+не\s+так|посмотри|смотри|разбери|критик)\b/i.test(t)) {
// Bare «посмотри/смотри» is casual chat — only critique when aimed at a result/frame.
if (/\b(critique|criticize)\b/i.test(t)
|| cyrTokenRe('критик[а-яё]*|что\\s+не\\s+так|разбери').test(t)
|| /(?:^|[^а-яёa-z0-9_])(посмотри|смотри)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген)/i.test(t)) {
return 'critique_image';
}
if (/\b(inpaint|замажь|закрась|руки|лицо|маск|mask|img2img|init\s*image)\b/i.test(t)) {
if (/\b(inpaint|mask|img2img)\b/i.test(t)
|| cyrTokenRe('замажь|закрась|руки|лицо|маск[а-яё]*').test(t)
|| /init\s*image/i.test(t)) {
return 'inpaint_edit';
}
if (userTextMentionsParams(t)) {
return 'fix_params';
return 'form_params';
}
if (/\b(сцен|moodboard|атмосфер|compose|scene|мизансцен)\b/i.test(t)) {
if (/\b(moodboard|compose|scene)\b/i.test(t)
|| cyrTokenRe('сцен[а-яё]*|атмосфер[а-яё]*|мизансцен[а-яё]*').test(t)) {
return 'compose_scene';
}
return 'write_prompt';
@@ -3402,11 +3459,15 @@
return false;
}
function shouldParkLlmBeforeGen() {
return !!$('sa_park_llm')?.checked;
}
/** Unloads the chat model from VRAM so Krea 2 gets the whole GPU. Never touches the embed model. */
function parkLlm() {
return new Promise((resolve) => {
const model = $('sa_model')?.value;
if (!model || state.llmParked || typeof genericRequest !== 'function') {
if (!shouldParkLlmBeforeGen() || !model || state.llmParked || typeof genericRequest !== 'function') {
resolve(false);
return;
}
@@ -3419,6 +3480,7 @@
settled = true;
if (ok) {
state.llmParked = true;
state.expectColdLoad = true;
}
resolve(!!ok);
};
@@ -3427,15 +3489,31 @@
});
}
/** Fire-and-forget re-load of the chat model once the user is back in the chat. */
/** Re-load chat model after park. Returns a Promise (await after Generate so the next Send is warm). */
function warmLlm() {
const model = $('sa_model')?.value;
if (!model || !state.llmParked || typeof genericRequest !== 'function') {
return;
}
state.llmParked = false;
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
genericRequest('AssistentWarmLlm', { baseUrl, model }, () => {}, 0, () => {});
return new Promise((resolve) => {
const model = $('sa_model')?.value;
if (!model || !state.llmParked || typeof genericRequest !== 'function') {
resolve(false);
return;
}
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
let settled = false;
const finish = (ok) => {
if (settled) {
return;
}
settled = true;
state.llmParked = false;
if (ok) {
state.expectColdLoad = false;
}
resolve(!!ok);
};
// VL 7B cold-load can exceed a minute — don't time out the flag early.
setTimeout(() => finish(false), 180000);
genericRequest('AssistentWarmLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
});
}
function cancelWaitForNewImage() {
@@ -3528,9 +3606,11 @@
return null;
}
const prev = findCurrentGenerateSrc();
startBusyUi('parking');
setStatus('Освобождаю VRAM…');
await parkLlm();
if (shouldParkLlmBeforeGen()) {
startBusyUi('parking');
setStatus('Освобождаю VRAM…');
await parkLlm();
}
setStatus('Генерация…');
startBusyUi('generating');
state.generating = true;
@@ -3545,14 +3625,16 @@
const src = await waitForNewImage(prev);
state.generating = false;
setInterruptVisible(state.busy);
// Auto-critique / next Send load the model themselves — skip warm if critique will run.
const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent;
if (state.llmParked && state.view === 'chat' && paneVisible && !$('sa_auto_critique')?.checked) {
startBusyUi('warming');
setStatus('Возвращаю LLM в GPU…');
await warmLlm();
}
if (!state.busy) {
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
}
// Auto-critique loads the model itself on the next request — don't pay for it twice.
const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent;
if (state.view === 'chat' && paneVisible && !$('sa_auto_critique')?.checked) {
warmLlm();
}
if (src) {
const gen = generateSlot();
if (gen) {
@@ -3777,6 +3859,7 @@
setAssistantBody(state.streamEl, '');
}
state.gotDelta = true;
state.expectColdLoad = false;
if (state.busyPhase !== 'refining') {
setBusyPhase('streaming');
}
@@ -4161,6 +4244,7 @@
const autoGen = localStorage.getItem(LS_AUTO_GENERATE);
const autoCrit = localStorage.getItem(LS_AUTO_CRITIQUE);
const autoDl = localStorage.getItem(LS_AUTO_DOWNLOAD);
const parkLlm = localStorage.getItem(LS_PARK_LLM);
const paneW = localStorage.getItem(LS_PANE_WIDTH);
if (base && $('sa_base_url')) {
$('sa_base_url').value = base;
@@ -4186,6 +4270,10 @@
if ($('sa_auto_download') && autoDl != null) {
$('sa_auto_download').checked = autoDl === '1';
}
// Default OFF — parking a VL 7B before every Generate caused 12 min reloads.
if ($('sa_park_llm')) {
$('sa_park_llm').checked = parkLlm === '1';
}
if (model) {
state.preferredModel = model;
}
@@ -4214,6 +4302,7 @@
auto_generate: !!$('sa_auto_generate')?.checked,
auto_critique: !!$('sa_auto_critique')?.checked,
auto_download: !!$('sa_auto_download')?.checked,
park_llm: !!$('sa_park_llm')?.checked,
pane_width: localStorage.getItem(LS_PANE_WIDTH) || '',
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
base_url: $('sa_base_url')?.value || '',
@@ -4267,6 +4356,7 @@
['auto_generate', LS_AUTO_GENERATE, 'sa_auto_generate'],
['auto_critique', LS_AUTO_CRITIQUE, 'sa_auto_critique'],
['auto_download', LS_AUTO_DOWNLOAD, 'sa_auto_download'],
['park_llm', LS_PARK_LLM, 'sa_park_llm'],
]) {
if (ui[key] == null || localStorage.getItem(lsKey) != null) {
continue;
@@ -4299,6 +4389,7 @@
localStorage.setItem(LS_AUTO_GENERATE, $('sa_auto_generate')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
localStorage.setItem(LS_PARK_LLM, $('sa_park_llm')?.checked ? '1' : '0');
persistServerSettings();
saveUiStateToDisk();
}
@@ -4418,11 +4509,20 @@
}
let controlSaveTimer = null;
let controlsPointerDown = false;
let pendingControlsRender = null;
function renderPersonaControls(schema, values) {
const box = $('sa_persona_controls');
if (!box) {
return;
}
// Don't rebuild DOM while the user is dragging — that snaps the thumb back.
if (controlsPointerDown) {
pendingControlsRender = { schema, values };
return;
}
pendingControlsRender = null;
box.innerHTML = '';
const keys = schema && typeof schema === 'object' ? Object.keys(schema) : [];
if (!keys.length) {
@@ -4471,16 +4571,40 @@
const valEl = document.createElement('span');
valEl.className = 'sa-control-val';
valEl.textContent = fmt(cur);
const onInput = () => {
const v = Number(input.value);
const applyLocal = (v) => {
valEl.textContent = fmt(v);
if (state.config) {
state.config.control_values = { ...(state.config.control_values || {}), [id]: v };
}
if (state.exact) {
state.exact.controls = { ...(state.exact.controls || {}), [id]: v };
}
};
input.addEventListener('pointerdown', () => {
controlsPointerDown = true;
});
const endPointer = () => {
controlsPointerDown = false;
if (pendingControlsRender) {
const pending = pendingControlsRender;
pendingControlsRender = null;
renderPersonaControls(pending.schema, pending.values);
}
};
input.addEventListener('pointerup', endPointer);
input.addEventListener('pointercancel', endPointer);
// Live label while dragging; persist only on release (change) so mid-drag saves cannot snap back.
input.addEventListener('input', () => {
applyLocal(Number(input.value));
});
input.addEventListener('change', () => {
const v = Number(input.value);
applyLocal(v);
if (controlSaveTimer) {
clearTimeout(controlSaveTimer);
}
controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 350);
};
input.addEventListener('input', onInput);
input.addEventListener('change', onInput);
controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 50);
});
row.appendChild(lab);
row.appendChild(input);
row.appendChild(valEl);
@@ -4509,11 +4633,18 @@
const max = Number(schema.horny.max ?? 100);
const cur = getControlValue('horny', Number(schema.horny.default ?? 35));
const next = Math.max(min, Math.min(max, cur - 30));
savePersonaControls({ horny: next });
renderPersonaControls(schema, {
const values = {
...(state.config?.control_values || state.exact?.controls || {}),
horny: next,
});
};
if (state.config) {
state.config.control_values = values;
}
if (state.exact) {
state.exact.controls = values;
}
renderPersonaControls(schema, values);
savePersonaControls({ horny: next });
appendSystemNote(`Хорни: ${Math.round(cur)}% → ${Math.round(next)}% (30)`);
setStatus(`/остынь → ${Math.round(next)}%`);
}
@@ -4544,6 +4675,15 @@
if (typeof genericRequest !== 'function') {
return;
}
// Optimistic local merge so UI / next chat see the new values immediately.
if (partial && typeof partial === 'object') {
if (state.config) {
state.config.control_values = { ...(state.config.control_values || {}), ...partial };
}
if (state.exact) {
state.exact.controls = { ...(state.exact.controls || {}), ...partial };
}
}
genericRequest(
'AssistentSaveControls',
{ persona, controls: partial || {} },
@@ -4558,8 +4698,10 @@
state.exact.controls = data.control_values;
}
}
if (data?.controls) {
renderPersonaControls(data.controls, data.control_values || {});
// Do not rebuild slider DOM here — that interrupts an in-progress drag and snaps values back.
// Update live inputs in place if present and not being dragged.
if (!controlsPointerDown && data?.control_values) {
syncPersonaControlInputs(data.control_values);
}
},
0,
@@ -4567,6 +4709,30 @@
);
}
function syncPersonaControlInputs(values) {
const box = $('sa_persona_controls');
if (!box || !values || typeof values !== 'object') {
return;
}
box.querySelectorAll('input[data-control-id]').forEach((input) => {
const id = input.dataset.controlId;
if (values[id] == null) {
return;
}
const v = Number(values[id]);
if (!Number.isFinite(v) || input.value === String(v)) {
return;
}
input.value = String(v);
const valEl = input.parentElement?.querySelector('.sa-control-val');
if (valEl) {
const schema = state.config?.controls?.[id];
const asPercent = String(schema?.display || '').toLowerCase() === 'percent';
valEl.textContent = asPercent ? `${Math.round(v)}%` : Number(v).toFixed(2);
}
});
}
async function deleteCurrentOverlayPersona() {
const id = $('sa_persona')?.value;
if (!id) {
@@ -6990,10 +7156,14 @@
const chatEpoch = bumpChatEpoch();
state.busy = true;
// Ollama reloads the model for this request (keep_alive 15m), so it is no longer parked.
// This request will keep_alive 15m; clear parked only after we know load started.
// Keep expectColdLoad so the UI can show a real GPU-load message if we just parked.
if (!state.llmParked) {
state.expectColdLoad = false;
}
state.llmParked = false;
setInterruptVisible(true);
startBusyUi('thinking');
startBusyUi(state.expectColdLoad ? 'loading' : 'thinking');
saveSettings();
// Always pull latest LoRA/checkpoint list before the LLM sees context
@@ -7079,7 +7249,13 @@
// Refresh cards into context after prefetch
const refreshed = collectLiveContext();
context.model_cards = refreshed.model_cards;
const messages = state.history.slice(-historyMessageLimit()).map((m) => ({ role: m.role, content: m.content }));
const messages = state.history.slice(-historyMessageLimit()).map((m) => {
let content = String(m.content || '');
if (m.role === 'assistant') {
content = stripJsonFencesForHistory(content);
}
return { role: m.role, content: content.slice(0, 4000) };
});
if (images && messages.length) {
messages[messages.length - 1].images = images;
}
@@ -7127,10 +7303,14 @@
if (chatEpoch !== state.chatEpoch) {
return;
}
state.busy = false;
setInterruptVisible(state.generating);
// Keep busy while silent Apply+Generate is still running (generating flag).
if (!state.generating) {
state.busy = false;
setInterruptVisible(false);
stopBusyUi('Готово');
} else {
state.busy = false;
setInterruptVisible(true);
}
}
};
@@ -7163,10 +7343,13 @@
return;
}
if (data.phase === 'waiting_ollama') {
setBusyPhase('loading');
// Server always emits this before /api/chat — not proof of a cold load.
setBusyPhase(state.expectColdLoad ? 'loading' : 'waiting');
const label = state.streamEl?.querySelector('.sa-typing-label');
if (label) {
label.textContent = `Загружаю ${modelShort(model)} в GPU…`;
label.textContent = state.expectColdLoad
? `Загружаю ${modelShort(model)} в GPU…`
: 'Думаю…';
}
return;
}
@@ -7748,6 +7931,7 @@
$('sa_auto_generate')?.addEventListener('change', saveSettings);
$('sa_auto_critique')?.addEventListener('change', saveSettings);
$('sa_auto_download')?.addEventListener('change', saveSettings);
$('sa_park_llm')?.addEventListener('change', saveSettings);
syncChipHighlight();
setInterval(syncChipHighlight, 2500);
+101 -81
View File
@@ -1,81 +1,101 @@
/**
* Swarm Assistent — patch detection / extraction / alias normalization.
* Loaded before assistent.js; mirrors AssistentPatch.cs on the server side.
*/
window.SA = window.SA || {};
(function () {
const PATCH_KEYS = [
'prompt', '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',
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
'creativity', 'intensity', 'complexity', 'movement',
'clear_prompt_images', 'slot_to_prompt_image', 'pack',
'memories', 'memory_query', 'memory_kind', 'tag_query', 'user_prefs',
'controls', 'persona_clone', 'persona_shelves', 'persona', 'notes',
];
const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi;
function has(obj, key) {
return obj[key] !== undefined && obj[key] !== null;
}
/** True when the object looks like a generation patch rather than arbitrary JSON. */
function isPatchObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
return PATCH_KEYS.some((k) => has(obj, k));
}
/** Maps alias fields onto canonical names, keeping the aliases in place. */
function normalizePatch(patch) {
if (!patch || typeof patch !== 'object') {
return patch;
}
if (!has(patch, 'search_query') && has(patch, 'civitai_query')) {
patch.search_query = patch.civitai_query;
}
if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
patch.init_creativity = patch.denoise;
}
if (!has(patch, 'look_at')) {
if (has(patch, 'vision_from')) {
patch.look_at = patch.vision_from;
} else if (has(patch, 'vision_slots')) {
patch.look_at = patch.vision_slots;
}
}
return patch;
}
/** Splits a reply into prose and the last fenced patch object found in it. */
function extractPatch(text) {
if (!text) {
return { prose: text || '', patch: null };
}
const re = new RegExp(FENCE_RE.source, 'gi');
let match;
let lastPatch = null;
let prose = text;
while ((match = re.exec(text)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (isPatchObject(obj)) {
lastPatch = normalizePatch(obj);
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
}
} catch (e) { /* not json */ }
}
return { prose, patch: lastPatch };
}
SA.PATCH_KEYS = PATCH_KEYS;
SA.isPatchObject = isPatchObject;
SA.normalizePatch = normalizePatch;
SA.extractPatch = extractPatch;
})();
/**
* Swarm Assistent — patch detection / extraction / alias normalization.
* Loaded before assistent.js; mirrors AssistentPatch.cs on the server side.
*/
window.SA = window.SA || {};
(function () {
const PATCH_KEYS = [
'prompt', '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',
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
'creativity', 'intensity', 'complexity', 'movement',
'clear_prompt_images', 'slot_to_prompt_image', 'pack',
'memories', 'memory_query', 'memory_kind', 'tag_query', 'user_prefs',
'controls', 'persona_clone', 'persona_shelves', 'persona', 'notes',
];
const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi;
function has(obj, key) {
return obj[key] !== undefined && obj[key] !== null;
}
/** Model-card JSON (catalog_card) — must not be treated as a Generate patch. */
function isCardObject(obj) {
if (!obj || typeof obj !== 'object') {
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
|| obj.search_query || obj.civitai_query || obj.look_at || obj.controls);
if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
return true;
}
return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null));
}
/** True when the object looks like a generation patch rather than a catalog card / arbitrary JSON. */
function isPatchObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
if (isCardObject(obj)) {
return false;
}
return PATCH_KEYS.some((k) => has(obj, k));
}
/** Maps alias fields onto canonical names, keeping the aliases in place. */
function normalizePatch(patch) {
if (!patch || typeof patch !== 'object') {
return patch;
}
if (!has(patch, 'search_query') && has(patch, 'civitai_query')) {
patch.search_query = patch.civitai_query;
}
if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
patch.init_creativity = patch.denoise;
}
if (!has(patch, 'look_at')) {
if (has(patch, 'vision_from')) {
patch.look_at = patch.vision_from;
} else if (has(patch, 'vision_slots')) {
patch.look_at = patch.vision_slots;
}
}
return patch;
}
/** Splits a reply into prose and the last fenced patch object found in it. */
function extractPatch(text) {
if (!text) {
return { prose: text || '', patch: null };
}
const re = new RegExp(FENCE_RE.source, 'gi');
let match;
let lastPatch = null;
let prose = text;
while ((match = re.exec(text)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (isPatchObject(obj)) {
lastPatch = normalizePatch(obj);
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
}
} catch (e) { /* not json */ }
}
return { prose, patch: lastPatch };
}
SA.PATCH_KEYS = PATCH_KEYS;
SA.isCardObject = isCardObject;
SA.isPatchObject = isPatchObject;
SA.normalizePatch = normalizePatch;
SA.extractPatch = extractPatch;
})();