diff --git a/Assets/assistent.css b/Assets/assistent.css index 7855b3f..f01e22a 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -731,11 +731,17 @@ .sa-settings { display: flex; flex-direction: column; - gap: 0.45rem; - padding: 0.7rem 0.75rem; - border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); - background: color-mix(in srgb, currentColor 5%, transparent); - max-height: 50vh; + gap: 0.55rem; + padding: 0.75rem 0.85rem 1rem; + flex: 1; + min-height: 0; + overflow: hidden; + border-bottom: none; + background: transparent; +} + +#sa_view_settings { + overflow: hidden; } .sa-settings-head { @@ -743,12 +749,14 @@ align-items: center; justify-content: space-between; gap: 0.5rem; + flex-shrink: 0; } .sa-settings-tabs { display: flex; flex-wrap: wrap; gap: 0.25rem; + flex-shrink: 0; } .sa-stab { @@ -1164,6 +1172,12 @@ gap: 0.35rem; } +.sa-msg-body.sa-prose-live { + display: block; + white-space: pre-wrap; + word-break: break-word; +} + .sa-prose-h { font-weight: 650; letter-spacing: 0.01em; @@ -1485,6 +1499,11 @@ background: color-mix(in srgb, currentColor 12%, transparent); } +#sa_btn_settings.sa-subtab-active { + opacity: 1; + background: color-mix(in srgb, currentColor 12%, transparent); +} + .sa-view { flex: 1; display: flex; diff --git a/Assets/assistent.js b/Assets/assistent.js index d3aff85..0e63c82 100644 --- a/Assets/assistent.js +++ b/Assets/assistent.js @@ -128,6 +128,7 @@ exact: null, sessionExact: {}, lastUserParamIntent: false, + lastUserControlIntent: false, lastPatch: null, pendingSilentGen: false, enabledSkills: [], @@ -147,6 +148,7 @@ streamEl: null, streamMeta: null, streamText: '', + streamFenceDone: false, critiqueHopUsed: false, visionHopUsed: false, lastSystemChars: 0, @@ -215,6 +217,37 @@ } } + let scrollMessagesRaf = 0; + + /** Autoscroll only when already near the bottom — otherwise streaming fights the user's scroll (shakes). */ + function messagesNearBottom(thresholdPx = 96) { + const box = $('sa_messages'); + if (!box) { + return true; + } + return (box.scrollHeight - box.scrollTop - box.clientHeight) <= thresholdPx; + } + + function scrollMessagesToBottom({ force = false } = {}) { + const box = $('sa_messages'); + if (!box) { + return; + } + if (!force && !messagesNearBottom()) { + return; + } + if (scrollMessagesRaf) { + return; + } + scrollMessagesRaf = requestAnimationFrame(() => { + scrollMessagesRaf = 0; + const el = $('sa_messages'); + if (el && (force || messagesNearBottom(120))) { + el.scrollTop = el.scrollHeight; + } + }); + } + function showChatEmptyIfIdle() { const box = $('sa_messages'); const empty = $('sa_chat_empty'); @@ -574,7 +607,7 @@ return parts.join(''); } - function setAssistantBody(div, text) { + function setAssistantBody(div, text, { live = false } = {}) { if (!div) { return; } @@ -585,7 +618,15 @@ div.appendChild(body); } const raw = text || ''; - body.classList.add('sa-prose'); + // While streaming, plain text — full HTML reformat every token reflows and shakes scroll. + if (live) { + body.classList.add('sa-prose', 'sa-prose-live'); + body.classList.remove('sa-prose-rich'); + body.textContent = raw; + return; + } + body.classList.add('sa-prose', 'sa-prose-rich'); + body.classList.remove('sa-prose-live'); const html = formatAssistantProseHtml(raw); if (html) { body.innerHTML = html; @@ -674,6 +715,56 @@ || /смени\s+(размер|aspect|соотношен)/i.test(t); } + function userTextMentionsControls(text) { + const t = String(text || ''); + if (/\b(horny|controls?|preference[_\s-]?bias)\b/i.test(t)) { + return true; + } + return cyrTokenRe('хорни|остынь|вкус|слайдер').test(t) + || /\/\s*(остынь|ostyn|horny-game)/i.test(t); + } + + function patchLooksLikeGeneration(patch) { + if (!patch || typeof patch !== 'object') { + return false; + } + if (patch.prompt != null || patch.loras || patch.aspect != null + || patch.width != null || patch.height != null || patch.steps != null + || patch.cfg != null || patch.seed != null) { + return true; + } + return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'); + } + + /** Drop Generate-patch echo of schema defaults that would wipe a user-tuned slider. */ + function filterControlPatch(incoming, patch) { + const schema = state.config?.controls || {}; + const out = {}; + if (!incoming || typeof incoming !== 'object') { + return out; + } + const likeGen = patchLooksLikeGeneration(patch) && !state.lastUserControlIntent; + for (const [id, raw] of Object.entries(incoming)) { + if (!schema[id]) { + continue; + } + const n = Number(raw); + if (!Number.isFinite(n)) { + continue; + } + const def = Number(schema[id]?.default); + const cur = getControlValue(id, Number.isFinite(def) ? def : n); + if (Math.abs(n - cur) < 0.0005) { + continue; + } + if (likeGen && Number.isFinite(def) && Math.abs(n - def) < 0.0005 && Math.abs(cur - def) > 0.0005) { + continue; + } + out[id] = n; + } + return out; + } + function userTextMentionsParams(text) { const t = String(text || ''); if (parseAspectFromUserText(t)) { @@ -1597,7 +1688,7 @@ div.className = 'sa-msg assistant sa-welcome'; div.innerHTML = WELCOME_HTML; box.appendChild(div); - box.scrollTop = box.scrollHeight; + scrollMessagesToBottom({ force: true }); } function chatUid() { @@ -2411,8 +2502,7 @@ fillEmptyParamsFromExact(); renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {}); syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source); - const settings = $('sa_settings'); - if (settings && !settings.hidden) { + if (state.view === 'settings') { if (state.settingsTab === 'user') { refreshUserPrefs(); } @@ -3598,17 +3688,15 @@ } } - // Persona Exact controls (model or user patch). Ignore persona_delete. + // Persona Exact controls (model or user patch). Ignore default-echo inside Generate patches. if (patch.controls && typeof patch.controls === 'object' && !Array.isArray(patch.controls)) { const schema = state.config?.controls || {}; - const next = { ...(state.config?.control_values || state.exact?.controls || {}) }; - for (const [k, v] of Object.entries(patch.controls)) { - if (schema[k]) { - next[k] = v; - } + const filtered = filterControlPatch(patch.controls, patch); + if (Object.keys(filtered).length) { + const next = { ...(state.config?.control_values || state.exact?.controls || {}), ...filtered }; + savePersonaControls(filtered); + renderPersonaControls(schema, next); } - savePersonaControls(next); - renderPersonaControls(schema, next); } const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : []; @@ -3728,11 +3816,15 @@ }); } - /** Re-load chat model after park. Returns a Promise (await after Generate so the next Send is warm). */ - function warmLlm() { + /** Re-load chat model into VRAM. force=true after Generate even without park — Krea often evicts Ollama. */ + function warmLlm({ force = false } = {}) { return new Promise((resolve) => { const model = $('sa_model')?.value; - if (!model || !state.llmParked || typeof genericRequest !== 'function') { + if (!model || typeof genericRequest !== 'function') { + resolve(false); + return; + } + if (!force && !state.llmParked) { resolve(false); return; } @@ -3749,7 +3841,7 @@ } resolve(!!ok); }; - // VL 7B cold-load can exceed a minute — don't time out the flag early. + // VL 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)); }); @@ -3864,12 +3956,15 @@ 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. + // Krea Generate almost always evicts the VL chat weights from VRAM — even when Park LLM is off. + state.expectColdLoad = true; const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent; - if (state.llmParked && state.view === 'chat' && paneVisible && !$('sa_auto_critique')?.checked) { + const willAutoCritique = !!$('sa_auto_critique')?.checked; + // Auto-critique's own chat request will cold-load; otherwise preload before the user types. + if (state.view === 'chat' && paneVisible && !willAutoCritique) { startBusyUi('warming'); setStatus('Возвращаю LLM в GPU…'); - await warmLlm(); + await warmLlm({ force: true }); } if (!state.busy) { stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)'); @@ -4053,7 +4148,7 @@ div.appendChild(buildCivitaiCards(civitaiResults)); } box.appendChild(div); - box.scrollTop = box.scrollHeight; + scrollMessagesToBottom({ force: true }); return div; } @@ -4071,13 +4166,42 @@ body.innerHTML = 'Waiting for the model…'; div.appendChild(body); box.appendChild(div); - box.scrollTop = box.scrollHeight; + scrollMessagesToBottom({ force: true }); state.streamEl = div; state.streamMeta = meta || null; + state.streamFenceDone = false; return div; } + function streamHasClosedPatchFence(text) { + const t = String(text || ''); + if (!/```[\s\S]*```/.test(t)) { + return false; + } + const { patch } = extractPatch(t); + return !!patch; + } + + function trimToClosedPatchFence(text) { + const t = String(text || ''); + const re = /```(?:json)?\s*([\s\S]*?)```/gi; + let match; + let lastEnd = -1; + while ((match = re.exec(t)) !== null) { + try { + const obj = JSON.parse(match[1].trim()); + if (isPatchObject(obj) || isCardObject(obj)) { + lastEnd = match.index + match[0].length; + } + } catch (e) { /* ignore */ } + } + return lastEnd > 0 ? t.slice(0, lastEnd).trimEnd() : t; + } + function appendStreamDelta(delta) { + if (state.streamFenceDone) { + return; + } if (!state.streamEl) { beginStreamMessage(state.streamMeta || undefined); } @@ -4085,7 +4209,7 @@ if (state.streamEl.classList.contains('sa-typing')) { state.streamEl.classList.remove('sa-typing'); state.streamText = ''; - setAssistantBody(state.streamEl, ''); + setAssistantBody(state.streamEl, '', { live: true }); } state.gotDelta = true; state.expectColdLoad = false; @@ -4093,11 +4217,12 @@ setBusyPhase('streaming'); } state.streamText = (state.streamText || '') + (delta || ''); - setAssistantBody(state.streamEl, state.streamText); - const box = $('sa_messages'); - if (box) { - box.scrollTop = box.scrollHeight; + if (streamHasClosedPatchFence(state.streamText)) { + state.streamText = trimToClosedPatchFence(state.streamText); + state.streamFenceDone = true; } + setAssistantBody(state.streamEl, state.streamText, { live: true }); + scrollMessagesToBottom(); } } @@ -4107,6 +4232,7 @@ state.streamEl = null; state.streamMeta = null; state.streamText = ''; + state.streamFenceDone = false; if (!el) { appendMessage('assistant', fullReply, null, civitaiResults, meta || undefined); return; @@ -4131,10 +4257,7 @@ if (civitaiResults && civitaiResults.length) { el.appendChild(buildCivitaiCards(civitaiResults)); } - const box = $('sa_messages'); - if (box) { - box.scrollTop = box.scrollHeight; - } + scrollMessagesToBottom(); } function buildCivitaiCards(results) { @@ -4517,7 +4640,7 @@ if (paneW) { document.documentElement.style.setProperty('--sa-image-width', paneW); } - if (view === 'cards' || view === 'chat') { + if (view === 'cards' || view === 'chat' || view === 'settings') { state.view = view; } const boardTab = localStorage.getItem(LS_BOARD_TAB); @@ -4577,7 +4700,7 @@ fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } }); fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } }); fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v)); - if (ui.view === 'cards' || ui.view === 'chat') { + if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings') { fill(LS_VIEW, ui.view, (v) => { state.view = v; }); } if (ui.board_tab === 'refs' || ui.board_tab === 'generate') { @@ -4817,16 +4940,26 @@ controlsPointerDown = true; }); const endPointer = () => { + const v = Number(input.value); + applyLocal(v); controlsPointerDown = false; + // Discard mid-drag rebuilds that carried stale server defaults — keep local values. if (pendingControlsRender) { - const pending = pendingControlsRender; + const schema = pendingControlsRender.schema; pendingControlsRender = null; - renderPersonaControls(pending.schema, pending.values); + renderPersonaControls( + schema, + state.config?.control_values || state.exact?.controls || {}, + ); } + if (controlSaveTimer) { + clearTimeout(controlSaveTimer); + } + controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 50); }; input.addEventListener('pointerup', endPointer); input.addEventListener('pointercancel', endPointer); - // Live label while dragging; persist only on release (change) so mid-drag saves cannot snap back. + // Live label while dragging; also persist on change for keyboard tweaks. input.addEventListener('input', () => { applyLocal(Number(input.value)); }); @@ -4857,6 +4990,7 @@ setStatus('/остынь только для Leonid'); return; } + state.lastUserControlIntent = true; const schema = state.config?.controls || {}; if (!schema.horny) { setStatus('У этой личности нет слайдера Хорни'); @@ -4889,6 +5023,7 @@ return; } const cur = getControlValue('horny', 35); + state.lastUserControlIntent = true; await sendChat({ skipSlash: true, skipAutoPack: true, @@ -5549,22 +5684,6 @@ } } - function openSettings(tab) { - const s = $('sa_settings'); - if (!s) { - return; - } - s.hidden = false; - setSettingsTab(tab || state.settingsTab || 'behavior'); - } - - function closeSettings() { - const s = $('sa_settings'); - if (s) { - s.hidden = true; - } - } - function fillKnobsFromConfig(data) { const asst = data?.assistant || state.config?.assistant || {}; const exact = data?.exact || state.config?.exact || state.exact || {}; @@ -6133,26 +6252,51 @@ } function setView(view) { - state.view = view === 'cards' ? 'cards' : 'chat'; + if (view === 'cards') { + state.view = 'cards'; + } else if (view === 'settings') { + state.view = 'settings'; + } else { + state.view = 'chat'; + } const chat = $('sa_view_chat'); const cards = $('sa_view_cards'); + const settings = $('sa_view_settings'); if (chat) { chat.hidden = state.view !== 'chat'; } if (cards) { cards.hidden = state.view !== 'cards'; } + if (settings) { + settings.hidden = state.view !== 'settings'; + } $('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat'); $('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards'); + $('sa_tab_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings'); + $('sa_btn_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings'); saveSettings(); if (state.view === 'cards') { renderCardsList(); - } else if (state.llmParked && !state.generating) { - // Back in the chat — bring the model home before the user hits Send. - warmLlm(); + } else if (state.view === 'settings') { + setSettingsTab(state.settingsTab || 'behavior'); + } else if ((state.llmParked || state.expectColdLoad) && !state.generating) { + // Back in the chat — bring the model home (Krea may have evicted it). + warmLlm({ force: true }); } } + function openSettings(tab) { + if (tab) { + state.settingsTab = tab; + } + setView('settings'); + } + + function closeSettings() { + setView('chat'); + } + function refreshPersonas() { loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => { if (data?.personas) { @@ -7050,7 +7194,7 @@ div.className = 'sa-msg assistant sa-system-note'; div.textContent = text; box.appendChild(div); - box.scrollTop = box.scrollHeight; + scrollMessagesToBottom(); } function clipDebug(s, max) { @@ -7423,6 +7567,7 @@ } if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) { state.lastUserParamIntent = userTextMentionsParams(text); + state.lastUserControlIntent = userTextMentionsControls(text); state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text); } @@ -7491,13 +7636,21 @@ const chatEpoch = bumpChatEpoch(); state.busy = true; - // 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; - } + // If Krea just ran, expectColdLoad stays true until warm / first token — don't clear it here. state.llmParked = false; setInterruptVisible(true); + if (state.expectColdLoad && !opts.fromVisionHop && !opts.fromAutoCritique) { + startBusyUi('warming'); + setStatus('Возвращаю LLM в GPU…'); + try { + await warmLlm({ force: true }); + } catch (e) { + console.warn('Assistent warm before send', e); + } + if (chatEpoch !== state.chatEpoch) { + return; + } + } startBusyUi(state.expectColdLoad ? 'loading' : 'thinking'); saveSettings(); @@ -8015,6 +8168,7 @@ $('sa_tab_chat')?.addEventListener('click', () => setView('chat')); $('sa_tab_cards')?.addEventListener('click', () => setView('cards')); + $('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior')); $('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate')); $('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs')); $('sa_persona')?.addEventListener('change', onPersonaChanged); @@ -8025,15 +8179,11 @@ $('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent()); $('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard()); $('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly()); - $('sa_btn_settings')?.addEventListener('click', () => { - const s = $('sa_settings'); - if (s) { - if (s.hidden) { - openSettings(state.settingsTab || 'behavior'); - } else { - closeSettings(); - } + if (state.view === 'settings') { + closeSettings(); + } else { + openSettings(state.settingsTab || 'behavior'); } }); $('sa_settings_close')?.addEventListener('click', () => closeSettings()); @@ -8105,8 +8255,7 @@ return; } let closed = false; - const settings = $('sa_settings'); - if (settings && !settings.hidden) { + if (state.view === 'settings') { closeSettings(); closed = true; } diff --git a/AssistentChatPipeline.cs b/AssistentChatPipeline.cs index f8e5246..1402c81 100644 --- a/AssistentChatPipeline.cs +++ b/AssistentChatPipeline.cs @@ -1003,12 +1003,24 @@ public partial class SwarmAssistentExtension patch["_persona_written"] = target; } } - // Control values from model patch (Exact). + // Control values from model patch (Exact). Ignore default-echo inside Generate patches. if (patch["controls"] is JObject ctrlVals) { string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId; - Config.SaveControlValues(ctrlPid, ctrlVals); - patch["_controls_saved"] = true; + JObject schema = Config.LoadControlsSchema(ctrlPid); + JObject current = Config.LoadControlValues(ctrlPid); + JObject filtered = AssistentConfig.FilterEchoedControlDefaults( + schema, current, ctrlVals, AssistentConfig.PatchLooksLikeGeneration(patch)); + if (filtered.Count > 0) + { + Config.SaveControlValues(ctrlPid, filtered); + patch["controls"] = filtered; + patch["_controls_saved"] = true; + } + else + { + patch.Remove("controls"); + } } } catch (Exception ex) diff --git a/AssistentConfig.cs b/AssistentConfig.cs index 98da376..16cc2d4 100644 --- a/AssistentConfig.cs +++ b/AssistentConfig.cs @@ -466,6 +466,81 @@ public sealed class AssistentConfig return result; } + /// + /// Drop control keys that merely restate schema defaults while the user already has a + /// different saved value — models often echo Exact defaults inside Generate patches and + /// that was resetting Хорни / Вкус after every reply. + /// Keep intentional changes: non-default values, or default when it matches current, or + /// when the patch is controls-only (e.g. /horny-game). + /// + public static JObject FilterEchoedControlDefaults(JObject schema, JObject current, JObject incoming, bool patchLooksLikeGen) + { + JObject result = new(); + if (schema is null || incoming is null) + { + return result; + } + current ??= new JObject(); + foreach (JProperty prop in incoming.Properties()) + { + if (schema[prop.Name] is not JObject def) + { + continue; + } + if (!double.TryParse(prop.Value?.ToString(), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out double incomingVal)) + { + continue; + } + double defVal = def["default"]?.Value() ?? double.NaN; + double curVal = double.NaN; + if (current[prop.Name] != null) + { + double.TryParse(current[prop.Name]?.ToString(), System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out curVal); + } + if (!double.IsNaN(curVal) && Math.Abs(incomingVal - curVal) < 0.0005) + { + continue; + } + if (patchLooksLikeGen + && !double.IsNaN(defVal) + && Math.Abs(incomingVal - defVal) < 0.0005 + && !double.IsNaN(curVal) + && Math.Abs(curVal - defVal) > 0.0005) + { + continue; + } + result[prop.Name] = Math.Round(incomingVal, 4); + } + return result; + } + + public static bool PatchLooksLikeGeneration(JObject patch) + { + if (patch is null) + { + return false; + } + if (patch["prompt"] != null || patch["loras"] != null || patch["aspect"] != null + || patch["width"] != null || patch["height"] != null || patch["steps"] != null + || patch["cfg"] != null || patch["seed"] != null) + { + return true; + } + if (patch["actions"] is JArray acts) + { + foreach (JToken a in acts) + { + if (string.Equals(a?.ToString(), "generate", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + return false; + } + public string RenderControlsBlock(string personaId) { string id = SafeId(personaId) ?? "neutral"; diff --git a/AssistentOllama.cs b/AssistentOllama.cs index 3456876..b691bab 100644 --- a/AssistentOllama.cs +++ b/AssistentOllama.cs @@ -192,6 +192,10 @@ public partial class SwarmAssistentExtension string body = await resp.Content.ReadAsStringAsync(); JObject parsed = JObject.Parse(body); string reply = parsed["message"]?["content"]?.ToString() ?? parsed["response"]?.ToString() ?? ""; + if (TryTruncateAtCompleteFence(reply, out string cut)) + { + reply = cut; + } return (reply, parsed); } StringBuilder full = new(); @@ -215,6 +219,23 @@ public partial class SwarmAssistentExtension if (!string.IsNullOrEmpty(delta)) { full.Append(delta); + // Closed ```json``` patch → stop reading. Models otherwise ramble («Готово!», 2nd aspect…). + if (TryTruncateAtCompleteFence(full.ToString(), out string cut)) + { + string extra = full.Length > cut.Length ? full.ToString(cut.Length, full.Length - cut.Length) : ""; + full.Clear(); + full.Append(cut); + if (onDelta is not null) + { + // Only forward the part of this delta that stays inside the fence. + int keep = delta.Length - extra.Length; + if (keep > 0) + { + await onDelta(delta.Substring(0, keep)); + } + } + break; + } if (onDelta is not null) { await onDelta(delta); diff --git a/AssistentPatch.cs b/AssistentPatch.cs index 6906d4b..98dd966 100644 --- a/AssistentPatch.cs +++ b/AssistentPatch.cs @@ -106,6 +106,51 @@ public partial class SwarmAssistentExtension return null; } + /// + /// If the reply already contains a closed fenced patch/card, cut everything after it. + /// Models often keep writing («Готово!», second aspect, …) and the stream never feels done. + /// + static bool TryTruncateAtCompleteFence(string reply, out string truncated) + { + truncated = reply ?? ""; + if (string.IsNullOrWhiteSpace(reply)) + { + return false; + } + MatchCollection matches = JsonFenceRe.Matches(reply); + if (matches.Count == 0) + { + return false; + } + for (int i = 0; i < matches.Count; i++) + { + Match match = matches[i]; + string raw = match.Groups[1].Value.Trim(); + try + { + JObject obj = JObject.Parse(raw); + if (obj is null) + { + continue; + } + bool usable = LooksLikeCardObject(obj) + || Array.Exists(PatchKeys, k => obj[k] is not null); + if (!usable) + { + continue; + } + truncated = reply.Substring(0, match.Index + match.Length).TrimEnd(); + // Only treat as complete if the fence actually closed (regex requires ```). + return true; + } + catch + { + // incomplete / invalid json inside fence + } + } + return false; + } + static string ExtractSearchQuery(JObject patch) { if (patch is null) diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md index ae26a32..9308f10 100644 --- a/Config/_base/core/core.md +++ b/Config/_base/core/core.md @@ -18,7 +18,7 @@ When instructions conflict, apply this order (highest wins): Exact = defaults encyclopedia. About the user = human taste. RAG = soft craft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match Exact (or `session_exact`) and the user did not ask to change them. -Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. Keep prose short (a few lines). Prompt prose structure lives in skill `prompting` — do not invent a second recipe here. +Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. Keep prose short (a few lines). **After the closing ``` of the JSON fence, STOP** — no «Готово!», no second aspect, no “сейчас сгенерирую оба”. One turn = one patch (one aspect). Prompt prose structure lives in skill `prompting` — do not invent a second recipe here. ## Live context @@ -55,6 +55,7 @@ Never write a “JSON Patch” section in prose without an actual fenced ```json ### Patch rules - Omit unchanged keys. Prefer omitting Exact-matching `steps`/`cfg`/`sigma_shift`/`aspect`. +- Prefer omitting Exact-matching **`controls`** (e.g. do not re-emit `"horny": 35` / `"preference_bias": 0.35` when unchanged) — echoing defaults in a Generate patch resets the UI sliders. - `loras` replaces the full intended set for Apply. Prefer `aspect` over raw width/height. - Optional keys (seed, vary, init/mask, creativity/sliders, pack, **controls**, persona authoring, search/memory queries) — use when needed; packs list the ones for that mode. - **`controls`** — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Example: `"controls": { "horny": 55 }`. Do not invent control ids. diff --git a/Config/personas/leonid/rules.json b/Config/personas/leonid/rules.json index 1d425cc..30fc3e9 100644 --- a/Config/personas/leonid/rules.json +++ b/Config/personas/leonid/rules.json @@ -6,6 +6,7 @@ "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)", + "Never re-emit controls.horny / preference_bias in a Generate patch unless the user asked to change them — echoing defaults resets the UI", "Explicit user look/outfit/plot beats personal taste", "«девушка которая тебе нравится» = YOUR (Leonid) taste, not the user's — unless they said otherwise", "High horny / NSFW plot / craft detail: persona_read shelves [\"roleplay\"] (and humor/craft if needed) — they are not always-on", diff --git a/README.md b/README.md index c9b5e8f..63520b8 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ 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.13** — Assistant prose renders `### Critique` as **Критика** (and similar headings); empty JSON Patch headers hidden. Builds on 0.10.12 aspect/critique fixes. +**Version 0.10.18** — Settings is a full subtab (Чат | Карточки | Настройки), not a header overlay. Builds on 0.10.17 post-Generate warm. ## Layout - **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`); **Посмотри результат** attaches the finished frame and asks for a verdict - **Splitter:** drag to resize panes -- **Right:** Chat | Cards; persona / pack / Ollama chat model; **Ollama health** badge; ⚙ settings panel (6 tabs) +- **Right:** Chat | Cards | Settings; persona / pack / Ollama chat model; **Ollama health** badge - **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override) ## Config (bundled + overlay) @@ -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 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 +- Before Generate the chat model is unloaded only if **Park LLM** is enabled (`keep_alive: 0`) — default off +- After Generate the chat model is **always** force-warmed (`AssistentWarmLlm`) — Krea still often evicts VL from VRAM even without park - Embed / memory models are never parked — reloading them would stall every retrieve ## UX diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 998f1c4..ac07ff7 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.13"; + Version = "0.10.18"; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; } diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index c186787..2f7c1e4 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -46,6 +46,7 @@ Чат Карточки + Настройки @@ -73,10 +74,88 @@ ⚙ - + + + + Совместная работа с Krea 2 + Напиши промпт, кинь refs, выбери персону или открой Карточки для LoRA. + + + + + Работаю… + + + + + + + + + + + Отправить + Собрать + Gen + Стоп + + Очистить + ▾ + + Очистить весь чат… + Убрать только патчи + + + + + + + + + + + + + Все + Checkpoints + LoRAs + + Обновить + + + + + + Выбери модель + + + + + Triggers + Weight + When + Avoid + Prompt hint + Notes + Civitai URL + Показать JSON + + + + В чат + Загрузить Civitai meta + Сгенерировать с Assistent + Сохранить карточку + В wanted + + + + + + + Настройки - ✕ + ← Чат Поведение @@ -215,83 +294,8 @@ - - - - Совместная работа с Krea 2 - Напиши промпт, кинь refs, выбери персону или открой Карточки для LoRA. - - - - - Работаю… - - - - - - - - - - - Отправить - Собрать + Gen - Стоп - - Очистить - ▾ - - Очистить весь чат… - Убрать только патчи - - - - - - - - - - - Все - Checkpoints - LoRAs - - Обновить - - - - - - Выбери модель - - - - - Triggers - Weight - When - Avoid - Prompt hint - Notes - Civitai URL - Показать JSON - - - - В чат - Загрузить Civitai meta - Сгенерировать с Assistent - Сохранить карточку - В wanted - - - - -