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:
+229
-45
@@ -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 1–2 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 2‑min 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 1–2 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);
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
␍
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (0–100), 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",
|
||||
|
||||
@@ -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 **Хорни** (0–100, 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
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user