Ship Assistent 0.10.18: settings as a peer tab, stream/VRAM chat fixes.

Move settings beside Chat/Cards; stop fence ramble, sticky scroll, horny echo filter, and force-warm after Generate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 04:12:53 +03:00
co-authored by Cursor
parent 43984208fc
commit 536637714c
11 changed files with 490 additions and 163 deletions
+24 -5
View File
@@ -731,11 +731,17 @@
.sa-settings { .sa-settings {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.45rem; gap: 0.55rem;
padding: 0.7rem 0.75rem; padding: 0.75rem 0.85rem 1rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); flex: 1;
background: color-mix(in srgb, currentColor 5%, transparent); min-height: 0;
max-height: 50vh; overflow: hidden;
border-bottom: none;
background: transparent;
}
#sa_view_settings {
overflow: hidden;
} }
.sa-settings-head { .sa-settings-head {
@@ -743,12 +749,14 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
} }
.sa-settings-tabs { .sa-settings-tabs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem;
flex-shrink: 0;
} }
.sa-stab { .sa-stab {
@@ -1164,6 +1172,12 @@
gap: 0.35rem; gap: 0.35rem;
} }
.sa-msg-body.sa-prose-live {
display: block;
white-space: pre-wrap;
word-break: break-word;
}
.sa-prose-h { .sa-prose-h {
font-weight: 650; font-weight: 650;
letter-spacing: 0.01em; letter-spacing: 0.01em;
@@ -1485,6 +1499,11 @@
background: color-mix(in srgb, currentColor 12%, transparent); 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 { .sa-view {
flex: 1; flex: 1;
display: flex; display: flex;
+221 -72
View File
@@ -128,6 +128,7 @@
exact: null, exact: null,
sessionExact: {}, sessionExact: {},
lastUserParamIntent: false, lastUserParamIntent: false,
lastUserControlIntent: false,
lastPatch: null, lastPatch: null,
pendingSilentGen: false, pendingSilentGen: false,
enabledSkills: [], enabledSkills: [],
@@ -147,6 +148,7 @@
streamEl: null, streamEl: null,
streamMeta: null, streamMeta: null,
streamText: '', streamText: '',
streamFenceDone: false,
critiqueHopUsed: false, critiqueHopUsed: false,
visionHopUsed: false, visionHopUsed: false,
lastSystemChars: 0, 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() { function showChatEmptyIfIdle() {
const box = $('sa_messages'); const box = $('sa_messages');
const empty = $('sa_chat_empty'); const empty = $('sa_chat_empty');
@@ -574,7 +607,7 @@
return parts.join(''); return parts.join('');
} }
function setAssistantBody(div, text) { function setAssistantBody(div, text, { live = false } = {}) {
if (!div) { if (!div) {
return; return;
} }
@@ -585,7 +618,15 @@
div.appendChild(body); div.appendChild(body);
} }
const raw = text || ''; 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); const html = formatAssistantProseHtml(raw);
if (html) { if (html) {
body.innerHTML = html; body.innerHTML = html;
@@ -674,6 +715,56 @@
|| /смени\s+(размер|aspect|соотношен)/i.test(t); || /смени\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) { function userTextMentionsParams(text) {
const t = String(text || ''); const t = String(text || '');
if (parseAspectFromUserText(t)) { if (parseAspectFromUserText(t)) {
@@ -1597,7 +1688,7 @@
div.className = 'sa-msg assistant sa-welcome'; div.className = 'sa-msg assistant sa-welcome';
div.innerHTML = WELCOME_HTML; div.innerHTML = WELCOME_HTML;
box.appendChild(div); box.appendChild(div);
box.scrollTop = box.scrollHeight; scrollMessagesToBottom({ force: true });
} }
function chatUid() { function chatUid() {
@@ -2411,8 +2502,7 @@
fillEmptyParamsFromExact(); fillEmptyParamsFromExact();
renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {}); renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source); syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source);
const settings = $('sa_settings'); if (state.view === 'settings') {
if (settings && !settings.hidden) {
if (state.settingsTab === 'user') { if (state.settingsTab === 'user') {
refreshUserPrefs(); 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)) { if (patch.controls && typeof patch.controls === 'object' && !Array.isArray(patch.controls)) {
const schema = state.config?.controls || {}; const schema = state.config?.controls || {};
const next = { ...(state.config?.control_values || state.exact?.controls || {}) }; const filtered = filterControlPatch(patch.controls, patch);
for (const [k, v] of Object.entries(patch.controls)) { if (Object.keys(filtered).length) {
if (schema[k]) { const next = { ...(state.config?.control_values || state.exact?.controls || {}), ...filtered };
next[k] = v; savePersonaControls(filtered);
} renderPersonaControls(schema, next);
} }
savePersonaControls(next);
renderPersonaControls(schema, next);
} }
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : []; 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). */ /** Re-load chat model into VRAM. force=true after Generate even without park — Krea often evicts Ollama. */
function warmLlm() { function warmLlm({ force = false } = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
const model = $('sa_model')?.value; const model = $('sa_model')?.value;
if (!model || !state.llmParked || typeof genericRequest !== 'function') { if (!model || typeof genericRequest !== 'function') {
resolve(false);
return;
}
if (!force && !state.llmParked) {
resolve(false); resolve(false);
return; return;
} }
@@ -3749,7 +3841,7 @@
} }
resolve(!!ok); 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); setTimeout(() => finish(false), 180000);
genericRequest('AssistentWarmLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false)); genericRequest('AssistentWarmLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
}); });
@@ -3864,12 +3956,15 @@
const src = await waitForNewImage(prev); const src = await waitForNewImage(prev);
state.generating = false; state.generating = false;
setInterruptVisible(state.busy); setInterruptVisible(state.busy);
// Auto-critique / next Send load the model themselves — skip warm if critique will run. // 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; 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'); startBusyUi('warming');
setStatus('Возвращаю LLM в GPU…'); setStatus('Возвращаю LLM в GPU…');
await warmLlm(); await warmLlm({ force: true });
} }
if (!state.busy) { if (!state.busy) {
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)'); stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
@@ -4053,7 +4148,7 @@
div.appendChild(buildCivitaiCards(civitaiResults)); div.appendChild(buildCivitaiCards(civitaiResults));
} }
box.appendChild(div); box.appendChild(div);
box.scrollTop = box.scrollHeight; scrollMessagesToBottom({ force: true });
return div; return div;
} }
@@ -4071,13 +4166,42 @@
body.innerHTML = '<span class="sa-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="sa-typing-label">Waiting for the model…</span>'; body.innerHTML = '<span class="sa-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="sa-typing-label">Waiting for the model…</span>';
div.appendChild(body); div.appendChild(body);
box.appendChild(div); box.appendChild(div);
box.scrollTop = box.scrollHeight; scrollMessagesToBottom({ force: true });
state.streamEl = div; state.streamEl = div;
state.streamMeta = meta || null; state.streamMeta = meta || null;
state.streamFenceDone = false;
return div; 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) { function appendStreamDelta(delta) {
if (state.streamFenceDone) {
return;
}
if (!state.streamEl) { if (!state.streamEl) {
beginStreamMessage(state.streamMeta || undefined); beginStreamMessage(state.streamMeta || undefined);
} }
@@ -4085,7 +4209,7 @@
if (state.streamEl.classList.contains('sa-typing')) { if (state.streamEl.classList.contains('sa-typing')) {
state.streamEl.classList.remove('sa-typing'); state.streamEl.classList.remove('sa-typing');
state.streamText = ''; state.streamText = '';
setAssistantBody(state.streamEl, ''); setAssistantBody(state.streamEl, '', { live: true });
} }
state.gotDelta = true; state.gotDelta = true;
state.expectColdLoad = false; state.expectColdLoad = false;
@@ -4093,11 +4217,12 @@
setBusyPhase('streaming'); setBusyPhase('streaming');
} }
state.streamText = (state.streamText || '') + (delta || ''); state.streamText = (state.streamText || '') + (delta || '');
setAssistantBody(state.streamEl, state.streamText); if (streamHasClosedPatchFence(state.streamText)) {
const box = $('sa_messages'); state.streamText = trimToClosedPatchFence(state.streamText);
if (box) { state.streamFenceDone = true;
box.scrollTop = box.scrollHeight;
} }
setAssistantBody(state.streamEl, state.streamText, { live: true });
scrollMessagesToBottom();
} }
} }
@@ -4107,6 +4232,7 @@
state.streamEl = null; state.streamEl = null;
state.streamMeta = null; state.streamMeta = null;
state.streamText = ''; state.streamText = '';
state.streamFenceDone = false;
if (!el) { if (!el) {
appendMessage('assistant', fullReply, null, civitaiResults, meta || undefined); appendMessage('assistant', fullReply, null, civitaiResults, meta || undefined);
return; return;
@@ -4131,10 +4257,7 @@
if (civitaiResults && civitaiResults.length) { if (civitaiResults && civitaiResults.length) {
el.appendChild(buildCivitaiCards(civitaiResults)); el.appendChild(buildCivitaiCards(civitaiResults));
} }
const box = $('sa_messages'); scrollMessagesToBottom();
if (box) {
box.scrollTop = box.scrollHeight;
}
} }
function buildCivitaiCards(results) { function buildCivitaiCards(results) {
@@ -4517,7 +4640,7 @@
if (paneW) { if (paneW) {
document.documentElement.style.setProperty('--sa-image-width', paneW); document.documentElement.style.setProperty('--sa-image-width', paneW);
} }
if (view === 'cards' || view === 'chat') { if (view === 'cards' || view === 'chat' || view === 'settings') {
state.view = view; state.view = view;
} }
const boardTab = localStorage.getItem(LS_BOARD_TAB); 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_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_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)); 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; }); fill(LS_VIEW, ui.view, (v) => { state.view = v; });
} }
if (ui.board_tab === 'refs' || ui.board_tab === 'generate') { if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
@@ -4817,16 +4940,26 @@
controlsPointerDown = true; controlsPointerDown = true;
}); });
const endPointer = () => { const endPointer = () => {
const v = Number(input.value);
applyLocal(v);
controlsPointerDown = false; controlsPointerDown = false;
// Discard mid-drag rebuilds that carried stale server defaults — keep local values.
if (pendingControlsRender) { if (pendingControlsRender) {
const pending = pendingControlsRender; const schema = pendingControlsRender.schema;
pendingControlsRender = null; 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('pointerup', endPointer);
input.addEventListener('pointercancel', 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', () => { input.addEventListener('input', () => {
applyLocal(Number(input.value)); applyLocal(Number(input.value));
}); });
@@ -4857,6 +4990,7 @@
setStatus('/остынь только для Leonid'); setStatus('/остынь только для Leonid');
return; return;
} }
state.lastUserControlIntent = true;
const schema = state.config?.controls || {}; const schema = state.config?.controls || {};
if (!schema.horny) { if (!schema.horny) {
setStatus('У этой личности нет слайдера Хорни'); setStatus('У этой личности нет слайдера Хорни');
@@ -4889,6 +5023,7 @@
return; return;
} }
const cur = getControlValue('horny', 35); const cur = getControlValue('horny', 35);
state.lastUserControlIntent = true;
await sendChat({ await sendChat({
skipSlash: true, skipSlash: true,
skipAutoPack: 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) { function fillKnobsFromConfig(data) {
const asst = data?.assistant || state.config?.assistant || {}; const asst = data?.assistant || state.config?.assistant || {};
const exact = data?.exact || state.config?.exact || state.exact || {}; const exact = data?.exact || state.config?.exact || state.exact || {};
@@ -6133,26 +6252,51 @@
} }
function setView(view) { 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 chat = $('sa_view_chat');
const cards = $('sa_view_cards'); const cards = $('sa_view_cards');
const settings = $('sa_view_settings');
if (chat) { if (chat) {
chat.hidden = state.view !== 'chat'; chat.hidden = state.view !== 'chat';
} }
if (cards) { if (cards) {
cards.hidden = state.view !== '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_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
$('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards'); $('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(); saveSettings();
if (state.view === 'cards') { if (state.view === 'cards') {
renderCardsList(); renderCardsList();
} else if (state.llmParked && !state.generating) { } else if (state.view === 'settings') {
// Back in the chat — bring the model home before the user hits Send. setSettingsTab(state.settingsTab || 'behavior');
warmLlm(); } 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() { function refreshPersonas() {
loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => { loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => {
if (data?.personas) { if (data?.personas) {
@@ -7050,7 +7194,7 @@
div.className = 'sa-msg assistant sa-system-note'; div.className = 'sa-msg assistant sa-system-note';
div.textContent = text; div.textContent = text;
box.appendChild(div); box.appendChild(div);
box.scrollTop = box.scrollHeight; scrollMessagesToBottom();
} }
function clipDebug(s, max) { function clipDebug(s, max) {
@@ -7423,6 +7567,7 @@
} }
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) { if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) {
state.lastUserParamIntent = userTextMentionsParams(text); state.lastUserParamIntent = userTextMentionsParams(text);
state.lastUserControlIntent = userTextMentionsControls(text);
state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text); state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text);
} }
@@ -7491,13 +7636,21 @@
const chatEpoch = bumpChatEpoch(); const chatEpoch = bumpChatEpoch();
state.busy = true; state.busy = true;
// This request will keep_alive 15m; clear parked only after we know load started. // If Krea just ran, expectColdLoad stays true until warm / first token — don't clear it here.
// Keep expectColdLoad so the UI can show a real GPU-load message if we just parked.
if (!state.llmParked) {
state.expectColdLoad = false;
}
state.llmParked = false; state.llmParked = false;
setInterruptVisible(true); setInterruptVisible(true);
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'); startBusyUi(state.expectColdLoad ? 'loading' : 'thinking');
saveSettings(); saveSettings();
@@ -8015,6 +8168,7 @@
$('sa_tab_chat')?.addEventListener('click', () => setView('chat')); $('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
$('sa_tab_cards')?.addEventListener('click', () => setView('cards')); $('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_gen')?.addEventListener('click', () => setBoardTab('generate'));
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs')); $('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
$('sa_persona')?.addEventListener('change', onPersonaChanged); $('sa_persona')?.addEventListener('change', onPersonaChanged);
@@ -8025,15 +8179,11 @@
$('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent()); $('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent());
$('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard()); $('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard());
$('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly()); $('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly());
$('sa_btn_settings')?.addEventListener('click', () => { $('sa_btn_settings')?.addEventListener('click', () => {
const s = $('sa_settings'); if (state.view === 'settings') {
if (s) { closeSettings();
if (s.hidden) { } else {
openSettings(state.settingsTab || 'behavior'); openSettings(state.settingsTab || 'behavior');
} else {
closeSettings();
}
} }
}); });
$('sa_settings_close')?.addEventListener('click', () => closeSettings()); $('sa_settings_close')?.addEventListener('click', () => closeSettings());
@@ -8105,8 +8255,7 @@
return; return;
} }
let closed = false; let closed = false;
const settings = $('sa_settings'); if (state.view === 'settings') {
if (settings && !settings.hidden) {
closeSettings(); closeSettings();
closed = true; closed = true;
} }
+15 -3
View File
@@ -1003,12 +1003,24 @@ public partial class SwarmAssistentExtension
patch["_persona_written"] = target; 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) if (patch["controls"] is JObject ctrlVals)
{ {
string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId; string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId;
Config.SaveControlValues(ctrlPid, ctrlVals); JObject schema = Config.LoadControlsSchema(ctrlPid);
patch["_controls_saved"] = true; 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) catch (Exception ex)
+75
View File
@@ -466,6 +466,81 @@ public sealed class AssistentConfig
return result; return result;
} }
/// <summary>
/// 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).
/// </summary>
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?>() ?? 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) public string RenderControlsBlock(string personaId)
{ {
string id = SafeId(personaId) ?? "neutral"; string id = SafeId(personaId) ?? "neutral";
+21
View File
@@ -192,6 +192,10 @@ public partial class SwarmAssistentExtension
string body = await resp.Content.ReadAsStringAsync(); string body = await resp.Content.ReadAsStringAsync();
JObject parsed = JObject.Parse(body); JObject parsed = JObject.Parse(body);
string reply = parsed["message"]?["content"]?.ToString() ?? parsed["response"]?.ToString() ?? ""; string reply = parsed["message"]?["content"]?.ToString() ?? parsed["response"]?.ToString() ?? "";
if (TryTruncateAtCompleteFence(reply, out string cut))
{
reply = cut;
}
return (reply, parsed); return (reply, parsed);
} }
StringBuilder full = new(); StringBuilder full = new();
@@ -215,6 +219,23 @@ public partial class SwarmAssistentExtension
if (!string.IsNullOrEmpty(delta)) if (!string.IsNullOrEmpty(delta))
{ {
full.Append(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) if (onDelta is not null)
{ {
await onDelta(delta); await onDelta(delta);
+45
View File
@@ -106,6 +106,51 @@ public partial class SwarmAssistentExtension
return null; return null;
} }
/// <summary>
/// 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.
/// </summary>
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) static string ExtractSearchQuery(JObject patch)
{ {
if (patch is null) if (patch is null)
+2 -1
View File
@@ -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. 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 ## Live context
@@ -55,6 +55,7 @@ Never write a “JSON Patch” section in prose without an actual fenced ```json
### Patch rules ### Patch rules
- Omit unchanged keys. Prefer omitting Exact-matching `steps`/`cfg`/`sigma_shift`/`aspect`. - 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. - `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. - 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. - **`controls`** — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Example: `"controls": { "horny": 55 }`. Do not invent control ids.
+1
View File
@@ -6,6 +6,7 @@
"Craft first: triggers, aspect, Turbo — horny never replaces technique", "Craft first: triggers, aspect, Turbo — horny never replaces technique",
"Scale appearance/outfits by controls.preference_bias (1…1)", "Scale appearance/outfits by controls.preference_bias (1…1)",
"Scale sexual tone + roleplay fetishes by controls.horny (0…100)", "Scale sexual tone + roleplay fetishes by controls.horny (0…100)",
"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", "Explicit user look/outfit/plot beats personal taste",
"«девушка которая тебе нравится» = YOUR (Leonid) taste, not the user's — unless they said otherwise", "«девушка которая тебе нравится» = 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", "High horny / NSFW plot / craft detail: persona_read shelves [\"roleplay\"] (and humor/craft if needed) — they are not always-on",
+4 -4
View File
@@ -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. 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 ## 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 - **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 - **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) - **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
## Config (bundled + overlay) ## 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. - `settings.json` and persona overlays stay files (layered merge + git). `.assistent.json` cards stay next to weights.
## VRAM handover ## 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 - Before Generate the chat model is unloaded only if **Park LLM** is enabled (`keep_alive: 0`) — default off
- When parked, after Generate the model is warmed again (`keep_alive 15m`) before the UI goes idle - 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 - Embed / memory models are never parked — reloading them would stall every retrieve
## UX ## UX
+1 -1
View File
@@ -36,7 +36,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid"; ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT"; License = "MIT";
Version = "0.10.13"; Version = "0.10.18";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
} }
+81 -77
View File
@@ -46,6 +46,7 @@
<div class="sa-subtabs" role="tablist"> <div class="sa-subtabs" role="tablist">
<button type="button" class="sa-subtab sa-subtab-active" data-view="chat" id="sa_tab_chat">Чат</button> <button type="button" class="sa-subtab sa-subtab-active" data-view="chat" id="sa_tab_chat">Чат</button>
<button type="button" class="sa-subtab" data-view="cards" id="sa_tab_cards">Карточки</button> <button type="button" class="sa-subtab" data-view="cards" id="sa_tab_cards">Карточки</button>
<button type="button" class="sa-subtab" data-view="settings" id="sa_tab_settings">Настройки</button>
</div> </div>
</div> </div>
<div class="sa-chats-panel" id="sa_chats_panel" hidden> <div class="sa-chats-panel" id="sa_chats_panel" hidden>
@@ -73,10 +74,88 @@
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_settings" title="Настройки" aria-label="Настройки"></button> <button type="button" class="basic-button sa-icon-btn" id="sa_btn_settings" title="Настройки" aria-label="Настройки"></button>
</div> </div>
</header> </header>
<div class="sa-settings" id="sa_settings" hidden> <div class="sa-view" id="sa_view_chat">
<div class="sa-messages" id="sa_messages">
<div class="sa-chat-empty" id="sa_chat_empty">
<div class="sa-chat-empty-title">Совместная работа с Krea 2</div>
<div class="sa-chat-empty-hint">Напиши промпт, кинь refs, выбери персону или открой <em>Карточки</em> для LoRA.</div>
</div>
</div>
<div class="sa-livebar" id="sa_livebar" hidden>
<span class="sa-spinner" aria-hidden="true"></span>
<span class="sa-livebar-text" id="sa_livebar_text">Работаю…</span>
<span class="sa-elapsed" id="sa_elapsed"></span>
</div>
<div class="sa-composer" id="sa_composer">
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"></div>
<div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div>
<div class="sa-slash-wrap">
<textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить · /help = команды"></textarea>
<div class="sa-slash-menu" id="sa_slash_menu" hidden role="listbox"></div>
</div>
<div class="sa-composer-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_send">Отправить</button>
<button type="button" class="basic-button sa-btn-gen" id="sa_btn_build_gen" title="Нет патча — Generate с текущим промптом SwarmUI">Собрать + Gen</button>
<button type="button" class="basic-button" id="sa_btn_interrupt" title="Прервать генерацию / чат" hidden>Стоп</button>
<div class="sa-more-wrap" id="sa_clear_more_wrap">
<button type="button" class="basic-button" id="sa_btn_clear" title="Очистить чат">Очистить</button>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_clear_more" title="Ещё варианты очистки" aria-expanded="false"></button>
<div class="sa-more-menu" id="sa_clear_more_menu" hidden role="menu">
<button type="button" class="sa-more-item" id="sa_btn_clear_confirm" role="menuitem">Очистить весь чат…</button>
<button type="button" class="sa-more-item" id="sa_btn_clear_patches" role="menuitem">Убрать только патчи</button>
</div>
</div>
<span class="sa-status" id="sa_status"></span>
</div>
</div>
</div>
<div class="sa-view" id="sa_view_cards" hidden>
<div class="sa-cards-layout">
<div class="sa-cards-list-pane">
<div class="sa-cards-filter">
<select id="sa_cards_kind" class="sa-select">
<option value="all">Все</option>
<option value="checkpoint">Checkpoints</option>
<option value="lora">LoRAs</option>
</select>
<button type="button" class="basic-button" id="sa_btn_cards_refresh">Обновить</button>
</div>
<div class="sa-cards-list" id="sa_cards_list"></div>
</div>
<div class="sa-cards-editor-pane">
<div class="sa-cards-editor-head">
<strong id="sa_card_title">Выбери модель</strong>
<span class="sa-card-badge" id="sa_card_badge" hidden></span>
</div>
<div class="sa-card-previews" id="sa_card_previews" hidden></div>
<div class="sa-card-form" id="sa_card_form">
<label>Triggers <input type="text" id="sa_card_triggers" placeholder="слово1, слово2" /></label>
<label>Weight <input type="number" id="sa_card_weight" step="0.05" min="0" max="2" value="0.8" /></label>
<label>When <textarea id="sa_card_when" rows="2" placeholder="Когда использовать"></textarea></label>
<label>Avoid <textarea id="sa_card_avoid" rows="2" placeholder="Чего избегать"></textarea></label>
<label>Prompt hint <textarea id="sa_card_hint" rows="2" placeholder="Подсказка для промпта"></textarea></label>
<label>Notes <textarea id="sa_card_notes" rows="2" placeholder="Заметки"></textarea></label>
<label>Civitai URL <input type="text" id="sa_card_url" placeholder="https://civitai…" /></label>
<label class="sa-card-json-toggle"><input type="checkbox" id="sa_card_show_json" /> Показать JSON</label>
<textarea id="sa_card_json" rows="8" hidden placeholder="Card JSON…" spellcheck="false"></textarea>
</div>
<div class="sa-cards-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_card_to_chat" title="В чат: используй эту модель/LoRA">В чат</button>
<button type="button" class="basic-button" id="sa_btn_card_meta">Загрузить Civitai meta</button>
<button type="button" class="basic-button" id="sa_btn_card_generate">Сгенерировать с Assistent</button>
<button type="button" class="basic-button" id="sa_btn_card_save">Сохранить карточку</button>
<button type="button" class="basic-button" id="sa_btn_card_wanted" title="В очередь на следующий up">В wanted</button>
<span class="sa-status" id="sa_card_status"></span>
</div>
</div>
</div>
</div>
<div class="sa-view" id="sa_view_settings" hidden>
<div class="sa-settings" id="sa_settings">
<div class="sa-settings-head"> <div class="sa-settings-head">
<strong>Настройки</strong> <strong>Настройки</strong>
<button type="button" class="basic-button sa-icon-btn" id="sa_settings_close" title="Закрыть" aria-label="Закрыть"></button> <button type="button" class="basic-button" id="sa_settings_close" title="К чату">← Чат</button>
</div> </div>
<div class="sa-settings-tabs" role="tablist" aria-label="Разделы настроек"> <div class="sa-settings-tabs" role="tablist" aria-label="Разделы настроек">
<button type="button" class="sa-stab sa-stab-active" data-stab="behavior" role="tab" aria-selected="true">Поведение</button> <button type="button" class="sa-stab sa-stab-active" data-stab="behavior" role="tab" aria-selected="true">Поведение</button>
@@ -215,83 +294,8 @@
</div> </div>
</div> </div>
<div class="sa-view" id="sa_view_chat">
<div class="sa-messages" id="sa_messages">
<div class="sa-chat-empty" id="sa_chat_empty">
<div class="sa-chat-empty-title">Совместная работа с Krea 2</div>
<div class="sa-chat-empty-hint">Напиши промпт, кинь refs, выбери персону или открой <em>Карточки</em> для LoRA.</div>
</div>
</div>
<div class="sa-livebar" id="sa_livebar" hidden>
<span class="sa-spinner" aria-hidden="true"></span>
<span class="sa-livebar-text" id="sa_livebar_text">Работаю…</span>
<span class="sa-elapsed" id="sa_elapsed"></span>
</div>
<div class="sa-composer" id="sa_composer">
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"></div>
<div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div>
<div class="sa-slash-wrap">
<textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить · /help = команды"></textarea>
<div class="sa-slash-menu" id="sa_slash_menu" hidden role="listbox"></div>
</div>
<div class="sa-composer-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_send">Отправить</button>
<button type="button" class="basic-button sa-btn-gen" id="sa_btn_build_gen" title="Нет патча — Generate с текущим промптом SwarmUI">Собрать + Gen</button>
<button type="button" class="basic-button" id="sa_btn_interrupt" title="Прервать генерацию / чат" hidden>Стоп</button>
<div class="sa-more-wrap" id="sa_clear_more_wrap">
<button type="button" class="basic-button" id="sa_btn_clear" title="Очистить чат">Очистить</button>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_clear_more" title="Ещё варианты очистки" aria-expanded="false"></button>
<div class="sa-more-menu" id="sa_clear_more_menu" hidden role="menu">
<button type="button" class="sa-more-item" id="sa_btn_clear_confirm" role="menuitem">Очистить весь чат…</button>
<button type="button" class="sa-more-item" id="sa_btn_clear_patches" role="menuitem">Убрать только патчи</button>
</div>
</div>
<span class="sa-status" id="sa_status"></span>
</div>
</div>
</div> </div>
<div class="sa-view" id="sa_view_cards" hidden>
<div class="sa-cards-layout">
<div class="sa-cards-list-pane">
<div class="sa-cards-filter">
<select id="sa_cards_kind" class="sa-select">
<option value="all">Все</option>
<option value="checkpoint">Checkpoints</option>
<option value="lora">LoRAs</option>
</select>
<button type="button" class="basic-button" id="sa_btn_cards_refresh">Обновить</button>
</div>
<div class="sa-cards-list" id="sa_cards_list"></div>
</div>
<div class="sa-cards-editor-pane">
<div class="sa-cards-editor-head">
<strong id="sa_card_title">Выбери модель</strong>
<span class="sa-card-badge" id="sa_card_badge" hidden></span>
</div>
<div class="sa-card-previews" id="sa_card_previews" hidden></div>
<div class="sa-card-form" id="sa_card_form">
<label>Triggers <input type="text" id="sa_card_triggers" placeholder="слово1, слово2" /></label>
<label>Weight <input type="number" id="sa_card_weight" step="0.05" min="0" max="2" value="0.8" /></label>
<label>When <textarea id="sa_card_when" rows="2" placeholder="Когда использовать"></textarea></label>
<label>Avoid <textarea id="sa_card_avoid" rows="2" placeholder="Чего избегать"></textarea></label>
<label>Prompt hint <textarea id="sa_card_hint" rows="2" placeholder="Подсказка для промпта"></textarea></label>
<label>Notes <textarea id="sa_card_notes" rows="2" placeholder="Заметки"></textarea></label>
<label>Civitai URL <input type="text" id="sa_card_url" placeholder="https://civitai…" /></label>
<label class="sa-card-json-toggle"><input type="checkbox" id="sa_card_show_json" /> Показать JSON</label>
<textarea id="sa_card_json" rows="8" hidden placeholder="Card JSON…" spellcheck="false"></textarea>
</div>
<div class="sa-cards-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_card_to_chat" title="В чат: используй эту модель/LoRA">В чат</button>
<button type="button" class="basic-button" id="sa_btn_card_meta">Загрузить Civitai meta</button>
<button type="button" class="basic-button" id="sa_btn_card_generate">Сгенерировать с Assistent</button>
<button type="button" class="basic-button" id="sa_btn_card_save">Сохранить карточку</button>
<button type="button" class="basic-button" id="sa_btn_card_wanted" title="В очередь на следующий up">В wanted</button>
<span class="sa-status" id="sa_card_status"></span>
</div>
</div>
</div>
</div>
</section> </section>
</div> </div>
</div> </div>