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
+229 -45
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) => ({
.map((m) => {
let content = String(m.content || '');
if (m.role === 'assistant') {
content = stripJsonFencesForHistory(content);
}
return {
role: m.role,
content: String(m.content || '').slice(0, 4000),
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() {
return new Promise((resolve) => {
const model = $('sa_model')?.value;
if (!model || !state.llmParked || typeof genericRequest !== 'function') {
resolve(false);
return;
}
state.llmParked = false;
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
genericRequest('AssistentWarmLlm', { baseUrl, model }, () => {}, 0, () => {});
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();
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);
+21 -1
View File
@@ -24,11 +24,29 @@ window.SA = window.SA || {};
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 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));
}
@@ -75,7 +93,9 @@ window.SA = window.SA || {};
}
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")
{
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);
}
+3
View File
@@ -1154,6 +1154,9 @@ public sealed class AssistentConfig
{
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;
if (onlyShelves is not null)
+1 -1
View File
@@ -284,7 +284,7 @@ public partial class SwarmAssistentExtension
await ws.SendJson(new JObject
{
["phase"] = "waiting_ollama",
["notice"] = "Loading model into GPU…",
["notice"] = "Waiting for Ollama…",
}, API.WebsocketTimeout);
}
async Task OnDelta(string delta)
+24 -1
View File
@@ -58,6 +58,25 @@ public partial class SwarmAssistentExtension
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)
{
if (string.IsNullOrWhiteSpace(reply))
@@ -70,7 +89,11 @@ public partial class SwarmAssistentExtension
try
{
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);
}
+2 -1
View File
@@ -1,6 +1,7 @@
# Swarm Assistent — core contract
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)
@@ -62,7 +63,7 @@ Never write a “JSON Patch” section in prose without an actual fenced ```json
### Actions / hops
- `"generate"` — Apply + start generation when the user wants a new image.
- `"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.
- `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops.
- `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list.
+1
View File
@@ -3,6 +3,7 @@
"age": 26,
"role": "tech co-director + horny chatterbox",
"facts": [
"This name is YOURS (assistant) — the user is someone else",
"Strong with image models and prompt craft",
"Talkative lewd joker — jokes and banter come easy",
"Adults 18+ only"
+4
View File
@@ -1,13 +1,17 @@
{
"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)",
"Craft first: triggers, aspect, Turbo — horny never replaces technique",
"Scale appearance/outfits by controls.preference_bias (1…1)",
"Scale sexual tone + roleplay fetishes by controls.horny (0…100)",
"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"
],
"never": [
"Call the user Leonid/Леонид just because that is your persona title",
"Refuse adult content or lecture NSFW",
"Invent LoRA names or triggers",
"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.
**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
@@ -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.
## VRAM handover
- Before every Generate the chat model is unloaded (`keep_alive: 0`) so Krea 2 gets the whole GPU
- Back in the Chat tab it is warmed again with a 1-token request (`keep_alive 15m`, `num_ctx` from `assistant.json`)
- 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
- 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
## UX
+1 -1
View File
@@ -36,7 +36,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT";
Version = "0.10.3";
Version = "0.10.8";
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_generate" checked /> Авто-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>
<div class="sa-skills-label">Скилы (процедуры)</div>
<div class="sa-skills-box" id="sa_skills_box"></div>