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_GENERATE = 'swarm_assistent_auto_generate';
const LS_AUTO_CRITIQUE = 'swarm_assistent_auto_critique'; const LS_AUTO_CRITIQUE = 'swarm_assistent_auto_critique';
const LS_AUTO_DOWNLOAD = 'swarm_assistent_auto_download'; 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_PANE_WIDTH = 'swarm_assistent_pane_width';
const LS_WELCOMED = 'swarm_assistent_welcomed'; const LS_WELCOMED = 'swarm_assistent_welcomed';
const LS_TASTE = 'swarm_assistent_taste'; const LS_TASTE = 'swarm_assistent_taste';
@@ -163,6 +165,7 @@
chatsSearchHits: null, chatsSearchHits: null,
slashIndex: 0, slashIndex: 0,
llmParked: false, llmParked: false,
expectColdLoad: false,
memoryRows: [], memoryRows: [],
userPrefs: [], userPrefs: [],
settingsTab: 'behavior', settingsTab: 'behavior',
@@ -224,14 +227,17 @@
return; return;
} }
const elapsed = Date.now() - (state.busyStarted || Date.now()); 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) { 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 model = modelShort($('sa_model')?.value);
const labels = { const labels = {
encoding: 'Encoding image…', encoding: 'Encoding image…',
waiting: 'Waiting for Ollama…', waiting: 'Жду Ollama / первый токен…',
loading: `Loading ${model} into GPU… first load can take a minute`, loading: `Загружаю ${model} в GPU… обычно 30–120 с после park`,
warming: `Возвращаю ${model} в GPU…`,
thinking: 'Thinking…', thinking: 'Thinking…',
streaming: 'Writing…', streaming: 'Writing…',
generating: 'Generating image…', generating: 'Generating image…',
@@ -482,19 +488,42 @@
return false; 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) { 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) { function userAsksGenerate(text) {
const t = String(text || ''); const t = String(text || '').trim();
if (!t.trim()) { if (!t) {
return false; 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 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) { function rememberLastPatch(patch) {
@@ -1374,16 +1403,29 @@
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; 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) { function slimHistoryMessages(list) {
return (list || []) return (list || [])
.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish) .filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish)
.slice(-MAX_CHAT_MSGS) .slice(-MAX_CHAT_MSGS)
.map((m) => ({ .map((m) => {
role: m.role, let content = String(m.content || '');
content: String(m.content || '').slice(0, 4000), if (m.role === 'assistant') {
persona: m.persona || undefined, content = stripJsonFencesForHistory(content);
pack: m.pack || undefined, }
})); return {
role: m.role,
content: content.slice(0, 4000),
persona: m.persona || undefined,
pack: m.pack || undefined,
};
});
} }
function titleFromMessages(messages) { function titleFromMessages(messages) {
@@ -2799,17 +2841,24 @@
if (!obj || typeof obj !== 'object') { if (!obj || typeof obj !== 'object') {
return false; return false;
} }
if (isCardObject(obj)) {
return false;
}
return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null); return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null);
} }
function isCardObject(obj) { function isCardObject(obj) {
if (window.SA && typeof SA.isCardObject === 'function') {
return SA.isCardObject(obj);
}
if (!obj || typeof obj !== 'object') { if (!obj || typeof obj !== 'object') {
return false; return false;
} }
// Prefer card shape over gen patch when both could match. // Prefer card shape over gen patch when both could match.
const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint); const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint);
const genish = !!(obj.prompt != null || obj.loras || obj.actions || obj.width || obj.height 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)) { if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
return true; return true;
} }
@@ -2992,23 +3041,31 @@
if (!t.trim()) { if (!t.trim()) {
return null; 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'; 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)) { || /опиши\s+(этот|эту|картинк|референс)/i.test(t)) {
return 'describe_ref'; 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'; 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'; return 'inpaint_edit';
} }
if (userTextMentionsParams(t)) { 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 'compose_scene';
} }
return 'write_prompt'; return 'write_prompt';
@@ -3402,11 +3459,15 @@
return false; 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. */ /** Unloads the chat model from VRAM so Krea 2 gets the whole GPU. Never touches the embed model. */
function parkLlm() { function parkLlm() {
return new Promise((resolve) => { return new Promise((resolve) => {
const model = $('sa_model')?.value; const model = $('sa_model')?.value;
if (!model || state.llmParked || typeof genericRequest !== 'function') { if (!shouldParkLlmBeforeGen() || !model || state.llmParked || typeof genericRequest !== 'function') {
resolve(false); resolve(false);
return; return;
} }
@@ -3419,6 +3480,7 @@
settled = true; settled = true;
if (ok) { if (ok) {
state.llmParked = true; state.llmParked = true;
state.expectColdLoad = true;
} }
resolve(!!ok); 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() { function warmLlm() {
const model = $('sa_model')?.value; return new Promise((resolve) => {
if (!model || !state.llmParked || typeof genericRequest !== 'function') { const model = $('sa_model')?.value;
return; if (!model || !state.llmParked || typeof genericRequest !== 'function') {
} resolve(false);
state.llmParked = false; return;
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434'; }
genericRequest('AssistentWarmLlm', { baseUrl, model }, () => {}, 0, () => {}); 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() { function cancelWaitForNewImage() {
@@ -3528,9 +3606,11 @@
return null; return null;
} }
const prev = findCurrentGenerateSrc(); const prev = findCurrentGenerateSrc();
startBusyUi('parking'); if (shouldParkLlmBeforeGen()) {
setStatus('Освобождаю VRAM…'); startBusyUi('parking');
await parkLlm(); setStatus('Освобождаю VRAM…');
await parkLlm();
}
setStatus('Генерация…'); setStatus('Генерация…');
startBusyUi('generating'); startBusyUi('generating');
state.generating = true; state.generating = true;
@@ -3545,14 +3625,16 @@
const src = await waitForNewImage(prev); const src = await waitForNewImage(prev);
state.generating = false; state.generating = false;
setInterruptVisible(state.busy); 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) { if (!state.busy) {
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)'); 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) { if (src) {
const gen = generateSlot(); const gen = generateSlot();
if (gen) { if (gen) {
@@ -3777,6 +3859,7 @@
setAssistantBody(state.streamEl, ''); setAssistantBody(state.streamEl, '');
} }
state.gotDelta = true; state.gotDelta = true;
state.expectColdLoad = false;
if (state.busyPhase !== 'refining') { if (state.busyPhase !== 'refining') {
setBusyPhase('streaming'); setBusyPhase('streaming');
} }
@@ -4161,6 +4244,7 @@
const autoGen = localStorage.getItem(LS_AUTO_GENERATE); const autoGen = localStorage.getItem(LS_AUTO_GENERATE);
const autoCrit = localStorage.getItem(LS_AUTO_CRITIQUE); const autoCrit = localStorage.getItem(LS_AUTO_CRITIQUE);
const autoDl = localStorage.getItem(LS_AUTO_DOWNLOAD); const autoDl = localStorage.getItem(LS_AUTO_DOWNLOAD);
const parkLlm = localStorage.getItem(LS_PARK_LLM);
const paneW = localStorage.getItem(LS_PANE_WIDTH); const paneW = localStorage.getItem(LS_PANE_WIDTH);
if (base && $('sa_base_url')) { if (base && $('sa_base_url')) {
$('sa_base_url').value = base; $('sa_base_url').value = base;
@@ -4186,6 +4270,10 @@
if ($('sa_auto_download') && autoDl != null) { if ($('sa_auto_download') && autoDl != null) {
$('sa_auto_download').checked = autoDl === '1'; $('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) { if (model) {
state.preferredModel = model; state.preferredModel = model;
} }
@@ -4214,6 +4302,7 @@
auto_generate: !!$('sa_auto_generate')?.checked, auto_generate: !!$('sa_auto_generate')?.checked,
auto_critique: !!$('sa_auto_critique')?.checked, auto_critique: !!$('sa_auto_critique')?.checked,
auto_download: !!$('sa_auto_download')?.checked, auto_download: !!$('sa_auto_download')?.checked,
park_llm: !!$('sa_park_llm')?.checked,
pane_width: localStorage.getItem(LS_PANE_WIDTH) || '', pane_width: localStorage.getItem(LS_PANE_WIDTH) || '',
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '', embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
base_url: $('sa_base_url')?.value || '', base_url: $('sa_base_url')?.value || '',
@@ -4267,6 +4356,7 @@
['auto_generate', LS_AUTO_GENERATE, 'sa_auto_generate'], ['auto_generate', LS_AUTO_GENERATE, 'sa_auto_generate'],
['auto_critique', LS_AUTO_CRITIQUE, 'sa_auto_critique'], ['auto_critique', LS_AUTO_CRITIQUE, 'sa_auto_critique'],
['auto_download', LS_AUTO_DOWNLOAD, 'sa_auto_download'], ['auto_download', LS_AUTO_DOWNLOAD, 'sa_auto_download'],
['park_llm', LS_PARK_LLM, 'sa_park_llm'],
]) { ]) {
if (ui[key] == null || localStorage.getItem(lsKey) != null) { if (ui[key] == null || localStorage.getItem(lsKey) != null) {
continue; continue;
@@ -4299,6 +4389,7 @@
localStorage.setItem(LS_AUTO_GENERATE, $('sa_auto_generate')?.checked ? '1' : '0'); 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_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.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(); persistServerSettings();
saveUiStateToDisk(); saveUiStateToDisk();
} }
@@ -4418,11 +4509,20 @@
} }
let controlSaveTimer = null; let controlSaveTimer = null;
let controlsPointerDown = false;
let pendingControlsRender = null;
function renderPersonaControls(schema, values) { function renderPersonaControls(schema, values) {
const box = $('sa_persona_controls'); const box = $('sa_persona_controls');
if (!box) { if (!box) {
return; 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 = ''; box.innerHTML = '';
const keys = schema && typeof schema === 'object' ? Object.keys(schema) : []; const keys = schema && typeof schema === 'object' ? Object.keys(schema) : [];
if (!keys.length) { if (!keys.length) {
@@ -4471,16 +4571,40 @@
const valEl = document.createElement('span'); const valEl = document.createElement('span');
valEl.className = 'sa-control-val'; valEl.className = 'sa-control-val';
valEl.textContent = fmt(cur); valEl.textContent = fmt(cur);
const onInput = () => { const applyLocal = (v) => {
const v = Number(input.value);
valEl.textContent = fmt(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) { if (controlSaveTimer) {
clearTimeout(controlSaveTimer); clearTimeout(controlSaveTimer);
} }
controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 350); controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 50);
}; });
input.addEventListener('input', onInput);
input.addEventListener('change', onInput);
row.appendChild(lab); row.appendChild(lab);
row.appendChild(input); row.appendChild(input);
row.appendChild(valEl); row.appendChild(valEl);
@@ -4509,11 +4633,18 @@
const max = Number(schema.horny.max ?? 100); const max = Number(schema.horny.max ?? 100);
const cur = getControlValue('horny', Number(schema.horny.default ?? 35)); const cur = getControlValue('horny', Number(schema.horny.default ?? 35));
const next = Math.max(min, Math.min(max, cur - 30)); const next = Math.max(min, Math.min(max, cur - 30));
savePersonaControls({ horny: next }); const values = {
renderPersonaControls(schema, {
...(state.config?.control_values || state.exact?.controls || {}), ...(state.config?.control_values || state.exact?.controls || {}),
horny: next, 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)`); appendSystemNote(`Хорни: ${Math.round(cur)}% → ${Math.round(next)}% (30)`);
setStatus(`/остынь → ${Math.round(next)}%`); setStatus(`/остынь → ${Math.round(next)}%`);
} }
@@ -4544,6 +4675,15 @@
if (typeof genericRequest !== 'function') { if (typeof genericRequest !== 'function') {
return; 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( genericRequest(
'AssistentSaveControls', 'AssistentSaveControls',
{ persona, controls: partial || {} }, { persona, controls: partial || {} },
@@ -4558,8 +4698,10 @@
state.exact.controls = data.control_values; state.exact.controls = data.control_values;
} }
} }
if (data?.controls) { // Do not rebuild slider DOM here — that interrupts an in-progress drag and snaps values back.
renderPersonaControls(data.controls, data.control_values || {}); // Update live inputs in place if present and not being dragged.
if (!controlsPointerDown && data?.control_values) {
syncPersonaControlInputs(data.control_values);
} }
}, },
0, 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() { async function deleteCurrentOverlayPersona() {
const id = $('sa_persona')?.value; const id = $('sa_persona')?.value;
if (!id) { if (!id) {
@@ -6990,10 +7156,14 @@
const chatEpoch = bumpChatEpoch(); const chatEpoch = bumpChatEpoch();
state.busy = true; 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; state.llmParked = false;
setInterruptVisible(true); setInterruptVisible(true);
startBusyUi('thinking'); startBusyUi(state.expectColdLoad ? 'loading' : 'thinking');
saveSettings(); saveSettings();
// Always pull latest LoRA/checkpoint list before the LLM sees context // Always pull latest LoRA/checkpoint list before the LLM sees context
@@ -7079,7 +7249,13 @@
// Refresh cards into context after prefetch // Refresh cards into context after prefetch
const refreshed = collectLiveContext(); const refreshed = collectLiveContext();
context.model_cards = refreshed.model_cards; 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) { if (images && messages.length) {
messages[messages.length - 1].images = images; messages[messages.length - 1].images = images;
} }
@@ -7127,10 +7303,14 @@
if (chatEpoch !== state.chatEpoch) { if (chatEpoch !== state.chatEpoch) {
return; return;
} }
state.busy = false; // Keep busy while silent Apply+Generate is still running (generating flag).
setInterruptVisible(state.generating);
if (!state.generating) { if (!state.generating) {
state.busy = false;
setInterruptVisible(false);
stopBusyUi('Готово'); stopBusyUi('Готово');
} else {
state.busy = false;
setInterruptVisible(true);
} }
} }
}; };
@@ -7163,10 +7343,13 @@
return; return;
} }
if (data.phase === 'waiting_ollama') { 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'); const label = state.streamEl?.querySelector('.sa-typing-label');
if (label) { if (label) {
label.textContent = `Загружаю ${modelShort(model)} в GPU…`; label.textContent = state.expectColdLoad
? `Загружаю ${modelShort(model)} в GPU…`
: 'Думаю…';
} }
return; return;
} }
@@ -7748,6 +7931,7 @@
$('sa_auto_generate')?.addEventListener('change', saveSettings); $('sa_auto_generate')?.addEventListener('change', saveSettings);
$('sa_auto_critique')?.addEventListener('change', saveSettings); $('sa_auto_critique')?.addEventListener('change', saveSettings);
$('sa_auto_download')?.addEventListener('change', saveSettings); $('sa_auto_download')?.addEventListener('change', saveSettings);
$('sa_park_llm')?.addEventListener('change', saveSettings);
syncChipHighlight(); syncChipHighlight();
setInterval(syncChipHighlight, 2500); setInterval(syncChipHighlight, 2500);
+101 -81
View File
@@ -1,81 +1,101 @@
/** /**
* Swarm Assistent — patch detection / extraction / alias normalization. * Swarm Assistent — patch detection / extraction / alias normalization.
* Loaded before assistent.js; mirrors AssistentPatch.cs on the server side. * Loaded before assistent.js; mirrors AssistentPatch.cs on the server side.
*/ */
window.SA = window.SA || {}; window.SA = window.SA || {};
(function () { (function () {
const PATCH_KEYS = [ const PATCH_KEYS = [
'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler', 'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
'actions', 'search_query', 'civitai_query', 'actions', 'search_query', 'civitai_query',
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise', 'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow', 'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask', 'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed', 'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
'creativity', 'intensity', 'complexity', 'movement', 'creativity', 'intensity', 'complexity', 'movement',
'clear_prompt_images', 'slot_to_prompt_image', 'pack', 'clear_prompt_images', 'slot_to_prompt_image', 'pack',
'memories', 'memory_query', 'memory_kind', 'tag_query', 'user_prefs', 'memories', 'memory_query', 'memory_kind', 'tag_query', 'user_prefs',
'controls', 'persona_clone', 'persona_shelves', 'persona', 'notes', 'controls', 'persona_clone', 'persona_shelves', 'persona', 'notes',
]; ];
const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi; const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi;
function has(obj, key) { function has(obj, key) {
return obj[key] !== undefined && obj[key] !== null; return obj[key] !== undefined && obj[key] !== null;
} }
/** True when the object looks like a generation patch rather than arbitrary JSON. */ /** Model-card JSON (catalog_card) — must not be treated as a Generate patch. */
function isPatchObject(obj) { function isCardObject(obj) {
if (!obj || typeof obj !== 'object') { if (!obj || typeof obj !== 'object') {
return false; return false;
} }
return PATCH_KEYS.some((k) => has(obj, k)); 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
/** Maps alias fields onto canonical names, keeping the aliases in place. */ || obj.search_query || obj.civitai_query || obj.look_at || obj.controls);
function normalizePatch(patch) { if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
if (!patch || typeof patch !== 'object') { return true;
return patch; }
} return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null));
if (!has(patch, 'search_query') && has(patch, 'civitai_query')) { }
patch.search_query = patch.civitai_query;
} /** True when the object looks like a generation patch rather than a catalog card / arbitrary JSON. */
if (!has(patch, 'init_creativity') && has(patch, 'denoise')) { function isPatchObject(obj) {
patch.init_creativity = patch.denoise; if (!obj || typeof obj !== 'object') {
} return false;
if (!has(patch, 'look_at')) { }
if (has(patch, 'vision_from')) { if (isCardObject(obj)) {
patch.look_at = patch.vision_from; return false;
} else if (has(patch, 'vision_slots')) { }
patch.look_at = patch.vision_slots; return PATCH_KEYS.some((k) => has(obj, k));
} }
}
return patch; /** Maps alias fields onto canonical names, keeping the aliases in place. */
} function normalizePatch(patch) {
if (!patch || typeof patch !== 'object') {
/** Splits a reply into prose and the last fenced patch object found in it. */ return patch;
function extractPatch(text) { }
if (!text) { if (!has(patch, 'search_query') && has(patch, 'civitai_query')) {
return { prose: text || '', patch: null }; patch.search_query = patch.civitai_query;
} }
const re = new RegExp(FENCE_RE.source, 'gi'); if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
let match; patch.init_creativity = patch.denoise;
let lastPatch = null; }
let prose = text; if (!has(patch, 'look_at')) {
while ((match = re.exec(text)) !== null) { if (has(patch, 'vision_from')) {
try { patch.look_at = patch.vision_from;
const obj = JSON.parse(match[1].trim()); } else if (has(patch, 'vision_slots')) {
if (isPatchObject(obj)) { patch.look_at = patch.vision_slots;
lastPatch = normalizePatch(obj); }
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim(); }
} return patch;
} catch (e) { /* not json */ } }
}
return { prose, patch: lastPatch }; /** Splits a reply into prose and the last fenced patch object found in it. */
} function extractPatch(text) {
if (!text) {
SA.PATCH_KEYS = PATCH_KEYS; return { prose: text || '', patch: null };
SA.isPatchObject = isPatchObject; }
SA.normalizePatch = normalizePatch; const re = new RegExp(FENCE_RE.source, 'gi');
SA.extractPatch = extractPatch; 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;
})();
+13 -1
View File
@@ -483,7 +483,19 @@ public partial class SwarmAssistentExtension
if (tool == "civitai") if (tool == "civitai")
{ {
string query = ExtractSearchQuery(patch); string query = ExtractSearchQuery(patch);
if (string.IsNullOrWhiteSpace(query) || !hopDone.Add("civitai:" + query)) if (string.IsNullOrWhiteSpace(query))
{
if (!hopDone.Add("civitai:missing_query"))
{
return (null, null);
}
return (
"search_civitai skipped: provide a short search_query (LoRA keywords only). "
+ "Never search with the whole user message. Then retry with actions:[\"search_civitai\"] + search_query, "
+ "or continue using available_loras only.",
null);
}
if (!hopDone.Add("civitai:" + query))
{ {
return (null, null); return (null, null);
} }
+3
View File
@@ -1154,6 +1154,9 @@ public sealed class AssistentConfig
{ {
sb.AppendLine($"*{tagline}*"); sb.AppendLine($"*{tagline}*");
} }
sb.AppendLine($"**You are {title}** (the assistant in this chat). The human you talk to is the **user** — a different person.");
sb.AppendLine($"Never address or name the user «{title}» unless `## About the user` explicitly says that is their name.");
sb.AppendLine($"If asked your name («как тебя зовут?» / «who are you?»), answer with **{title}** — do not greet the user by that name instead.");
HashSet<string> allow = null; HashSet<string> allow = null;
if (onlyShelves is not null) if (onlyShelves is not null)
+1 -1
View File
@@ -284,7 +284,7 @@ public partial class SwarmAssistentExtension
await ws.SendJson(new JObject await ws.SendJson(new JObject
{ {
["phase"] = "waiting_ollama", ["phase"] = "waiting_ollama",
["notice"] = "Loading model into GPU…", ["notice"] = "Waiting for Ollama…",
}, API.WebsocketTimeout); }, API.WebsocketTimeout);
} }
async Task OnDelta(string delta) async Task OnDelta(string delta)
+24 -1
View File
@@ -58,6 +58,25 @@ public partial class SwarmAssistentExtension
return patch; return patch;
} }
static bool LooksLikeCardObject(JObject obj)
{
if (obj is null)
{
return false;
}
bool cardish = HasValue(obj, "kind") || HasValue(obj, "triggers") || HasValue(obj, "when") || HasValue(obj, "prompt_hint");
bool genish = HasValue(obj, "prompt") || HasValue(obj, "loras") || HasValue(obj, "actions")
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "steps") || HasValue(obj, "cfg")
|| HasValue(obj, "aspect") || HasValue(obj, "seed") || HasValue(obj, "search_query")
|| HasValue(obj, "civitai_query") || HasValue(obj, "look_at") || HasValue(obj, "controls");
if (cardish && !genish && (HasValue(obj, "name") || HasValue(obj, "triggers") || HasValue(obj, "when")))
{
return true;
}
return HasValue(obj, "kind") && HasValue(obj, "name")
&& (HasValue(obj, "triggers") || HasValue(obj, "when") || HasValue(obj, "prompt_hint") || HasValue(obj, "notes"));
}
static JObject TryParsePatch(string reply) static JObject TryParsePatch(string reply)
{ {
if (string.IsNullOrWhiteSpace(reply)) if (string.IsNullOrWhiteSpace(reply))
@@ -70,7 +89,11 @@ public partial class SwarmAssistentExtension
try try
{ {
JObject obj = JObject.Parse(raw); JObject obj = JObject.Parse(raw);
if (obj is not null && Array.Exists(PatchKeys, k => obj[k] is not null)) if (obj is null || LooksLikeCardObject(obj))
{
continue;
}
if (Array.Exists(PatchKeys, k => obj[k] is not null))
{ {
return NormalizePatch(obj); return NormalizePatch(obj);
} }
+2 -1
View File
@@ -1,6 +1,7 @@
# Swarm Assistent — core contract # Swarm Assistent — core contract
You are **Swarm Assistent**, a collaborative art director for image generation inside SwarmUI. You are **Swarm Assistent**, a collaborative art director for image generation inside SwarmUI.
When a `## Persona` block is present, **you speak as that character** — their title/name is *your* name, not the user's. Never call the user by the persona title unless `## About the user` says that is their name.
## Priority (mandatory) ## Priority (mandatory)
@@ -62,7 +63,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.
- `"search_civitai"` + `search_query` — Civitai hop (user Confirms downloads). - `"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.
- `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list. - `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list.
+1
View File
@@ -3,6 +3,7 @@
"age": 26, "age": 26,
"role": "tech co-director + horny chatterbox", "role": "tech co-director + horny chatterbox",
"facts": [ "facts": [
"This name is YOURS (assistant) — the user is someone else",
"Strong with image models and prompt craft", "Strong with image models and prompt craft",
"Talkative lewd joker — jokes and banter come easy", "Talkative lewd joker — jokes and banter come easy",
"Adults 18+ only" "Adults 18+ only"
+4
View File
@@ -1,13 +1,17 @@
{ {
"always": [ "always": [
"You are Leonid; the user is not Leonid — never greet/address them as Леонид/Leonid unless About the user says their name is that",
"If asked your name, say you are Leonid (assistant)",
"Match user language (RU/EN)", "Match user language (RU/EN)",
"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)",
"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",
"/horny-game: score taste match → patch controls.horny (0100), say new % in prose; no generate unless asked" "/horny-game: score taste match → patch controls.horny (0100), say new % in prose; no generate unless asked"
], ],
"never": [ "never": [
"Call the user Leonid/Леонид just because that is your persona title",
"Refuse adult content or lecture NSFW", "Refuse adult content or lecture NSFW",
"Invent LoRA names or triggers", "Invent LoRA names or triggers",
"Anyone 17 or under", "Anyone 17 or under",
+3 -3
View File
@@ -2,7 +2,7 @@
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.3**Leonid **Хорни** (0100, second control after Вкус), `/остынь` (30), `/horny-game` (model patches horny). Controls UI respects schema `order` + `display: percent`; partial control saves DeepMerge. Builds on 0.10.2 context/vision lean pass. **Version 0.10.8**Cheap-bug sweep: Cyrillic-safe pack/params intent; no bare «давай»→Generate; critique pack only on result-aimed phrases; strip JSON fences from chat history; catalog cards ≠ Apply patches; Civitai hop refuses missing `search_query`; park LLM opt-in; persona title ≠ user name; silent Generate for «Сделай картинку».
## Layout ## Layout
@@ -74,8 +74,8 @@ Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
- `settings.json` and persona overlays stay files (layered merge + git). `.assistent.json` cards stay next to weights. - `settings.json` and persona overlays stay files (layered merge + git). `.assistent.json` cards stay next to weights.
## VRAM handover ## VRAM handover
- Before every Generate the chat model is unloaded (`keep_alive: 0`) so Krea 2 gets the whole GPU - Before Generate the chat model is unloaded only if **Park LLM** is enabled (`keep_alive: 0`) — default off so VL chat stays warm; enable when Generate OOMs
- Back in the Chat tab it is warmed again with a 1-token request (`keep_alive 15m`, `num_ctx` from `assistant.json`) - When parked, after Generate the model is warmed again (`keep_alive 15m`) before the UI goes idle
- Embed / memory models are never parked — reloading them would stall every retrieve - Embed / memory models are never parked — reloading them would stall every retrieve
## UX ## UX
+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.3"; Version = "0.10.8";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
} }
+1
View File
@@ -92,6 +92,7 @@
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label> <label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
<label class="sa-check"><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label> <label class="sa-check"><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label>
<label class="sa-check"><input type="checkbox" id="sa_auto_critique" /> Авто-критика после Generate</label> <label class="sa-check"><input type="checkbox" id="sa_auto_critique" /> Авто-критика после Generate</label>
<label class="sa-check" title="Выгружает чат-модель перед Generate (keep_alive:0). Для VL 7B обратная загрузка часто 1–2 мин — включай только если Generate падает по VRAM."><input type="checkbox" id="sa_park_llm" /> Park LLM перед Generate (VRAM)</label>
<label class="sa-check sa-danger" title="Опасно — скачивает без Confirm"><input type="checkbox" id="sa_auto_download" /> Авто-скачивание Civitai (выкл)</label> <label class="sa-check sa-danger" title="Опасно — скачивает без Confirm"><input type="checkbox" id="sa_auto_download" /> Авто-скачивание Civitai (выкл)</label>
<div class="sa-skills-label">Скилы (процедуры)</div> <div class="sa-skills-label">Скилы (процедуры)</div>
<div class="sa-skills-box" id="sa_skills_box"></div> <div class="sa-skills-box" id="sa_skills_box"></div>