diff --git a/Assets/assistent.js b/Assets/assistent.js index 917d2ba..1d213f6 100644 --- a/Assets/assistent.js +++ b/Assets/assistent.js @@ -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) => ({ - role: m.role, - content: String(m.content || '').slice(0, 4000), - persona: m.persona || undefined, - pack: m.pack || undefined, - })); + .map((m) => { + let content = String(m.content || ''); + if (m.role === 'assistant') { + content = stripJsonFencesForHistory(content); + } + return { + role: m.role, + content: content.slice(0, 4000), + persona: m.persona || undefined, + pack: m.pack || undefined, + }; + }); } function titleFromMessages(messages) { @@ -2799,17 +2841,24 @@ if (!obj || typeof obj !== 'object') { return false; } + if (isCardObject(obj)) { + return false; + } return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null); } function isCardObject(obj) { + if (window.SA && typeof SA.isCardObject === 'function') { + return SA.isCardObject(obj); + } if (!obj || typeof obj !== 'object') { return false; } // Prefer card shape over gen patch when both could match. const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint); const genish = !!(obj.prompt != null || obj.loras || obj.actions || obj.width || obj.height - || obj.steps || obj.cfg || obj.aspect || obj.seed != null); + || obj.steps || obj.cfg || obj.aspect || obj.seed != null + || obj.search_query || obj.civitai_query || obj.look_at || obj.controls); if (cardish && !genish && (obj.name || obj.triggers || obj.when)) { return true; } @@ -2992,23 +3041,31 @@ if (!t.trim()) { return null; } - if (/\b(поправь|исправь|перепиши|улучши|fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) { + if (cyrTokenRe('поправь|исправь|перепиши|улучши').test(t) + || /\b(fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) { return 'write_prompt'; } - if (/\b(опиши\s+реф|опиши\s+изображ|prompt\s+from\s+image|describe\s+(this|the|ref|image)|reverse\s*prompt)\b/i.test(t) + if (cyrTokenRe('опиши\\s+(реф|изображ[а-яё]*|этот|эту|картинк[а-яё]*|референс)').test(t) + || /\b(prompt\s+from\s+image|describe\s+(this|the|ref|image)|reverse\s*prompt)\b/i.test(t) || /опиши\s+(этот|эту|картинк|референс)/i.test(t)) { return 'describe_ref'; } - if (/\b(critique|что\s+не\s+так|посмотри|смотри|разбери|критик)\b/i.test(t)) { + // Bare «посмотри/смотри» is casual chat — only critique when aimed at a result/frame. + if (/\b(critique|criticize)\b/i.test(t) + || cyrTokenRe('критик[а-яё]*|что\\s+не\\s+так|разбери').test(t) + || /(?:^|[^а-яёa-z0-9_])(посмотри|смотри)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген)/i.test(t)) { return 'critique_image'; } - if (/\b(inpaint|замажь|закрась|руки|лицо|маск|mask|img2img|init\s*image)\b/i.test(t)) { + if (/\b(inpaint|mask|img2img)\b/i.test(t) + || cyrTokenRe('замажь|закрась|руки|лицо|маск[а-яё]*').test(t) + || /init\s*image/i.test(t)) { return 'inpaint_edit'; } if (userTextMentionsParams(t)) { - return 'fix_params'; + return 'form_params'; } - if (/\b(сцен|moodboard|атмосфер|compose|scene|мизансцен)\b/i.test(t)) { + if (/\b(moodboard|compose|scene)\b/i.test(t) + || cyrTokenRe('сцен[а-яё]*|атмосфер[а-яё]*|мизансцен[а-яё]*').test(t)) { return 'compose_scene'; } return 'write_prompt'; @@ -3402,11 +3459,15 @@ return false; } + function shouldParkLlmBeforeGen() { + return !!$('sa_park_llm')?.checked; + } + /** Unloads the chat model from VRAM so Krea 2 gets the whole GPU. Never touches the embed model. */ function parkLlm() { return new Promise((resolve) => { const model = $('sa_model')?.value; - if (!model || state.llmParked || typeof genericRequest !== 'function') { + if (!shouldParkLlmBeforeGen() || !model || state.llmParked || typeof genericRequest !== 'function') { resolve(false); return; } @@ -3419,6 +3480,7 @@ settled = true; if (ok) { state.llmParked = true; + state.expectColdLoad = true; } resolve(!!ok); }; @@ -3427,15 +3489,31 @@ }); } - /** Fire-and-forget re-load of the chat model once the user is back in the chat. */ + /** Re-load chat model after park. Returns a Promise (await after Generate so the next Send is warm). */ function warmLlm() { - const model = $('sa_model')?.value; - if (!model || !state.llmParked || typeof genericRequest !== 'function') { - return; - } - state.llmParked = false; - const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434'; - genericRequest('AssistentWarmLlm', { baseUrl, model }, () => {}, 0, () => {}); + return new Promise((resolve) => { + const model = $('sa_model')?.value; + if (!model || !state.llmParked || typeof genericRequest !== 'function') { + resolve(false); + return; + } + const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434'; + let settled = false; + const finish = (ok) => { + if (settled) { + return; + } + settled = true; + state.llmParked = false; + if (ok) { + state.expectColdLoad = false; + } + resolve(!!ok); + }; + // VL 7B cold-load can exceed a minute — don't time out the flag early. + setTimeout(() => finish(false), 180000); + genericRequest('AssistentWarmLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false)); + }); } function cancelWaitForNewImage() { @@ -3528,9 +3606,11 @@ return null; } const prev = findCurrentGenerateSrc(); - startBusyUi('parking'); - setStatus('Освобождаю VRAM…'); - await parkLlm(); + if (shouldParkLlmBeforeGen()) { + startBusyUi('parking'); + setStatus('Освобождаю VRAM…'); + await parkLlm(); + } setStatus('Генерация…'); startBusyUi('generating'); state.generating = true; @@ -3545,14 +3625,16 @@ const src = await waitForNewImage(prev); state.generating = false; setInterruptVisible(state.busy); + // Auto-critique / next Send load the model themselves — skip warm if critique will run. + const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent; + if (state.llmParked && state.view === 'chat' && paneVisible && !$('sa_auto_critique')?.checked) { + startBusyUi('warming'); + setStatus('Возвращаю LLM в GPU…'); + await warmLlm(); + } if (!state.busy) { stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)'); } - // Auto-critique loads the model itself on the next request — don't pay for it twice. - const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent; - if (state.view === 'chat' && paneVisible && !$('sa_auto_critique')?.checked) { - warmLlm(); - } if (src) { const gen = generateSlot(); if (gen) { @@ -3777,6 +3859,7 @@ setAssistantBody(state.streamEl, ''); } state.gotDelta = true; + state.expectColdLoad = false; if (state.busyPhase !== 'refining') { setBusyPhase('streaming'); } @@ -4161,6 +4244,7 @@ const autoGen = localStorage.getItem(LS_AUTO_GENERATE); const autoCrit = localStorage.getItem(LS_AUTO_CRITIQUE); const autoDl = localStorage.getItem(LS_AUTO_DOWNLOAD); + const parkLlm = localStorage.getItem(LS_PARK_LLM); const paneW = localStorage.getItem(LS_PANE_WIDTH); if (base && $('sa_base_url')) { $('sa_base_url').value = base; @@ -4186,6 +4270,10 @@ if ($('sa_auto_download') && autoDl != null) { $('sa_auto_download').checked = autoDl === '1'; } + // Default OFF — parking a VL 7B before every Generate caused 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); diff --git a/Assets/assistent.patch.js b/Assets/assistent.patch.js index 765907b..9762934 100644 --- a/Assets/assistent.patch.js +++ b/Assets/assistent.patch.js @@ -1,81 +1,101 @@ -/** - * Swarm Assistent — patch detection / extraction / alias normalization. - * Loaded before assistent.js; mirrors AssistentPatch.cs on the server side. - */ -window.SA = window.SA || {}; - -(function () { - const PATCH_KEYS = [ - 'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler', - 'actions', 'search_query', 'civitai_query', - 'use_init_image', 'clear_init_image', 'init_creativity', 'denoise', - 'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow', - 'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask', - 'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed', - 'creativity', 'intensity', 'complexity', 'movement', - 'clear_prompt_images', 'slot_to_prompt_image', 'pack', - 'memories', 'memory_query', 'memory_kind', 'tag_query', 'user_prefs', - 'controls', 'persona_clone', 'persona_shelves', 'persona', 'notes', - ]; - - const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi; - - function has(obj, key) { - return obj[key] !== undefined && obj[key] !== null; - } - - /** True when the object looks like a generation patch rather than arbitrary JSON. */ - function isPatchObject(obj) { - if (!obj || typeof obj !== 'object') { - return false; - } - return PATCH_KEYS.some((k) => has(obj, k)); - } - - /** Maps alias fields onto canonical names, keeping the aliases in place. */ - function normalizePatch(patch) { - if (!patch || typeof patch !== 'object') { - return patch; - } - if (!has(patch, 'search_query') && has(patch, 'civitai_query')) { - patch.search_query = patch.civitai_query; - } - if (!has(patch, 'init_creativity') && has(patch, 'denoise')) { - patch.init_creativity = patch.denoise; - } - if (!has(patch, 'look_at')) { - if (has(patch, 'vision_from')) { - patch.look_at = patch.vision_from; - } else if (has(patch, 'vision_slots')) { - patch.look_at = patch.vision_slots; - } - } - return patch; - } - - /** Splits a reply into prose and the last fenced patch object found in it. */ - function extractPatch(text) { - if (!text) { - return { prose: text || '', patch: null }; - } - const re = new RegExp(FENCE_RE.source, 'gi'); - let match; - let lastPatch = null; - let prose = text; - while ((match = re.exec(text)) !== null) { - try { - const obj = JSON.parse(match[1].trim()); - if (isPatchObject(obj)) { - lastPatch = normalizePatch(obj); - prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim(); - } - } catch (e) { /* not json */ } - } - return { prose, patch: lastPatch }; - } - - SA.PATCH_KEYS = PATCH_KEYS; - SA.isPatchObject = isPatchObject; - SA.normalizePatch = normalizePatch; - SA.extractPatch = extractPatch; -})(); +/** + * Swarm Assistent — patch detection / extraction / alias normalization. + * Loaded before assistent.js; mirrors AssistentPatch.cs on the server side. + */ +window.SA = window.SA || {}; + +(function () { + const PATCH_KEYS = [ + 'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler', + 'actions', 'search_query', 'civitai_query', + 'use_init_image', 'clear_init_image', 'init_creativity', 'denoise', + 'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow', + 'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask', + 'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed', + 'creativity', 'intensity', 'complexity', 'movement', + 'clear_prompt_images', 'slot_to_prompt_image', 'pack', + 'memories', 'memory_query', 'memory_kind', 'tag_query', 'user_prefs', + 'controls', 'persona_clone', 'persona_shelves', 'persona', 'notes', + ]; + + const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi; + + function has(obj, key) { + return obj[key] !== undefined && obj[key] !== null; + } + + /** Model-card JSON (catalog_card) — must not be treated as a Generate patch. */ + function isCardObject(obj) { + if (!obj || typeof obj !== 'object') { + return false; + } + const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint); + const genish = !!(obj.prompt != null || obj.loras || obj.actions || obj.width || obj.height + || obj.steps || obj.cfg || obj.aspect || obj.seed != null + || obj.search_query || obj.civitai_query || obj.look_at || obj.controls); + if (cardish && !genish && (obj.name || obj.triggers || obj.when)) { + return true; + } + return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null)); + } + + /** True when the object looks like a generation patch rather than a catalog card / arbitrary JSON. */ + function isPatchObject(obj) { + if (!obj || typeof obj !== 'object') { + return false; + } + if (isCardObject(obj)) { + return false; + } + return PATCH_KEYS.some((k) => has(obj, k)); + } + + /** Maps alias fields onto canonical names, keeping the aliases in place. */ + function normalizePatch(patch) { + if (!patch || typeof patch !== 'object') { + return patch; + } + if (!has(patch, 'search_query') && has(patch, 'civitai_query')) { + patch.search_query = patch.civitai_query; + } + if (!has(patch, 'init_creativity') && has(patch, 'denoise')) { + patch.init_creativity = patch.denoise; + } + if (!has(patch, 'look_at')) { + if (has(patch, 'vision_from')) { + patch.look_at = patch.vision_from; + } else if (has(patch, 'vision_slots')) { + patch.look_at = patch.vision_slots; + } + } + return patch; + } + + /** Splits a reply into prose and the last fenced patch object found in it. */ + function extractPatch(text) { + if (!text) { + return { prose: text || '', patch: null }; + } + const re = new RegExp(FENCE_RE.source, 'gi'); + let match; + let lastPatch = null; + let prose = text; + while ((match = re.exec(text)) !== null) { + try { + const obj = JSON.parse(match[1].trim()); + if (isPatchObject(obj)) { + lastPatch = normalizePatch(obj); + prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim(); + } + } catch (e) { /* not json */ } + } + return { prose, patch: lastPatch }; + } + + SA.PATCH_KEYS = PATCH_KEYS; + SA.isCardObject = isCardObject; + SA.isPatchObject = isPatchObject; + SA.normalizePatch = normalizePatch; + SA.extractPatch = extractPatch; +})(); + \ No newline at end of file diff --git a/AssistentChatPipeline.cs b/AssistentChatPipeline.cs index c510782..cc2b6c9 100644 --- a/AssistentChatPipeline.cs +++ b/AssistentChatPipeline.cs @@ -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); } diff --git a/AssistentConfig.cs b/AssistentConfig.cs index 1563449..98da376 100644 --- a/AssistentConfig.cs +++ b/AssistentConfig.cs @@ -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 allow = null; if (onlyShelves is not null) diff --git a/AssistentOllama.cs b/AssistentOllama.cs index 22ffbe0..03ecd0c 100644 --- a/AssistentOllama.cs +++ b/AssistentOllama.cs @@ -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) diff --git a/AssistentPatch.cs b/AssistentPatch.cs index dc6a961..6906d4b 100644 --- a/AssistentPatch.cs +++ b/AssistentPatch.cs @@ -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); } diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md index 9bff73b..d77008c 100644 --- a/Config/_base/core/core.md +++ b/Config/_base/core/core.md @@ -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. diff --git a/Config/personas/leonid/bio.json b/Config/personas/leonid/bio.json index af6a21d..fc5becc 100644 --- a/Config/personas/leonid/bio.json +++ b/Config/personas/leonid/bio.json @@ -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" diff --git a/Config/personas/leonid/rules.json b/Config/personas/leonid/rules.json index 0cf74c0..b97c3f3 100644 --- a/Config/personas/leonid/rules.json +++ b/Config/personas/leonid/rules.json @@ -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", diff --git a/README.md b/README.md index 2b8f574..364c77b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 9a38e20..1c3cb12 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -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"]; } diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index 6dd7f8b..ac80be3 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -92,6 +92,7 @@ +
Скилы (процедуры)